diff --git a/.gitignore b/.gitignore index 14e883b19..0839516bd 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ test-config/ typesense-data/ meilisearch-data/ .expert/ + +# SST auto-generated type stubs (from monorepo builds that scan package.json dirs) +sst-env.d.ts diff --git a/Dockerfile b/Dockerfile index 7040d9c34..b47aae904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,8 +86,8 @@ ENV RELEASE_VERSION=${RELEASE_VERSION} # Compile the release RUN mix compile -# Ensure stacktraces we send to Sentry are complete -RUN mix sentry.package_source_code +# Ensure stacktraces we send to Sentry are complete (skip for self-hosted — no DSN) +RUN if [ "$SELF_HOSTED" != "1" ]; then mix sentry.package_source_code; fi # Changes to config/runtime.exs don't require recompiling the code COPY config/runtime.exs config/ diff --git a/config/prod.exs b/config/prod.exs index e9ce90920..e17a0acca 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -8,8 +8,14 @@ import Config self_hosted = System.get_env("SELF_HOSTED", "0") in ~w(1 true) +# Empty string DSN crashes Sentry — treat "" as nil for self-hosted builds +sentry_dsn = case System.get_env("SENTRY_DSN") do + "" -> nil + dsn -> dsn +end + config :sentry, - dsn: System.get_env("SENTRY_DSN"), + dsn: sentry_dsn, release: System.get_env("RELEASE_VERSION") config :sequin, Sequin.ConsoleLogger, drop_metadata_keys: [:mfa] diff --git a/lib/sequin/accounts/accounts.ex b/lib/sequin/accounts/accounts.ex index 26ce7f585..037823f08 100644 --- a/lib/sequin/accounts/accounts.ex +++ b/lib/sequin/accounts/accounts.ex @@ -97,6 +97,16 @@ defmodule Sequin.Accounts do """ def get_user!(id), do: Repo.get!(User, id) + def get_first_user do + User + |> Ecto.Query.first() + |> Repo.one() + |> case do + nil -> nil + user -> Repo.preload(user, [:accounts, :accounts_users]) + end + end + def get_user_with_preloads!(user_id) do User |> Repo.get!(user_id) diff --git a/lib/sequin/consumers/meilisearch_sink.ex b/lib/sequin/consumers/meilisearch_sink.ex index b7a926052..9f3f6045a 100644 --- a/lib/sequin/consumers/meilisearch_sink.ex +++ b/lib/sequin/consumers/meilisearch_sink.ex @@ -5,7 +5,7 @@ defmodule Sequin.Consumers.MeilisearchSink do import Ecto.Changeset - @derive {Jason.Encoder, only: [:endpoint_url, :index_name, :primary_key]} + @derive {Jason.Encoder, only: [:endpoint_url, :index_name, :primary_key, :document_mode]} @derive {Inspect, except: [:api_key]} @primary_key false @@ -18,6 +18,7 @@ defmodule Sequin.Consumers.MeilisearchSink do field(:batch_size, :integer, default: 100) field(:timeout_seconds, :integer, default: 5) field(:routing_mode, Ecto.Enum, values: [:dynamic, :static]) + field(:document_mode, Ecto.Enum, values: [:replace, :update], default: :replace) end def changeset(struct, params) do @@ -29,7 +30,8 @@ defmodule Sequin.Consumers.MeilisearchSink do :api_key, :batch_size, :timeout_seconds, - :routing_mode + :routing_mode, + :document_mode ]) |> validate_required([:endpoint_url, :api_key]) |> validate_routing() diff --git a/lib/sequin/sinks/meilisearch/client.ex b/lib/sequin/sinks/meilisearch/client.ex index 26effd614..3e9fec1e6 100644 --- a/lib/sequin/sinks/meilisearch/client.ex +++ b/lib/sequin/sinks/meilisearch/client.ex @@ -110,7 +110,15 @@ defmodule Sequin.Sinks.Meilisearch.Client do body: jsonl ) - case Req.post(req) do + # :replace (default) uses POST = add or replace (full document replacement) + # :update uses PUT = add or update (partial merge — only overwrites fields present) + result = + case sink.document_mode do + :update -> Req.put(req) + _ -> Req.post(req) + end + + case result do {:ok, %{body: %{"taskUid" => task_id}}} -> wait_for_task(sink, task_id) diff --git a/lib/sequin/transforms/transforms.ex b/lib/sequin/transforms/transforms.ex index ebd12dffc..e4d6c39e1 100644 --- a/lib/sequin/transforms/transforms.ex +++ b/lib/sequin/transforms/transforms.ex @@ -413,7 +413,8 @@ defmodule Sequin.Transforms do index_name: sink.index_name, primary_key: sink.primary_key, api_key: SensitiveValue.new(sink.api_key, show_sensitive), - timeout_seconds: sink.timeout_seconds + timeout_seconds: sink.timeout_seconds, + document_mode: if(sink.document_mode != :replace, do: to_string(sink.document_mode)) }) end @@ -1305,7 +1306,8 @@ defmodule Sequin.Transforms do primary_key: attrs["primary_key"], api_key: attrs["api_key"], batch_size: attrs["batch_size"], - timeout_seconds: attrs["timeout_seconds"] + timeout_seconds: attrs["timeout_seconds"], + document_mode: attrs["document_mode"] }} end diff --git a/lib/sequin_web/user_auth.ex b/lib/sequin_web/user_auth.ex index 2f980b44b..38cb50997 100644 --- a/lib/sequin_web/user_auth.ex +++ b/lib/sequin_web/user_auth.ex @@ -206,12 +206,19 @@ defmodule SequinWeb.UserAuth do if socket.assigns.current_user do {:cont, socket} else - socket = - socket - |> Phoenix.LiveView.put_flash(:toast, %{kind: :error, title: "Please log in to continue."}) - |> Phoenix.LiveView.redirect(to: ~p"/login") + if auth_disabled?() do + case Accounts.get_first_user() do + nil -> {:cont, socket} + user -> {:cont, Phoenix.Component.assign(socket, :current_user, user)} + end + else + socket = + socket + |> Phoenix.LiveView.put_flash(:toast, %{kind: :error, title: "Please log in to continue."}) + |> Phoenix.LiveView.redirect(to: ~p"/login") - {:halt, socket} + {:halt, socket} + end end end @@ -279,20 +286,24 @@ defmodule SequinWeb.UserAuth do if conn.assigns[:current_user] do conn else - {title, redirect_to} = - case Keyword.get(opts, :unauthenticated_redirect, :login) do - :login -> - {"Please log in to continue.", ~p"/login"} + if auth_disabled?() do + auto_login_default_user(conn) + else + {title, redirect_to} = + case Keyword.get(opts, :unauthenticated_redirect, :login) do + :login -> + {"Please log in to continue.", ~p"/login"} - :register -> - {"Please register to continue.", ~p"/register"} - end + :register -> + {"Please register to continue.", ~p"/register"} + end - conn - |> put_flash(:toast, %{kind: :error, title: title}) - |> maybe_store_return_to() - |> redirect(to: redirect_to) - |> halt() + conn + |> put_flash(:toast, %{kind: :error, title: title}) + |> maybe_store_return_to() + |> redirect(to: redirect_to) + |> halt() + end end end @@ -309,4 +320,23 @@ defmodule SequinWeb.UserAuth do defp maybe_store_return_to(conn), do: conn defp signed_in_path(_conn), do: ~p"/" + + defp auth_disabled? do + System.get_env("AUTH_DISABLED") in ~w(true 1) + end + + defp auto_login_default_user(conn) do + case Accounts.get_first_user() do + nil -> + conn + + user -> + token = Accounts.generate_user_session_token(user) + + conn + |> renew_session() + |> put_token_in_session(token) + |> assign(:current_user, user) + end + end end diff --git a/test/sequin/meilisearch_client_test.exs b/test/sequin/meilisearch_client_test.exs index d6441c692..46071d589 100644 --- a/test/sequin/meilisearch_client_test.exs +++ b/test/sequin/meilisearch_client_test.exs @@ -67,6 +67,59 @@ defmodule Sequin.Sinks.Meilisearch.ClientTest do end end + describe "import_documents/3 with document_mode: :update" do + @update_sink %MeilisearchSink{ + type: :meilisearch, + endpoint_url: "http://127.0.0.1:7700", + index_name: "test", + primary_key: "id", + api_key: "token", + document_mode: :update + } + + test "uses PUT when document_mode is :update" do + records = [SinkFactory.meilisearch_record()] + + Req.Test.expect(Client, fn conn -> + assert conn.method == "PUT" + assert conn.request_path == "/indexes/test/documents" + + Req.Test.json(conn, %{"taskUid" => 1}) + end) + + Req.Test.expect(Client, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/tasks/1" + + response_data = %{"status" => "succeeded"} + send_gzipped_response(conn, 200, response_data) + end) + + assert :ok = Client.import_documents(@update_sink, "test", records) + end + + test "uses POST when document_mode is :replace (default)" do + records = [SinkFactory.meilisearch_record()] + + Req.Test.expect(Client, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/indexes/test/documents" + + Req.Test.json(conn, %{"taskUid" => 1}) + end) + + Req.Test.expect(Client, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/tasks/1" + + response_data = %{"status" => "succeeded"} + send_gzipped_response(conn, 200, response_data) + end) + + assert :ok = Client.import_documents(@sink, "test", records) + end + end + describe "delete_documents/2" do test "successfully delete batch" do records = [SinkFactory.meilisearch_record(), SinkFactory.meilisearch_record()] diff --git a/test/sequin/meilisearch_sink_test.exs b/test/sequin/meilisearch_sink_test.exs index 6f797d602..c1b280aae 100644 --- a/test/sequin/meilisearch_sink_test.exs +++ b/test/sequin/meilisearch_sink_test.exs @@ -55,5 +55,21 @@ defmodule Sequin.Consumers.MeilisearchSinkTest do refute :index_name in changeset.changes end + + test "defaults document_mode to :replace", %{valid_params: params} do + changeset = MeilisearchSink.changeset(%MeilisearchSink{}, params) + assert Ecto.Changeset.get_field(changeset, :document_mode) == :replace + end + + test "accepts document_mode :update", %{valid_params: params} do + changeset = MeilisearchSink.changeset(%MeilisearchSink{}, Map.put(params, :document_mode, :update)) + assert Sequin.Error.errors_on(changeset) == %{} + assert Ecto.Changeset.get_field(changeset, :document_mode) == :update + end + + test "rejects invalid document_mode", %{valid_params: params} do + changeset = MeilisearchSink.changeset(%MeilisearchSink{}, Map.put(params, :document_mode, :invalid)) + assert Sequin.Error.errors_on(changeset)[:document_mode] != nil + end end end