Skip to content

fix(meshcore): seed in-memory contacts from DB so the DM list isn't empty on a flaky source#3554

Merged
Yeraze merged 1 commit into
mainfrom
fix/meshcore-seed-contacts-from-db
Jun 19, 2026
Merged

fix(meshcore): seed in-memory contacts from DB so the DM list isn't empty on a flaky source#3554
Yeraze merged 1 commit into
mainfrom
fix/meshcore-seed-contacts-from-db

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

On multi-source MeshCore, the map page showed a full node list but the DM page showed only a handful of unnamed entries for one source. Root cause:

  • The map / node list reads getAllNodes()databaseService.meshcore.getNodesBySource() (DB-backed) → full list ✓
  • The DM contact list reads getContacts()this.contacts (in-memory only), which is populated solely by the live device get_contacts.

this.contacts was never seeded from the DB. On a flaky/slow companion whose get_contacts returns empty or times out — and refreshContacts() deliberately won't wipe the list on an empty result (the "node list collapses to 1" guard) — the in-memory map stayed nearly empty, with only stray advert-push entries (often without advName, hence the unnamed "ID#" rows). Meanwhile the DB still held the full set, so the map looked fine. That's why it was per-source: a healthy companion's DM list worked; a flaky one's didn't.

(Observed on a source that was logging Native command timeout: get_channels.)

Fix

Add seedContactsFromDb(), called in connect() immediately before the live refreshContacts():

  • Pre-fills this.contacts from the persisted node list (the same DB the map reads).
  • Skips the local node; never clobbers a fresher in-memory entry (e.g. a live advert that raced ahead).
  • A successful live refresh still replaces it with the device-authoritative list; a degraded one keeps the DB-seeded contacts → DM and map views stay consistent.
  • Companion-only (repeaters have no companion contact list); non-fatal on DB error.

Mirrors the existing message-seeding-from-DB pattern already in connect().

Testing

  • New meshcoreManager.seedContacts.test.ts: seeds named contacts, skips the local node, doesn't clobber a live in-memory entry, and is a no-op for repeater sources.
  • All 42 meshcore manager suites pass; full Vitest suite green; tsc clean.

Relation to #3550

Independent of the "define a path by name" PR (#3550). That PR touched the path editor; this touches contact loading. Branched off main.

🤖 Generated with Claude Code

The DM contact list is sourced from getContacts() (the in-memory this.contacts
map), which is only populated by the live device get_contacts. On a flaky/slow
companion that returns empty or times out — and refreshContacts deliberately
won't wipe on an empty result — the in-memory list stayed nearly empty. So the
DM page showed a handful of unnamed entries while the node/map view (DB-backed
getAllNodes) showed the full set. This was visible per-source: a healthy
companion's DM list was fine; a flaky one's was sparse.

Add seedContactsFromDb(), called in connect() right before the live
refreshContacts(): it pre-fills this.contacts from the persisted node list (the
same DB the map reads), skipping the local node and never clobbering a fresher
in-memory entry. A successful live refresh still replaces it with the
device-authoritative list; a degraded one keeps the DB-seeded contacts so the
DM and map views stay consistent. Companion-only; non-fatal on DB error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(meshcore): seed in-memory contacts from DB

Tasks:

  • Read changed files
  • Analyze implementation
  • Review tests
  • Post feedback

Summary

This PR correctly addresses a real bug: the DM contact list (getContacts()this.contacts) was in-memory only, while the map/node list used the DB-backed getAllNodes(). On a flaky companion, refreshContacts() intentionally refuses to wipe on an empty result (to avoid the "node list collapses to 1" regression), leaving this.contacts sparse. The fix adds seedContactsFromDb() as a pre-seed step, matching the existing message-seeding-from-DB pattern.

The implementation is clean and the logic is sound. A few observations:


Code Quality

meshcoreManager.tsseedContactsFromDb() (line 1850)

The implementation is well-structured. Minor observations:

  1. Mixed null semantics for outPath/pathLen — In refreshContacts() (line 1823-1824) these fields are set as c.out_path ?? null (explicitly null). In seedContactsFromDb() (line 1868-1869) they're also ?? null. This is consistent. However the MeshCoreContact interface (line 330-335) types them as number | null and string | null — the null values here are intentional sentinels (OUT_PATH_UNKNOWN), which is correct.

  2. advType cast — Line 1862: (n.advType ?? undefined) as MeshCoreDeviceType | undefined. This cast is necessary since the DB column is typed as a raw string/number. The pattern is the same as getAllNodes() (line 3572), so it's consistent.

  3. No event emission after seedingseedContactsFromDb() does not call dataEventEmitter.emitMeshCoreContactUpdated() for each seeded contact (unlike the live advert path at lines 1022, 1122, 1147). This appears intentional — the method is called at connect time before the frontend has subscribed to SSE events for this source. Worth confirming this assumption holds in all reconnect scenarios (e.g. if the frontend is already connected during a reconnect).

  4. seeded counter is logged but the 0-contact case is silently skipped — Fine as-is; this is a minor style preference.


Potential Issue: Reconnect Scenario

seedContactsFromDb() is called in connect() (line 663). Looking at disconnect() (line 840: this.contacts.clear()), on reconnect the sequence is:

  1. contacts.clear()
  2. connect()seedContactsFromDb()refreshContacts()

This works correctly. However, if a reconnect happens while seedContactsFromDb() is running (unlikely, since it awaits before refreshContacts()), the DB result could race with a clear. Given the sequential await chain this is not a real concern, but worth noting.


Test Coverage

meshcoreManager.seedContacts.test.ts

The test file covers the four key behaviors well:

Scenario Covered
Seeds named contacts into getContacts() ✓ line 56
Skips the local node ✓ line 72
Does not clobber a live in-memory entry ✓ line 83
No-op for repeater sources ✓ line 95

One gap: no test for the DB error path (the catch block at line 1876). The method is non-fatal on DB error per the comment, but there's no test confirming the catch swallows the error and leaves contacts empty/untouched rather than propagating. This isn't a blocker but would strengthen the regression suite.

Test design note: Using (m as unknown as {...}) to access private state is pragmatic and consistent with the existing test pattern in this codebase. The seed() helper (line 50) is a clean way to call the private method without exposing it.


No Security or Performance Concerns

  • The DB query (getNodesBySource) is already used by getAllNodes() on every node-list fetch; adding one more call at connect time is negligible.
  • No raw SQL introduced (ESLint-compliant).
  • No sensitive data handling changes.

Verdict

Approve. The fix is correct, minimal, and well-tested. The one recommended addition (error-path test) is optional but would be a nice follow-up.

@Yeraze

Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review! On the optional DB-error-path test: I did write one initially, but it trips vitest's spy error-surfacing — a mockImplementation that throws (sync or async) is reported by @vitest/spy through the test runner even though seedContactsFromDb's own try/catch swallows it, so the test fails on a caught error. Rather than fight the tooling on an optional case, I'm leaving it out; the catch (warn-and-continue, leaving this.contacts untouched) is straightforward by inspection. Happy to add it later if we find a clean mock pattern that doesn't trigger the detector.

@Yeraze Yeraze merged commit 5880c92 into main Jun 19, 2026
19 checks passed
@Yeraze Yeraze deleted the fix/meshcore-seed-contacts-from-db branch June 19, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant