From e4ddabfa409fa3a783f2b12fad501e8c955597b0 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Mon, 3 Aug 2026 13:28:02 -0600 Subject: [PATCH 1/2] fix: support associations without an id `maybe_remove_from_association/4` dereferenced `.id` on every member of a loaded has_many, which raises a KeyError for join style schemas with a composite primary key. The filter ran even when nothing was being removed, so any sync message crashed a LiveView with such an association loaded. Compare records using `LiveSync.lookup_info/1` instead, which is also what keys the updates map. Schemas without a `Watch` impl look up as `nil` and are never removed. `sync/4` had the same assumption baked in and would raise for any schema deriving `Watch` with a custom `id` option. Co-Authored-By: Claude Opus 5 --- lib/live_sync/replication_test.exs | 2 +- lib/live_sync/socket.ex | 8 ++++---- lib/live_sync/socket_test.exs | 20 ++++++++++++++++++++ lib/test_helper.exs | 9 +++++++++ mix.exs | 2 +- test/support/example.ex | 1 + test/support/example_tag.ex | 13 +++++++++++++ test/support/live_page.ex | 5 +++-- 8 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 test/support/example_tag.ex diff --git a/lib/live_sync/replication_test.exs b/lib/live_sync/replication_test.exs index ed3d665..f661a22 100644 --- a/lib/live_sync/replication_test.exs +++ b/lib/live_sync/replication_test.exs @@ -7,7 +7,7 @@ defmodule LiveSync.ReplicationTest do alias LiveSync.Replication alias LiveSync.Repo - @moduletag cleanup: ["ignored", "examples"] + @moduletag cleanup: ["example_tags", "ignored", "examples"] setup do LiveSync.start_link(repo: Repo, otp_app: :live_sync) diff --git a/lib/live_sync/socket.ex b/lib/live_sync/socket.ex index 6c0e3fe..8432988 100644 --- a/lib/live_sync/socket.ex +++ b/lib/live_sync/socket.ex @@ -69,10 +69,10 @@ defmodule LiveSync.Socket do :delete %{} = value -> - {schema, id} = LiveSync.lookup_info(value) + lookup = LiveSync.lookup_info(value) Enum.find_value(operations, fn {op, record} -> - if record.__struct__ == schema and record.id == id do + if LiveSync.lookup_info(record) == lookup do op end end) @@ -206,9 +206,9 @@ defmodule LiveSync.Socket do update.__struct__ == assoc.related and Map.get(update, assoc.related_key) != Map.get(parent, assoc.owner_key) end) - |> Enum.map(fn {_lookup, {_op, update}} -> update.id end) + |> Enum.map(fn {lookup, {_op, _update}} -> lookup end) - Enum.filter(list, &(is_map(&1) && &1.id not in records_to_remove)) + Enum.filter(list, &(is_map(&1) && LiveSync.lookup_info(&1) not in records_to_remove)) end defp maybe_remove_from_association(record, _parent, _assoc, _updates), do: record diff --git a/lib/live_sync/socket_test.exs b/lib/live_sync/socket_test.exs index fe63d5c..7e6610e 100644 --- a/lib/live_sync/socket_test.exs +++ b/lib/live_sync/socket_test.exs @@ -2,6 +2,7 @@ defmodule LiveSync.SocketTest do use LiveSync.ConnCase alias LiveSync.Example + alias LiveSync.ExampleTag alias LiveSync.Ignored alias LiveSync.Repo @@ -179,6 +180,25 @@ defmodule LiveSync.SocketTest do ] == Floki.find(parsed_html, ".data-child-name") end + test "works with associations without an id", %{conn: conn} do + example = Repo.insert!(%Example{organization_id: 1, name: "replication", enabled: false}) + Repo.insert!(%ExampleTag{name: "tag", example_id: example.id}) + + {:ok, view, html} = conn |> get("/#{example.id}") |> live() + + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find(".data-tag-name") |> Floki.text() == "tag" + + Repo.update!(change(example, name: "more replication")) + + assert_receive :synced + html = render(view) + + parsed_html = Floki.parse_document!(html) + assert parsed_html |> Floki.find("#data-name") |> Floki.text() == "more replication" + assert parsed_html |> Floki.find(".data-tag-name") |> Floki.text() == "tag" + end + test "ignores non-watched schemas", %{conn: conn} do example = Repo.insert!(%Example{organization_id: 1, name: "replication", enabled: false}) ignore = Repo.insert!(%Ignored{organization_id: 1, name: "ignore", example_id: example.id}) diff --git a/lib/test_helper.exs b/lib/test_helper.exs index 0554f2a..9aaffac 100644 --- a/lib/test_helper.exs +++ b/lib/test_helper.exs @@ -1,6 +1,7 @@ LiveSync.Repo.start_link() LiveSync.Endpoint.start_link() +LiveSync.Repo.query!("DROP TABLE IF EXISTS example_tags;") LiveSync.Repo.query!("DROP TABLE IF EXISTS ignored;") LiveSync.Repo.query!("DROP TABLE IF EXISTS examples;") LiveSync.Repo.query("DROP PUBLICATION live_sync;") @@ -28,6 +29,14 @@ CREATE TABLE ignored ( ); """) +LiveSync.Repo.query!(""" +CREATE TABLE example_tags ( + name text, + example_id bytea REFERENCES examples(id), + PRIMARY KEY (name, example_id) +); +""") + Ecto.Migration.Runner.run(LiveSync.Repo, LiveSync.Repo.config(), 1, LiveSync.Migration, :forward, :up, :up, []) ExUnit.start(capture_log: true) diff --git a/mix.exs b/mix.exs index be990dd..1af102e 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule LiveSync.MixProject do def project do [ app: :live_sync, - version: "0.1.11", + version: "0.1.12", elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/support/example.ex b/test/support/example.ex index cd3c379..31b98ef 100644 --- a/test/support/example.ex +++ b/test/support/example.ex @@ -26,6 +26,7 @@ defmodule LiveSync.Example do belongs_to :parent, LiveSync.Example, type: :binary_id, foreign_key: :parent_id has_many :children, LiveSync.Example, foreign_key: :parent_id has_many :ignored, LiveSync.Ignored, foreign_key: :example_id + has_many :example_tags, LiveSync.ExampleTag, foreign_key: :example_id end def changeset(struct \\ %__MODULE__{}, params) do diff --git a/test/support/example_tag.ex b/test/support/example_tag.ex new file mode 100644 index 0000000..24697f4 --- /dev/null +++ b/test/support/example_tag.ex @@ -0,0 +1,13 @@ +defmodule LiveSync.ExampleTag do + @moduledoc false + use Ecto.Schema + + # a join style schema with a composite primary key and no `id` field + @primary_key false + + schema "example_tags" do + field :name, :string, primary_key: true + + belongs_to :example, LiveSync.Example, type: :binary_id, foreign_key: :example_id, primary_key: true + end +end diff --git a/test/support/live_page.ex b/test/support/live_page.ex index 5e48ff1..aa82cca 100644 --- a/test/support/live_page.ex +++ b/test/support/live_page.ex @@ -12,7 +12,7 @@ defmodule LiveSync.LivePage do alias LiveSync.Repo def mount(%{"id" => id}, session, socket) do - data = LiveSync.Example |> Repo.get!(id) |> Repo.preload([:parent, :children, :ignored]) + data = LiveSync.Example |> Repo.get!(id) |> Repo.preload([:parent, :children, :ignored, :example_tags]) {:ok, assign(socket, examples: [data], data: data, test: session["test"])} end @@ -23,7 +23,7 @@ defmodule LiveSync.LivePage do end def sync(:data, value, socket) do - data = Repo.preload(value, [:parent, :children]) + data = Repo.preload(value, [:parent, :children, :example_tags]) assign(socket, data: data) end @@ -36,6 +36,7 @@ defmodule LiveSync.LivePage do

{@data.parent.name}

{child.name}

{ignored.name}

+

{tag.name}

From 6b6edf031907b48c0749f1c27d3e26294f29ca41 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Mon, 3 Aug 2026 13:36:05 -0600 Subject: [PATCH 2/2] test: don't re-preload example_tags in sync/3 Already loaded associations survive the traversal, so the test page does not need to preload them again. Not re-preloading also makes the regression test stronger: it now proves the composite primary key association is carried through traverse_associations/3 rather than being reloaded after it. Co-Authored-By: Claude Opus 5 --- test/support/live_page.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/support/live_page.ex b/test/support/live_page.ex index aa82cca..09b5190 100644 --- a/test/support/live_page.ex +++ b/test/support/live_page.ex @@ -23,7 +23,7 @@ defmodule LiveSync.LivePage do end def sync(:data, value, socket) do - data = Repo.preload(value, [:parent, :children, :example_tags]) + data = Repo.preload(value, [:parent, :children]) assign(socket, data: data) end