web: fix node duplication through message synthetization (#757)

* web: fix node duplication through message synthetization

* web: fix edge case

* web: address review comments
This commit is contained in:
l5y
2026-04-21 08:53:09 +02:00
committed by GitHub
parent f866cf8837
commit a6cac6ced5
4 changed files with 524 additions and 6 deletions
@@ -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, "")
@@ -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(
+235
View File
@@ -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
+173
View File
@@ -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