From a6cac6ced597d0972e5535be4e5e39d1e8dd3297 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:53:09 +0200 Subject: [PATCH] web: fix node duplication through message synthetization (#757) * web: fix node duplication through message synthetization * web: fix edge case * web: address review comments --- .../application/data_processing.rb | 72 +++++- web/lib/potato_mesh/application/database.rb | 50 ++++ web/spec/data_processing_spec.rb | 235 ++++++++++++++++++ web/spec/database_spec.rb | 173 +++++++++++++ 4 files changed, 524 insertions(+), 6 deletions(-) diff --git a/web/lib/potato_mesh/application/data_processing.rb b/web/lib/potato_mesh/application/data_processing.rb index aa0fc8a..d3145eb 100644 --- a/web/lib/potato_mesh/application/data_processing.rb +++ b/web/lib/potato_mesh/application/data_processing.rb @@ -467,12 +467,17 @@ module PotatoMesh AND NOT (COALESCE(nodes.synthetic,0) = 0 AND excluded.synthetic = 1) SQL - # When a real (non-synthetic) node is upserted with a known long - # name, migrate any synthetic placeholder rows that share that name. - # This fires when the MeshCore device finally receives the sender's - # contact advertisement, resolving the placeholder to a real node ID. - if synthetic == 0 && long_name && !long_name.empty? - merge_synthetic_nodes(db, node_id, long_name) + # Reconcile synthetic placeholder rows with their real counterparts + # whenever a MeshCore node is upserted. Both directions must fire — + # the arrival order of chat messages vs contact advertisements is + # not guaranteed and may differ across co-operating ingestors that + # share this database. See issue #755. + if protocol == "meshcore" && long_name && !long_name.empty? + if synthetic == 0 + merge_synthetic_nodes(db, node_id, long_name) + else + merge_into_real_node(db, node_id, long_name) + end end end end @@ -494,6 +499,17 @@ module PotatoMesh # @param long_name [String] long name to match against synthetic rows. # @return [void] def merge_synthetic_nodes(db, real_node_id, long_name) + # long_name is user-editable and not unique across pubkeys — two real + # meshcore devices can legitimately share the same display name. When + # that happens we cannot tell which real node a given chat-derived + # synthetic was acting as placeholder for, so any merge would risk + # mis-attributing messages. Bail out and leave the synthetic intact. + other_real = db.execute( + "SELECT 1 FROM nodes WHERE long_name = ? AND synthetic = 0 AND protocol = 'meshcore' AND node_id != ? LIMIT 1", + [long_name, real_node_id], + ).first + return if other_real + synthetic_ids = db.execute( "SELECT node_id FROM nodes WHERE long_name = ? AND synthetic = 1 AND protocol = 'meshcore' AND node_id != ?", [long_name, real_node_id], @@ -511,6 +527,50 @@ module PotatoMesh end end + # Reverse of +merge_synthetic_nodes+: when a synthetic placeholder is + # upserted for a MeshCore sender whose real contact advertisement has + # already been stored (e.g. by a co-operating ingestor that saw the + # advertisement first), migrate any messages from the synthetic id to the + # real id and drop the synthetic row. + # + # Fixes duplication bug #755 where a chat-derived synthetic node and a + # pubkey-derived real node coexisted because the forward merge only fired + # on real-node upserts and never back-filled late-arriving synthetics. + # + # @param db [SQLite3::Database] open database connection. + # @param synthetic_node_id [String] canonical node ID of the synthetic placeholder being upserted. + # @param long_name [String] long name to match against existing real rows. + # @return [void] + def merge_into_real_node(db, synthetic_node_id, long_name) + # Index by [0] rather than the hash key so this works whether the db + # handle was opened with results_as_hash = true or not. + real_rows = db.execute( + "SELECT node_id FROM nodes WHERE long_name = ? AND synthetic = 0 AND protocol = 'meshcore' AND node_id != ? LIMIT 2", + [long_name, synthetic_node_id], + ) + # Ambiguous name: two distinct real meshcore devices share this + # long_name. The synthetic placeholder could legitimately represent + # either, so we cannot pick one without risking mis-attribution. Leave + # the synthetic in place; an operator can resolve the duplicate + # manually. + return if real_rows.length > 1 + + row = real_rows.first + return unless row + + real_node_id = row[0] + return unless real_node_id + + db.execute( + "UPDATE messages SET from_id = ? WHERE from_id = ?", + [real_node_id, synthetic_node_id], + ) + db.execute( + "DELETE FROM nodes WHERE node_id = ? AND synthetic = 1", + [synthetic_node_id], + ) + end + def require_token! token = ENV["API_TOKEN"] provided = request.env["HTTP_AUTHORIZATION"].to_s.sub(/^Bearer\s+/i, "") diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index 7f9f762..22cda72 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -155,6 +155,56 @@ module PotatoMesh db.execute("UPDATE nodes SET protocol = 'meshcore' WHERE long_name LIKE 'Meshcore %' AND protocol = 'meshtastic'") db.execute("UPDATE nodes SET role = 'COMPANION' WHERE protocol = 'meshcore' AND role = 'CLIENT_HIDDEN'") end + + # Backfill #755: reconcile meshcore synthetic placeholder rows that + # share a long_name with a real (pubkey-derived) meshcore node. + # Earlier releases only merged synthetics at real-node upsert time; + # if a synthetic arrived after the real was already stored (common + # with co-operating ingestors that share this DB), the duplicate + # persisted. Migrate messages to the real id, then drop the stray + # synthetic rows. Idempotent — the EXISTS guards make repeated runs + # a no-op. + if node_columns.include?("protocol") && node_columns.include?("synthetic") + # Only collapse synthetics whose long_name resolves to *exactly* + # one real meshcore node. When two real devices share a + # long_name, the placeholder is ambiguous — merging would risk + # mis-attributing historical chat messages to the wrong radio. + # Wrapped in a single transaction so that a crash between the + # UPDATE and DELETE cannot leave messages redirected without the + # corresponding synthetic row cleared. + db.transaction do + db.execute(<<~SQL) + UPDATE messages + SET from_id = ( + SELECT real.node_id FROM nodes real + JOIN nodes synth ON synth.long_name = real.long_name + WHERE synth.node_id = messages.from_id + AND synth.synthetic = 1 AND synth.protocol = 'meshcore' + AND real.synthetic = 0 AND real.protocol = 'meshcore' + LIMIT 1 + ) + WHERE from_id IN ( + SELECT synth.node_id FROM nodes synth + WHERE synth.synthetic = 1 AND synth.protocol = 'meshcore' + AND ( + SELECT COUNT(*) FROM nodes real + WHERE real.long_name = synth.long_name + AND real.synthetic = 0 AND real.protocol = 'meshcore' + ) = 1 + ) + SQL + db.execute(<<~SQL) + DELETE FROM nodes + WHERE synthetic = 1 AND protocol = 'meshcore' + AND ( + SELECT COUNT(*) FROM nodes real + WHERE real.long_name = nodes.long_name + AND real.synthetic = 0 AND real.protocol = 'meshcore' + AND real.node_id != nodes.node_id + ) = 1 + SQL + end + end end message_table_exists = db.get_first_value( diff --git a/web/spec/data_processing_spec.rb b/web/spec/data_processing_spec.rb index 1b99d28..eb8470c 100644 --- a/web/spec/data_processing_spec.rb +++ b/web/spec/data_processing_spec.rb @@ -523,6 +523,144 @@ RSpec.describe PotatoMesh::App::DataProcessing do expect(remaining).to be_empty db.close end + + # Regression tests for issue #755: synthetic arrives after the real node + # was already stored (e.g. by a co-operating ingestor that saw the contact + # advertisement first). The reverse merge must fire at synthetic-upsert + # time so duplicates never persist. + it "collapses a synthetic upsert when a real meshcore node with the same long_name already exists" do + db = open_db + real_id = "!real8888" + synth_id = "!synth888" + dp.upsert_node(db, real_id, { + "lastHeard" => now - 100, + "user" => { "longName" => "Heidi", "shortName" => " H ", "role" => "COMPANION", "publicKey" => "88" * 32 }, + }, protocol: "meshcore") + # Pre-existing message with synthetic id (simulates a chat message that + # was stored before the ingestor learned about the real contact). + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [71, now - 50, "2025-01-01T00:00:00Z", synth_id, "^all", "meshcore"], + ) + dp.upsert_node(db, synth_id, { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Heidi", "shortName" => "", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + # Synthetic must not linger as a second row. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first).to be_nil + # Real node still there. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_id]).first).not_to be_nil + # Pre-existing message redirected. + expect(db.execute("SELECT from_id FROM messages WHERE id = 71").first[0]).to eq(real_id) + db.close + end + + it "leaves a synthetic in place when no real meshcore peer exists yet" do + db = open_db + synth_id = "!synth999" + dp.upsert_node(db, synth_id, { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Ivan", "shortName" => "", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + row = db.execute("SELECT synthetic FROM nodes WHERE node_id = ?", [synth_id]).first + expect(row).not_to be_nil + expect(row[0]).to eq(1) + db.close + end + + it "does not merge across protocols — a synthetic meshtastic peer is not treated as a match" do + db = open_db + real_meshtastic = "!realmtA1" + synth_meshcore = "!synthmcA" + # Real meshtastic node sharing the same long_name must not be mistaken + # for a reverse-merge target when a meshcore synthetic is upserted. + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [real_meshtastic, "Judy", "meshtastic", 0, now - 100, now - 100], + ) + dp.upsert_node(db, synth_meshcore, { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Judy", "shortName" => "", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + # Both rows must coexist. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_meshtastic]).first).not_to be_nil + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_meshcore]).first).not_to be_nil + db.close + end + + # Two real meshcore radios can legitimately advertise the same long_name + # (it is user-editable and has no uniqueness constraint). In that case we + # cannot tell which real device a synthetic placeholder stood in for, so + # neither direction of the merge is allowed to fire. + it "skips the reverse merge when two real meshcore nodes share the same long_name" do + db = open_db + real_a = "!realambA" + real_b = "!realambB" + synth_id = "!synthamb" + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard,public_key) VALUES (?,?,?,?,?,?,?)", + [real_a, "Karl", "meshcore", 0, now - 200, now - 200, "aa" * 32], + ) + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard,public_key) VALUES (?,?,?,?,?,?,?)", + [real_b, "Karl", "meshcore", 0, now - 100, now - 100, "bb" * 32], + ) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [91, now - 10, "2025-01-01T00:00:00Z", synth_id, "^all", "meshcore"], + ) + dp.upsert_node(db, synth_id, { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Karl", "shortName" => "", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + # Synthetic must NOT be merged — keep it as a visible placeholder so an + # operator can resolve the ambiguity manually. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first).not_to be_nil + # Message untouched. + expect(db.execute("SELECT from_id FROM messages WHERE id = 91").first[0]).to eq(synth_id) + # Both real rows still present. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_a]).first).not_to be_nil + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_b]).first).not_to be_nil + db.close + end + + it "skips the forward merge when another real meshcore node already owns the long_name" do + db = open_db + real_a = "!realfwdA" + real_b = "!realfwdB" + synth_id = "!synthfwd" + # Pre-existing real meshcore "Liam" (simulates another device that + # advertised before) and a synthetic "Liam" placeholder. + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard,public_key) VALUES (?,?,?,?,?,?,?)", + [real_a, "Liam", "meshcore", 0, now - 200, now - 200, "cc" * 32], + ) + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [synth_id, "Liam", "meshcore", 1, now - 100, now - 100], + ) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [92, now - 50, "2025-01-01T00:00:00Z", synth_id, "^all", "meshcore"], + ) + # Now a second real meshcore "Liam" is upserted. Because the name is + # ambiguous, the forward merge must NOT claim the synthetic on behalf + # of this node — that would randomly attribute the pre-existing message + # to whichever real was upserted first. + dp.upsert_node(db, real_b, { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Liam", "shortName" => "L", "role" => "COMPANION", "publicKey" => "dd" * 32 }, + }, protocol: "meshcore") + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first).not_to be_nil + expect(db.execute("SELECT from_id FROM messages WHERE id = 92").first[0]).to eq(synth_id) + ensure + db&.close + end end # --------------------------------------------------------------------------- @@ -569,4 +707,101 @@ RSpec.describe PotatoMesh::App::DataProcessing do db.close end end + + # --------------------------------------------------------------------------- + # merge_into_real_node — reverse of merge_synthetic_nodes (issue #755). + # --------------------------------------------------------------------------- + describe "#merge_into_real_node" do + include_context "with isolated db" + + let(:now) { Time.now.to_i } + + it "is a no-op when no real meshcore node shares the long_name" do + db = open_db + synth_id = "!synthAAA" + dp.upsert_node(db, synth_id, { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Mallory", "shortName" => "", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + dp.merge_into_real_node(db, synth_id, "Mallory") + # Synthetic remains because there is no real peer. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first).not_to be_nil + ensure + db&.close + end + + it "migrates messages and drops the synthetic when a real meshcore peer exists" do + db = open_db + real_id = "!realBBBB" + synth_id = "!synthBBB" + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [real_id, "Niaj", "meshcore", 0, now - 100, now - 100], + ) + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [synth_id, "Niaj", "meshcore", 1, now, now], + ) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [81, now, "2025-01-01T00:00:00Z", synth_id, "^all", "meshcore"], + ) + dp.merge_into_real_node(db, synth_id, "Niaj") + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first).to be_nil + expect(db.execute("SELECT from_id FROM messages WHERE id = 81").first[0]).to eq(real_id) + ensure + db&.close + end + + it "does not match a real meshtastic node as the reverse-merge target" do + db = open_db + real_meshtastic = "!realCCCC" + synth_meshcore = "!synthCCC" + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [real_meshtastic, "Oscar", "meshtastic", 0, now - 100, now - 100], + ) + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [synth_meshcore, "Oscar", "meshcore", 1, now, now], + ) + dp.merge_into_real_node(db, synth_meshcore, "Oscar") + # Cross-protocol row must be left alone; synthetic survives. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_meshcore]).first).not_to be_nil + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_meshtastic]).first).not_to be_nil + ensure + db&.close + end + + it "refuses to merge when two real meshcore nodes share the long_name" do + db = open_db + real_a = "!realDDDA" + real_b = "!realDDDB" + synth_id = "!synthDDD" + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [real_a, "Paul", "meshcore", 0, now - 200, now - 200], + ) + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [real_b, "Paul", "meshcore", 0, now - 100, now - 100], + ) + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [synth_id, "Paul", "meshcore", 1, now, now], + ) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [82, now, "2025-01-01T00:00:00Z", synth_id, "^all", "meshcore"], + ) + dp.merge_into_real_node(db, synth_id, "Paul") + # Neither real should take the synthetic's messages because we cannot + # tell which Paul actually sent the chat. + expect(db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first).not_to be_nil + expect(db.execute("SELECT from_id FROM messages WHERE id = 82").first[0]).to eq(synth_id) + ensure + db&.close + end + end end diff --git a/web/spec/database_spec.rb b/web/spec/database_spec.rb index f64505b..216347b 100644 --- a/web/spec/database_spec.rb +++ b/web/spec/database_spec.rb @@ -307,4 +307,177 @@ RSpec.describe PotatoMesh::App::Database do expect(untouched["role"]).to eq("CLIENT_HIDDEN") end end + + it "backfills meshcore synthetic/real duplicates and redirects their messages" do + # Covers issue #755: before the reverse-merge fix shipped, a synthetic + # placeholder created from a chat message could coexist with the real + # pubkey-derived node if the real node was upserted first (typical when a + # co-operating ingestor saw the contact advertisement before this one). + SQLite3::Database.new(PotatoMesh::Config.db_path) do |db| + db.execute(<<~SQL) + CREATE TABLE nodes( + node_id TEXT PRIMARY KEY, num INTEGER, short_name TEXT, long_name TEXT, + role TEXT, last_heard INTEGER, first_heard INTEGER, + protocol TEXT NOT NULL DEFAULT 'meshtastic', synthetic BOOLEAN NOT NULL DEFAULT 0 + ) + SQL + db.execute(<<~SQL) + CREATE TABLE messages( + id INTEGER PRIMARY KEY, rx_time INTEGER, rx_iso TEXT, + from_id TEXT, to_id TEXT, protocol TEXT NOT NULL DEFAULT 'meshtastic' + ) + SQL + + # Duplicate pair sharing a long_name — the classic issue #755 shape. + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!realdup1", "Peggy", "COMPANION", "meshcore", 0], + ) + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!synthdp1", "Peggy", "COMPANION", "meshcore", 1], + ) + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, protocol) VALUES (?, ?, ?, ?, ?, ?)", + [901, 1, "2025-01-01T00:00:00Z", "!synthdp1", "^all", "meshcore"], + ) + + # Orphaned synthetic with no real counterpart — must be preserved. + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!synthorp", "Trent", "COMPANION", "meshcore", 1], + ) + + # Cross-protocol namesake — must NOT be merged. + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!realmtX1", "Victor", "CLIENT", "meshtastic", 0], + ) + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!synthmcX", "Victor", "COMPANION", "meshcore", 1], + ) + end + + harness_class.ensure_schema_upgrades + + SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true) do |db| + db.results_as_hash = true + + # Synthetic duplicate collapsed, real node survived. + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!synthdp1'")).to be_nil + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!realdup1'")).not_to be_nil + # Message redirected to the real node id. + msg = db.get_first_row("SELECT from_id FROM messages WHERE id = 901") + expect(msg["from_id"]).to eq("!realdup1") + + # Orphaned synthetic left alone. + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!synthorp'")).not_to be_nil + + # Cross-protocol namesake pair untouched — meshtastic real must not + # absorb a meshcore synthetic. + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!synthmcX'")).not_to be_nil + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!realmtX1'")).not_to be_nil + end + end + + it "leaves synthetic placeholders alone when two real meshcore nodes share the long_name" do + # Long-name is user-editable and not unique across meshcore pubkeys. If + # two real devices happen to advertise the same name, the backfill cannot + # safely attribute the synthetic placeholder's history to either — leave + # all three rows in place for manual resolution. + SQLite3::Database.new(PotatoMesh::Config.db_path) do |db| + db.execute(<<~SQL) + CREATE TABLE nodes( + node_id TEXT PRIMARY KEY, num INTEGER, short_name TEXT, long_name TEXT, + role TEXT, last_heard INTEGER, first_heard INTEGER, + protocol TEXT NOT NULL DEFAULT 'meshtastic', synthetic BOOLEAN NOT NULL DEFAULT 0 + ) + SQL + db.execute(<<~SQL) + CREATE TABLE messages( + id INTEGER PRIMARY KEY, rx_time INTEGER, rx_iso TEXT, + from_id TEXT, to_id TEXT, protocol TEXT NOT NULL DEFAULT 'meshtastic' + ) + SQL + + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!realambA", "Quinn", "COMPANION", "meshcore", 0], + ) + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!realambB", "Quinn", "COMPANION", "meshcore", 0], + ) + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!synthamb", "Quinn", "COMPANION", "meshcore", 1], + ) + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, protocol) VALUES (?, ?, ?, ?, ?, ?)", + [902, 1, "2025-01-01T00:00:00Z", "!synthamb", "^all", "meshcore"], + ) + end + + harness_class.ensure_schema_upgrades + + SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true) do |db| + db.results_as_hash = true + # All three rows survive. + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!realambA'")).not_to be_nil + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!realambB'")).not_to be_nil + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!synthamb'")).not_to be_nil + # Message stays attributed to the synthetic placeholder — better an + # obvious unresolved pointer than a silent mis-attribution. + msg = db.get_first_row("SELECT from_id FROM messages WHERE id = 902") + expect(msg["from_id"]).to eq("!synthamb") + end + end + + it "makes the #755 duplicate backfill idempotent across successive boots" do + # ensure_schema_upgrades runs on every startup, so the backfill must be a + # no-op the second time around — otherwise a later upsert that re-creates + # the synthetic could be undone by an unrelated migration re-run. + SQLite3::Database.new(PotatoMesh::Config.db_path) do |db| + db.execute(<<~SQL) + CREATE TABLE nodes( + node_id TEXT PRIMARY KEY, num INTEGER, short_name TEXT, long_name TEXT, + role TEXT, last_heard INTEGER, first_heard INTEGER, + protocol TEXT NOT NULL DEFAULT 'meshtastic', synthetic BOOLEAN NOT NULL DEFAULT 0 + ) + SQL + db.execute(<<~SQL) + CREATE TABLE messages( + id INTEGER PRIMARY KEY, rx_time INTEGER, rx_iso TEXT, + from_id TEXT, to_id TEXT, protocol TEXT NOT NULL DEFAULT 'meshtastic' + ) + SQL + + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!realidmp", "Sybil", "COMPANION", "meshcore", 0], + ) + db.execute( + "INSERT INTO nodes(node_id, long_name, role, protocol, synthetic) VALUES (?, ?, ?, ?, ?)", + ["!syntidmp", "Sybil", "COMPANION", "meshcore", 1], + ) + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, protocol) VALUES (?, ?, ?, ?, ?, ?)", + [903, 1, "2025-01-01T00:00:00Z", "!syntidmp", "^all", "meshcore"], + ) + end + + 2.times { harness_class.ensure_schema_upgrades } + + SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true) do |db| + db.results_as_hash = true + # Real still there, synthetic gone, message redirected — same as the + # single-run case. The second pass must not re-introduce or corrupt + # any state. + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!realidmp'")).not_to be_nil + expect(db.get_first_row("SELECT node_id FROM nodes WHERE node_id = '!syntidmp'")).to be_nil + msg = db.get_first_row("SELECT from_id FROM messages WHERE id = 903") + expect(msg["from_id"]).to eq("!realidmp") + end + end end