mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-09 18:33:02 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user