Skip to content

feat(meshcore): server-side node favoriting with pin-to-top (#3588)#3595

Merged
Yeraze merged 1 commit into
mainfrom
feat/3588-meshcore-favorites
Jun 21, 2026
Merged

feat(meshcore): server-side node favoriting with pin-to-top (#3588)#3595
Yeraze merged 1 commit into
mainfrom
feat/3588-meshcore-favorites

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the ability to favorite MeshCore nodes of any role (Companion, Repeater, Room Server, …), pinning favorited nodes to the top of the node list — consistent with how Meshtastic node favorites are displayed.

MeshCore firmware has no native favorite concept, so unlike Meshtastic (which round-trips a SetFavoriteNode admin message to the device), the favorite flag is stored server-side only and is never pushed to the device.

Closes #3588

What changed

Storage (per-source, all 3 backends)

  • src/db/schema/meshcoreNodes.ts — new isFavorite boolean column (SQLite/PostgreSQL/MySQL).
  • src/server/migrations/094_add_meshcore_node_favorite.ts — idempotent column add across all three backends; registered in src/db/migrations.ts; migrations.test.ts count bumped to 94.
  • src/db/repositories/meshcore.tssetNodeFavorite(sourceId, publicKey, isFavorite) using the existing stub-insert pattern (lets a user favorite a node seen only in-memory). Fully source-scoped via the composite PK.

Backend (no device IO)

  • src/server/meshcoreManager.tssetNodeFavorite() (local-only) + maps isFavorite in getAllNodes().
  • src/server/routes/meshcoreRoutes.tsPOST /api/sources/:id/meshcore/nodes/:publicKey/favorite, gated by nodes:write (matches the Meshtastic favorite endpoint), backfills node identity from the in-memory contact, and performs no device round-trip.

Frontend

  • MeshCoreNodesView.tsx — per-row star toggle; favorites pinned to top by sorting favorites/non-favorites independently then concatenating (mirrors the Meshtastic useProcessedNodes convention).
  • hooks/useMeshCore.tssetNodeFavorite action with optimistic update; recomputeNodes now carries forward the favorite flag so a contact push can't transiently un-pin a node before the next snapshot reconciles from the DB.
  • MeshCorePage.css — star button styling.

Tests

  • src/db/repositories/meshcoreFavorite.perSource.test.ts — per-source isolation (favoriting under one source doesn't leak into another sharing the same public key), stub-seed, no-clobber, un-favorite.
  • src/server/routes/meshcoreRoutes.test.ts — auth, malformed-key + non-boolean validation, local-only behavior (asserts manager.setNodeFavorite called, no device path), contact backfill, un-favorite.
  • Updated the hand-rolled DDL in meshcore.test.ts for the new column.

Verification

  • Full Vitest suite: 7024 passed, 0 failed (563 pending).
  • tsc -p tsconfig.server.json --noEmit: clean.
  • ESLint on touched files: no new errors/warnings (remaining NodeJS/any findings are pre-existing in untouched regions).

🤖 Generated with Claude Code

Add the ability to favorite MeshCore nodes of any role (Companion,
Repeater, Room Server, …), pinning them to the top of the node list —
consistent with how Meshtastic node favorites are displayed.

MeshCore firmware has no native favorite concept, so unlike Meshtastic
(which round-trips a SetFavoriteNode admin message to the device), the
flag is stored server-side only and never pushed to the device.

- Schema: add `isFavorite` to meshcore_nodes (SQLite/PG/MySQL).
- Migration 094: idempotent column add across all three backends.
- Repository: `setNodeFavorite(sourceId, publicKey, isFavorite)` with the
  stub-insert pattern (favorite a node seen only in-memory). Source-scoped.
- Manager: `setNodeFavorite` (local-only, no device IO) + map isFavorite
  in `getAllNodes`.
- Route: POST /api/sources/:id/meshcore/nodes/:publicKey/favorite, gated by
  nodes:write, backfills identity from the in-memory contact, no device
  round-trip.
- Frontend: star toggle per row in MeshCoreNodesView; favorites pinned to
  top mirroring the Meshtastic useProcessedNodes convention; optimistic
  update + recomputeNodes carry-forward so contact pushes don't un-pin.

Tests: per-source isolation repo test + route tests (auth, validation,
local-only behavior, backfill). Full vitest suite green (7024 passed).

Closes #3588

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): server-side node favoriting with pin-to-top (#3588)

Tasks:

  • Gather context and understand the PR
  • Review schema and migration changes
  • Review repository and backend changes
  • Review route and API changes
  • Review frontend changes
  • Review test coverage
  • Post final review feedback

Overall this is a well-implemented feature that correctly follows the codebase's patterns. The architecture decision to store the flag server-side only (with clear documentation of the reason) is correct and the implementation is consistent throughout the stack. Here are my findings:

Strengths

  • Correct multi-source scoping: The composite PK (sourceId, publicKey) is respected at every layer — schema, repository, route, and tests. Per-source isolation is verified by meshcoreFavorite.perSource.test.ts.
  • Stub-row insert pattern: Mirrors setNodeTelemetryConfig exactly — a node seen only in-memory can still be favorited. The contact-backfill in the route handler is a nice touch that seeds a name/type alongside the flag.
  • Optimistic update in the frontend: setNodes(prev => prev.map(...)) in useMeshCore.ts:980 gives instant UI feedback, and recomputeNodes at line 342 carries the flag forward so a contact push can't transiently un-pin a favorite.
  • Idempotent migrations: SQLite uses try/catch on duplicate column, PostgreSQL uses ADD COLUMN IF NOT EXISTS, MySQL uses information_schema — all three patterns are consistent with other migrations.

Issues Found

Minor Bug: isFavorite not preserved when in-memory contact overlays a DB node in getAllNodes

File: src/server/meshcoreManager.ts around line 3627–3639

for (const contact of this.contacts.values()) {
  const base = byKey.get(contact.publicKey);
  byKey.set(contact.publicKey, {
    ...base,           // isFavorite IS spread from base here
    publicKey: contact.publicKey,
    name: ...,
    advType: ...,
    lastHeard: ...,
    rssi: ...,
    snr: ...,
    latitude: ...,
    longitude: ...,
    // isFavorite not explicitly listed — relies on ...base spread
  });
}

This actually works correctly because ...base includes isFavorite and no later key overwrites it. However, when base is undefined (a contact that hasn't been persisted yet), the spread is a no-op and isFavorite will be undefined on the returned node. The downstream consumer in MeshCoreNodesView.tsx:66 uses n.isFavorite ?? false, so it degrades gracefully to false, but technically a live-only contact that just got favorited (triggering a stub-insert) could briefly show as un-favorited until the next getAllNodes snapshot.

This is a very edge case and the existing optimistic update in the hook mitigates it at the UI level, but it's worth being aware of.

Potential race: contact backfill in route handler can silently no-op

File: src/server/routes/meshcoreRoutes.ts:2432–2436

} catch (err) {
  logger.warn(
    `[API] favorite: contact backfill for ${publicKey.substring(0, 16)}… failed: ${(err as Error).message}`,
  );
}

The upsertNode failure is caught and swallowed (with a warn log). This is intentional — the favorite flag itself is still set even if the backfill fails. This is the right call, but the comment at line 2414 says "Backfill identity from the in-memory contact before seeding the stub row" — the upsertNode and the subsequent setNodeFavorite (which also inserts a stub) can both insert rows independently. If upsertNode fails, setNodeFavorite still inserts a stub without name/type, which is exactly what the PR intends. The logic is correct but slightly surprising to read.

isFavorite not forwarded in the in-memory overlay path for recomputeNodes

File: src/components/MeshCore/hooks/useMeshCore.ts:342–349

const favByKey = new Map(prev.map(n => [n.publicKey, n.isFavorite]));
...
const fav = favByKey.get(c.publicKey);
if (fav !== undefined) node.isFavorite = fav;

This only preserves isFavorite for contacts already present in the previous nodes state. A brand-new contact that arrives via a push event (and therefore has no entry in favByKey) will always get isFavorite: undefined, which renders as the empty star. This is the expected behavior since a new node can't yet be a favorite. This is fine; just noting the intentional asymmetry.


Observations & Suggestions

  1. Missing isFavorite in MeshCoreActions type (useMeshCore.ts:99–166): The setNodeFavorite method is correctly added to the returned actions object at line 1397, but it isn't listed in the MeshCoreActions interface. The TypeScript type should be updated to include it, otherwise callers relying on the interface type would not see it as part of the contract.

  2. favoriteBusy allows only one in-flight request globally (MeshCoreNodesView.tsx:130): if (!onToggleFavorite || favoriteBusy) return; — clicking any star while one request is in flight silently no-ops for all rows, not just the one being toggled. This is a reasonable UX choice for simplicity (avoids conflicting optimistic updates), but a per-key busy state would be more precise. Given this is a low-frequency action, the current approach is acceptable.

  3. Search doesn't filter favorited pinned nodes separately (MeshCoreNodesView.tsx:221–226): sortRows (which includes the favorites-first sort) is applied before the search filter. This means favorites show first within search results too, which is the right behavior. ✓

  4. No WebSocket push on favorite change: The API response at line 2441–2444 returns { success: true, data: { publicKey, sourceId, isFavorite } } but doesn't emit a socket event. This is fine for single-user scenarios (the optimistic update in the hook handles it), but in a multi-browser-session scenario, another tab won't see the change until the next snapshot poll. This limitation is consistent with how other local-only mutations work in the codebase.

  5. Test: no assertion that upsertNode is NOT called when getContact returns undefined in meshcoreRoutes.test.ts:1248–1258. The test at line 1249 explicitly sets getContact to return undefined and the test passes — but it only asserts that setNodeFavorite was called and the response was 200. An explicit expect(upsertNode).not.toHaveBeenCalled() would make the test's intent more explicit.


Verdict

Approve with minor suggestions. The core feature is correct, well-tested, and properly integrated across all three DB backends. The TypeScript interface gap (point 1 above) is the only thing I'd call a real issue worth addressing before merge; the rest are observations.

@Yeraze Yeraze merged commit be491d4 into main Jun 21, 2026
19 checks passed
@Yeraze Yeraze deleted the feat/3588-meshcore-favorites branch June 21, 2026 03:56
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.

Feature: MeshCore node favoriting (pin to top of node list)

1 participant