Skip to content

feat(settings): read Node Display settings per-source (#4412 Phase 2) - #4425

Merged
Yeraze merged 8 commits into
mainfrom
feature/per-source-node-display-reads
Jul 29, 2026
Merged

feat(settings): read Node Display settings per-source (#4412 Phase 2)#4425
Yeraze merged 8 commits into
mainfrom
feature/per-source-node-display-reads

Conversation

@Yeraze

@Yeraze Yeraze commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Phase 2 of the per-source Node Display epic (#4412). Converts every backend read of the 10 Node Display keys from global to per-source. Frontend is Phase 3; MeshCore filtering is Phase 4.

Plan: docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_EPIC.md
Spec: docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_PHASE2_SPEC.md
Builds on Phase 1 (#4417), which seeded source:{id}:{key} for every existing source via migration 131.

⚠️ User-visible behavior change — release notes

GET /api/neighbor-info read the settings key 'maxNodeAge', which has never existed in VALID_SETTINGS_KEYS. It always resolved null and always fell back to 24 hours. The endpoint has been silently pinned at a 24-hour window forever. It now honors the configured maxNodeAgeHours per source, so anyone who tuned that setting will see their neighbor-info link set change on upgrade.

What changed

New shared foundation. There is no runtime global fallback (#2839/#2840), so an unset source falls through to a hardcoded default — which meant 24 was about to scatter across a dozen read sites and rot independently.

  • src/constants/nodeDisplayDefaults.ts — the values. Isomorphic and import-free so Phase 3 can consume the same runtime values on the frontend (src/server/constants/settings.ts was rejected: the frontend imports only type from src/server/**). Also carries NODE_DISPLAY_RANGES, ending the duplicated validation literals between settingsRoutes.ts and server.ts.
  • src/server/services/nodeDisplaySettings.ts — the accessor, via reader injection copying the existing ManagerSettingsDb pattern. It must not import databaseService; that graph imports into this one.
  • SettingsRepository.getSettingForSources(ids, key) — one indexed IN() lookup. The unifiedRoutes dashboard fan-out reads once and broadcasts to every source; converting it naively would have made a hot path N+1. Deliberately not built on getSourceSettings, which is a full-table scan ([BUG] Per-source settings POST compares against global current-values; getSourceSettings does a full-table scan #4419).

~15 read sites converted, including three in database.ts (getNodeNeedingTracerouteAsync, getNodesNeedingRemoteLocalStatsAsync, getNodeNeedingRemoteAdminCheckAsync) that were reading the global value despite already having sourceId threaded through from every caller. Verified: zero global reads of any of the 10 keys remain outside tests and migrations.

Inactive-node service restructured to one timer with per-source config — not timer-per-source, which was rejected for lifecycle coupling. Per-source nextRunAt, due-ness computed before the users query, nextRunAt set before the work so a throw can't hot-loop, an explicit running flag (getStatus() previously derived it from the timer handle and would report false during the 60s warm-up), and pruning on deregistration. restartInactiveNodeService(t,c,cd)rescheduleInactiveNodeService(sourceId?).

Two tests were pinning bugs rather than catching them

Both are why this phase found what it did:

  • server.neighbor-info-position.test.ts re-implemented the neighbor-info route body inside the test — including the same dead 'maxNodeAge' key. It could not catch the bug because it copied it. Deleted; real-router coverage now lives in neighborInfoRoutes.test.ts (converted to createRouteTestApp()) and the existing nodeEnhancer.position-override.test.ts. This is the anti-pattern CLAUDE.md names: a fake that re-implements the logic under test cannot catch regressions in that logic.
  • inactiveNodeNotificationService.test.ts asserted "no eligible users does not advance nextRunAt — the source is retried on the very next tick", pinning the regression below as intended behavior.

Regression caught in review, fixed in 707a6d87

The restructured scheduler's zero-users branch left every due source permanently due, so getUsersWithInactiveNodeNotifications() and the zero-eligible diagnostic ran on every 60s tick instead of at each source's configured interval — a 60× increase, and zero-users is the default state on a fresh install. Same failure class as #4399/#4413.

The reasoning in the original comment ("nothing was checked, so retry") does not hold: "no users have notifications enabled" is a global condition, not per-source, and cannot become true faster because you re-queried 59 seconds later.

The entire suite passed with this bug present — nothing tested scheduler cadence. The fix ships with a fake-timer test asserting one query across five ticks.

Verification

  • Full suite: 12,506 passing, 0 failing, success: true via JSON reporter. 109 skips, unchanged from the Phase 1 baseline, so nothing silently stopped running.
  • PostgreSQL (5433) and MySQL (3307) containers live for the run.
  • tsc --noEmit clean. lint:ci ratchet clean.
  • New *.perSource.test.ts files prove two sources with different maxNodeAgeHours get different results, and that two sources with different check intervals each fire on their own schedule.

Still not fixed

Per-source permission scoping (#4416) remains open — a source-A-scoped settings:write grant still authorizes source-B writes. Do not read this PR as "per-source settings are now secure."

Refs #4412

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB

Yeraze and others added 7 commits July 29, 2026 13:05
 Phase 2 WP1)

There is no runtime global fallback (#2839/#2840), so a source with no
stored value falls through to a hardcoded default. Without one home for
those defaults, '24' would scatter across a dozen read sites and rot
independently.

- src/constants/nodeDisplayDefaults.ts: isomorphic, zero imports, so
  Phase 3 can consume the same runtime values on the frontend. Also
  carries NODE_DISPLAY_RANGES so validation bounds stop being duplicated
  between settingsRoutes and server.ts.
- src/server/services/nodeDisplaySettings.ts: reader injection, copying
  the ManagerSettingsDb pattern. It must not import databaseService --
  that graph imports into this one.
- SettingsRepository.getSettingForSources(): one indexed IN() lookup for
  a whole selection, so converting the unifiedRoutes fan-out does not
  become an N+1. Deliberately not built on getSourceSettings, which is a
  full-table scan (#4419).

The allowlist test now imports NODE_DISPLAY_SETTING_KEYS instead of
re-declaring the ten keys, so it cross-checks the new module rather than
drifting from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
 WP2)

sourceDashboardData.ts and unifiedRoutes.ts now resolve maxNodeAgeHours
per-source via the WP1 nodeDisplaySettings accessors instead of a single
global read. unifiedRoutes' dashboard fan-out uses the batched
getMaxNodeAgeHoursForSources() so it stays one query for the whole
selection.

neighborInfoRoutes.ts read the key 'maxNodeAge', which is not in
VALID_SETTINGS_KEYS and always resolved to null, silently pinning the
neighbor-info window at the hardcoded 24h fallback regardless of the
configured maxNodeAgeHours. It now reads the real per-source setting.
User-visible: anyone who tuned maxNodeAgeHours will see their
neighbor-info link set change on upgrade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…WP2)

- neighborInfoRoutes.test.ts: converted from a full vi.mock() fake (which
  re-implemented the same dead 'maxNodeAge' key the route read, so it
  could not catch the bug) to createRouteTestApp() against the real
  router, real DB, and real getMaxNodeAgeHours(). Adds a two-source proof
  that different maxNodeAgeHours values filter the same-age link
  differently, plus a position-override integration case.
- server.neighbor-info-position.test.ts: deleted. It re-implemented both
  neighbor-info route handlers inline (including the same dead key) and
  a local copy of the position-override helper. Both are now redundant:
  route-level position-override coverage moved into
  neighborInfoRoutes.test.ts, and the pure-function override logic
  already has dedicated coverage in
  src/server/utils/nodeEnhancer.position-override.test.ts.
- sourceDashboardData.perSource.test.ts (new): headline per-source proof
  for buildSourceNeighborInfo — two sources with different
  maxNodeAgeHours return different neighbor-info sets for an identical
  24h-old link; an explicit maxNodeAgeHours argument still overrides the
  per-source read (the unified-dashboard batch path).
- unifiedRoutes.perSource.test.ts (new): GET /api/unified/dashboard
  proof that two sources bundle different neighborInfo lengths while the
  settings read stays a single batched getSettingForSources() call, not
  one query per source.
- sourceRoutes.dashboard.test.ts / sourceRoutes.neighbor-info.test.ts /
  unifiedRoutes.test.ts: added getSettingForSource(s) to the mocked
  settings object so the existing suites keep passing now that the
  route/service code resolves maxNodeAgeHours per-source instead of via
  the old global getSetting('maxNodeAgeHours').

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…4412 Phase 2 WP3)

Converts every remaining backend read of maxNodeAgeHours and
localStatsIntervalMinutes to the per-source accessors added in WP1
(src/server/services/nodeDisplaySettings.ts):

- tracerouteRoutes.ts (/recent): maxNodeAgeHours now resolved via
  getMaxNodeAgeHours(databaseService.settings, recentSourceId ?? null) —
  a request with ?sourceId= reads that source's window; without it, reads
  the un-namespaced global row (documented getSettingForSource semantics,
  not a reintroduced fallback).
- pollRoutes.ts (/api/poll): same conversion, scoped to pollSourceId.
- meshtasticManager.ts (saveSystemNodeMetrics): scoped to this.sourceId.
- virtualNodeServer.ts (sendNodeInfosFromDb): scoped to the manager's
  sourceId; drops the last databaseService.getSettingAsync use in this file.
- src/services/database.ts: getNodeNeedingTracerouteAsync,
  getNodesNeedingRemoteLocalStatsAsync, getNodeNeedingRemoteAdminCheckAsync
  all previously read the global this.getSetting('maxNodeAgeHours') despite
  already having sourceId threaded in from their callers — now scoped.
- applyManagerSettings.ts: localStatsIntervalMinutes folds onto the shared
  getLocalStatsIntervalMinutes() helper instead of duplicating the 0..60
  range check inline. Behavior-preserving: both the old null-skip path and
  the new clamp-to-default path land on 15, matching
  MeshtasticManager.localStatsIntervalMinutes's class-field default.

No read site in this WP's scope stays global — every one already had a
sourceId in scope, confirmed by reading each call site before converting.

Tests: new database.maxNodeAge.perSource.test.ts proves two sources with
different maxNodeAgeHours yield different node sets from
getNodeNeedingRemoteAdminCheckAsync/getNodesNeedingRemoteLocalStatsAsync,
plus a global-fallback (sourceId omitted) case. Extended
tracerouteRoutes.test.ts, pollRoutes.test.ts, virtualNodeServer.zombieFix.test.ts
and applyManagerSettings.test.ts with per-source cases and re-pointed their
mocks from getSetting/getSettingAsync to getSettingForSource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…per-source config (#4412 Phase 2 WP4)

inactiveNodeNotificationService now resolves threshold/checkInterval/cooldown
per source on every scheduler tick instead of capturing them as instance
fields at start() time. A single setInterval drives all sources; a per-source
nextRunAt map lets each source honor its own check interval without a
timer-per-source (explicitly rejected for lifecycle/cleanup reasons).

- start()/stop() no longer take threshold/check/cooldown args; new
  reschedule(sourceId?) invalidates one source's (or, when omitted, every
  source's) cached next-run so a settings save takes effect on the next tick
  without tearing down any timer.
- Loop inverted to source-outer/user-inner (per-source due-ness forces this).
  Due-ness is computed before the users query so the 60s tick doesn't turn an
  hourly check into a per-minute DB query. nextRunAt is set before doing a
  source's work so a throw can't hot-loop it, and is pruned when a manager
  deregisters (bounded map growth, same discipline as #4413).
- getStatus().running is now explicit instance state instead of being
  derived from the interval handle, which doesn't exist during the 60s
  warm-up (would have regressed to false).
- server.ts: deleted the three global getSetting reads + inline 1..720/
  1..1440/1..720 clamping for inactive-node settings; the single tick now
  resolves and clamps each source's config via getInactiveNodeConfig().
- settingsRoutes.ts: restartInactiveNodeService(threshold, check, cooldown)
  replaced by rescheduleInactiveNodeService(sourceId?); wired into both the
  per-source save branch (the side-effect Phase 1 left a TODO for) and the
  global branch. Range validation for maxNodeAgeHours and the three
  inactive-node keys now reads bounds from NODE_DISPLAY_RANGES instead of
  duplicating the literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…4412 Phase 2 WP4 review)

The zero-users early return in tick() left every due source's nextRunAt
unset "so it would be retried on the next tick" — but "no users have
inactive-node notifications enabled" is a global condition, not a
per-source one, so retrying 60s sooner can't make it true any faster.
Since that's the DEFAULT state on a fresh install, this pinned every due
source permanently due and turned getUsersWithInactiveNodeNotifications()
plus logZeroEligibleDiagnostic()'s own queries into a per-60s poll forever
instead of running at each source's configured cadence — the same
runaway-work failure class as #4399/#4413.

Fix: in that branch, resolve each due source's own checkIntervalMinutes via
getInactiveNodeConfig() (wrapped per-source so one failed read can't block
scheduling the others) and advance its nextRunAt before returning, exactly
as if there had been users but zero eligible nodes. The genuine
"don't advance, let it retry sooner" case remains correct: it's the
per-source failure path a few lines down, inside the per-source try/catch,
where a retry actually can help.

Added a regression test asserting the users query is issued once, not once
per tick, across several ticks with fake timers when nobody has
notifications enabled, plus updated the now-stale "does not advance"
assertion to prove the corrected scheduling behavior instead.

Full suite: 12636 total, 12506 passed, 0 failed (109 pending — unrelated
skips; PG :5433 and MySQL :3307 containers were up for this run).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(settings): read Node Display settings per-source (#4412 Phase 2)

  • Gather context and understand PR scope
  • Review new foundation files (nodeDisplayDefaults.ts, nodeDisplaySettings.ts)
  • Review repository changes (settings.ts)
  • Review inactiveNodeNotificationService.ts refactor
  • Review route changes (neighbor-info, traceroute, unified, settings)
  • Review test coverage
  • Post final review

This is a well-engineered, thoroughly documented phase. The PR body is unusually transparent about its own bugs (the scheduler hot-loop regression, the pinned test anti-pattern) and shows genuine ownership of code quality. The observations below are mostly minor; none are blockers given the stated Phase 3/4 scope.

Architecture & Design

Excellent decisions:

  • nodeDisplayDefaults.ts being isomorphic (zero imports, lives in src/constants/) is the right call for Phase 3 frontend reuse. The precedent citation (hopEmoji.ts) is appropriate.
  • The NodeDisplayBatchReader interface being separate from NodeDisplaySettingsReader keeps the batched IN() path opt-in. Only the hot unified-dashboard fan-out needs it; other callers aren't forced to implement it.
  • The "no global fallback" decision is correctly applied throughout: getSettingForSources() leaves absent ids out of its result, and getMaxNodeAgeHoursForSources() fills them from hardcoded defaults rather than a second query.
  • The getInactiveNodeConfig() using Promise.all for the three independent reads is the right pattern here.

Minor design observation:

tracerouteRoutes.ts:22GET /traceroutes/recent computes maxNodeAgeHours per-source via getMaxNodeAgeHours(databaseService.settings, recentSourceId ?? null). When recentSourceId is null/undefined (no ?sourceId filter), this reads the bare maxNodeAgeHours key (the legacy global row). That is technically correct per the "no global fallback" spec — getSettingForSource(null, key) reads the un-namespaced key. But it means a global save to maxNodeAgeHours (from the global settings tab) still gates the cross-source /traceroutes/recent? response, while per-source saves don't. This is a known Phase 3/4 boundary concern and not a bug in this PR — just worth flagging as something to revisit when the frontend is updated.

Code Quality

settingsRoutes.ts:953-958 (global save path, inactive-node keys):

if (INACTIVE_NODE_KEYS.some((key) => key in filteredSettings)) {
  callbacks.rescheduleInactiveNodeService?.(null);
  logger.debug('✅ Inactive node check rescheduled for all sources');
}

The comment correctly explains why null (= all sources) is right here: global saves of these keys must invalidate every source's nextRunAt. The approach is sound.

settingsRoutes.ts:1034 (global save success response):

res.json({ success: true, settings: filteredSettings });

The per-source branch at line 825 uses ok(res, { ignoredKeys }) (the shared envelope helper), but this global branch hand-rolls the response. The CLAUDE.md response envelope notes ok() should be used for handlers but also notes this is an "opportunistic" conversion — no issue here, just noting the asymmetry.

inactiveNodeNotificationService.ts:141-144 (pruning nextRunAt while iterating):

for (const id of this.nextRunAt.keys()) {
  if (!activeIds.has(id)) this.nextRunAt.delete(id);
}

Iterating a Map while deleting from it is safe in JavaScript (the spec guarantees the iterator yields entries that existed when the iteration began, deleted entries are skipped). This is correct, just worth noting for readers unfamiliar with the JS Map spec.

getSettingForSources — empty sourceIds with duplicates:
The method deduplicates input via new Set(sourceIds). This is good hygiene, but the caller getMaxNodeAgeHoursForSources doesn't deduplicate its sourceIds input first. The result Map is keyed by sourceId, so duplicates in the input produce correct values — the database never sees duplicates, and the result Map is iterated over the original sourceIds array. Safe, but slightly asymmetric (the dedup is at the repo layer, not the caller).

Bug / Potential Issues

parseNodeDisplayNumber and nodeDimmingStartHours/nodeDimmingMinOpacity (no range):

src/constants/nodeDisplayDefaults.ts:100-105 documents that keys without a NODE_DISPLAY_RANGES entry are parsed but not bounds-checked, and parseNodeDisplayNumber('nodeDimmingStartHours', '-5') returns -5. This is intentional for now (Phase 3 adds ranges from the SettingsTab inputs). The test at nodeDisplayDefaults.test.ts:103-104 pins this behavior explicitly, which is good.

inactiveNodeNotificationService.ts:143-144 (map cleanup and newly deregistered source):

After pruning stale nextRunAt entries, if a source was deregistered mid-tick (after managers was captured but before the pruning loop), it could appear in due even after the prune — since due was computed from the captured managers slice before pruning. In practice this is a non-issue (deregistration and tick run on the same JS event loop, so there's no true race), but it's a subtle ordering dependency worth a short comment.

Performance

sourceDashboardData.ts:231buildSourceNeighborInfo resolves maxNodeAgeHours per-source when maxNodeAgeHours is not provided:

const resolvedMaxAge = maxNodeAgeHours ?? await getMaxNodeAgeHours(databaseService.settings, source.id);

When called from buildSourceDashboard via the per-source /api/sources/:id/dashboard route (not the unified route), this triggers one getSetting DB query per dashboard call. The unified route correctly batches this with getMaxNodeAgeHoursForSources. The per-source single-source path was already one-query-per-source before this PR (it was a global read), so the performance is unchanged — just noted for Phase 3 awareness.

unifiedRoutes.ts:1300-1303 — The batched getMaxNodeAgeHoursForSources call correctly uses a single IN() query for the entire source selection. Well done.

Test Coverage

Strengths:

  • nodeDisplayDefaults.test.ts pins every invariant that matters: key count, no duplicates, subset-of-VALID_SETTINGS_KEYS, byte-identity with migration 131's seed, '0'/'1' boolean convention, parseNodeDisplayNumber edge cases (0-below-min, out-of-range, unranged keys), parseNodeDisplayBoolean edge cases.
  • nodeDisplaySettings.test.ts isolates the accessor service with a stub reader and explicitly tests the "no global fallback" invariant (a global row present, no per-source override → hardcoded default). This is the exact regression being prevented.
  • inactiveNodeNotificationService.perSource.test.ts is exemplary: fake timers, independent cadences between two sources, independent cooldowns, and bounded-map growth on deregistration — four distinct scheduling behaviors, each in its own it block.
  • The zero-users scheduler fix test (advancing five ticks with getUsersWithInactiveNodeNotifications called exactly once per tick, not five times) directly pins the regression caught in review.

One gap — getSettingForSources with null value from DB:

settings.test.ts tests the batched repo method, but it would be useful to also assert what happens when a row has a NULL value (i.e., the key exists in the table but value is null). The type signature Promise<Map<string, string>> suggests values are always non-null strings, but the DB select({ value: settings.value }) could return null for a null-value row. The cast at line 238 (as Array<{ key: string; value: string }>) would silently pass null through as a string to the caller. This is an edge case that's unlikely in practice (migration 131 seeds string values) but worth a defensive cast or note.

Security

Minor Style

  • src/server/routes/settingsRoutes.ts:255GET /settings error at line 255 hand-rolls res.status(500).json({ error: '...' }) while some surrounding handlers use fail(). The opportunistic-conversion note in CLAUDE.md covers this.
  • The settingsRoutes.ts file is now 1,835 lines. This is pre-existing, but Phase 3's frontend changes may be a natural point to consider splitting the Node Display section into its own sub-router if it grows further.

Summary

The PR achieves its stated goal cleanly: zero global reads of the 10 Node Display keys remain in backend business logic, the scheduler regression is caught and fixed with a pinning test, and two test files that were masking bugs rather than catching them are correctly replaced. The nodeDisplayDefaults.ts / nodeDisplaySettings.ts foundation is a solid, reusable pattern for Phase 3.

Verdict: Ready to merge with no blocking issues. The observations above are informational for Phase 3/4 planning.

Review of #4425: pruning nextRunAt and computing `due` both read the same
captured managers slice. Not a race (same event loop), but the ordering is
load-bearing -- re-reading the registry between them would let the two
disagree. Comment-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
@Yeraze

Yeraze commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Thanks — review triaged. One suggestion applied, one answered with evidence, the rest noted for Phase 3.

Applied

tick() prune/due ordering dependency. Agreed it deserved a comment rather than being left implicit. Added one stating that the prune and the due filter both read the same captured managers slice, that a source deregistered after the snapshot gets one final pass and is pruned next tick, and that re-reading the registry between the two would let them disagree. Comment-only, no behavior change.

Answered — getSettingForSources cannot receive a NULL value

Good instinct, but the case is impossible at the schema level: value is .notNull() on all three backends (src/db/schema/settings.ts:12, :20, :28). There is no way for the table to hold a null-value row, so the cast at settings.ts:238 cannot smuggle null through as a string.

I'd rather not add a defensive branch for a state the schema forbids — it would be unreachable code implying the invariant is weaker than it is. If the NOT NULL constraint were ever relaxed, that is the change that should carry the null handling.

Noted, no action

  • Unranged nodeDimming* keys — intentional, and the behavior is pinned by test at nodeDisplaySettings/nodeDisplayDefaults. Phase 3 adds the ranges from the SettingsTab inputs, which is where the authoritative bounds live.
  • buildSourceNeighborInfo single-source query — as you note, unchanged from before this PR (it was one global read, now one scoped read). The fan-out path is the one that mattered and it is batched.
  • fail() at settingsRoutes.ts:255 — opportunistic conversion per CLAUDE.md; converting untouched handlers would widen this diff without improving it.
  • settingsRoutes.ts at 1,835 lines — agreed, and Phase 3 is the natural moment to look at it. Logged as a Phase 3 consideration rather than done here, since splitting a router under a behavior change makes both harder to review.

Reminder carried forward

Reiterating your security note so it does not get lost: per-source permission scoping (#4416) is still open. A settings:write grant on source A can still write source B's namespace. This PR does not change that, and the PR body says so.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(settings): read Node Display settings per-source (#4412 Phase 2)

  • Gather context and understand PR scope
  • Review new foundation files (nodeDisplayDefaults.ts, nodeDisplaySettings.ts)
  • Review repository changes (settings.ts)
  • Review inactiveNodeNotificationService.ts refactor
  • Review route changes (neighbor-info, traceroute, unified, settings)
  • Review test coverage
  • Post final review

This is a substantial, well-architected phase. The PR body is unusually self-aware about the bugs it found and fixed, and the review history shows the author engaged with all prior feedback constructively. My observations below are mostly confirmatory, with a few new findings.

Architecture & Design

Strong decisions:

  • nodeDisplayDefaults.ts being zero-import / isomorphic is the correct call for Phase 3 reuse. The hopEmoji.ts precedent cited in the file header is apt.
  • The NodeDisplayBatchReader interface being additive over NodeDisplaySettingsReader (not folded into one) is correct — only the hot unifiedRoutes dashboard fan-out needs batching, and the interface boundary makes that opt-in explicit.
  • The getSettingForSources() reverse-map approach (prefixed key → sourceId rather than string-splitting) at settings.ts:227-229 is correct and defensively documented: a : in a sourceId would corrupt a split. Good.
  • tick() comment at inactiveNodeNotificationService.ts:141-148 correctly documents the ordering dependency between the prune step and the due filter, and why re-reading the registry in between would break them. This directly addresses the previous review's observation.

Code Quality

neighborInfoRoutes.ts:19-22 — silent null→undefined coercion:

const maxNodeAgeHours = await getMaxNodeAgeHours(
  databaseService.settings,
  neighborInfoSourceId ?? null,
);

neighborInfoSourceId is typed string | undefined; ?? null converts undefinednull. getMaxNodeAgeHours then calls getSettingForSource(null, key) which reads the un-namespaced global row. This is correct per the spec, but the type signature mismatch between the query param (string | undefined) and getSettingForSource's string | null | undefined means the ?? null is load-bearing — if it were removed, the behavior would be identical but the intent less clear. A comment on the ?? null would help the next reader understand this is not a defensive no-op.

tracerouteRoutes.ts:21getSetting without async wrapper:

const tracerouteIntervalMinutes = parseInt(
  await databaseService.settings.getSetting('tracerouteIntervalMinutes') || '5'
);

getSetting returns Promise<string | null>. The || '5' fallback is applied to the resolved value — correct. But this line reads the global, un-namespaced tracerouteIntervalMinutes even when recentSourceId is defined. This was pre-existing behavior (not introduced by this PR) and is mentioned in the previous review as a Phase 3/4 concern, so no action required now — just confirming it's known.

inactiveNodeNotificationService.ts:143-151 — per-due-source config reads are serial:

for (const manager of due) {
  try {
    const cfg = await getInactiveNodeConfig(databaseService.settings, manager.sourceId);
    this.nextRunAt.set(manager.sourceId, now + cfg.checkIntervalMinutes * 60_000);
    // ... work ...
  }
}

Inside the zero-users branch, each due source's config is fetched with a separate await inside the for-loop. With N due sources this is N×3 sequential DB queries (three reads per getInactiveNodeConfig). The same pattern exists in the non-zero branch. Since the minimum check interval is 1 minute and most deployments will have few sources, this is not a practical problem — but a Promise.all(due.map(...)) for the config reads would be safer for large deployments. Not blocking; noting for Phase 3.

settingsRoutes.ts:1034 — global save response inconsistency:

res.json({ success: true, settings: filteredSettings });

The per-source save at line 825 uses ok(res, { ignoredKeys }). The global path at 1034 hand-rolls { success: true, settings }. As noted in the prior review, this is an opportunistic-conversion concern per CLAUDE.md — not blocking, but the asymmetry remains.

Potential Bugs

parseNodeDisplayNumberinteger: true in NODE_DISPLAY_RANGES is unused:

src/constants/nodeDisplayDefaults.ts:87-94 defines ranges with an integer field:

maxNodeAgeHours: { min: 1, max: 168, integer: true },

But parseNodeDisplayNumber never reads range.integer (line 116: only n < range.min || n > range.max). So parseNodeDisplayNumber('maxNodeAgeHours', '1.5') returns 1.5, not clamped to default and not truncated to 1. The field is presumably intended for Phase 3 UI validation. This is intentional and not a bug in this PR — but the integer field is dead code today and may cause confusion. A brief comment noting it's Phase 3's responsibility would help.

getSettingForSourcesinArray with a large sourceId list:

At settings.ts:236: inArray(settings.key, [...prefixedToSourceId.keys()]). SQLite's IN (...) has a default maximum of 999 parameters (compile-time SQLITE_LIMIT_VARIABLE_NUMBER, default 999 in older builds, 32766 in newer). With N sources each needing one prefixed key, a deployment with >999 sources would fail silently. In practice no deployment will have >999 sources, but this limit is worth a code comment if the method is later extended to multi-key batching.

Performance

unifiedRoutes.ts:1300-1303getMaxNodeAgeHoursForSources correctly batches into a single IN() for the dashboard fan-out. This was the hot-path concern and it's handled correctly.

getInactiveNodeConfig at zero-users branch — three DB reads per due source, sequential. As noted above, acceptable for current scale.

Security

Test Coverage

Strengths (directly observed):

  • nodeDisplayDefaults.test.ts is thorough: key count, no duplicates, subset-of-valid-keys, byte-identity with migration 131 seed, '0'/'1' convention, parseNodeDisplayNumber edge cases (0-below-min, out-of-range, unranged keys), parseNodeDisplayBoolean round-trips.
  • nodeDisplaySettings.test.ts pins the "no global fallback" invariant with explicit counter-example: global row present + no per-source override → hardcoded default, not the global row. This is the regression being prevented.
  • settings.test.ts adds six new getSettingForSources tests including one that monkey-patches db.select to count actual round-trips — the "issues exactly one SELECT" assertion is unusually rigorous.
  • inactiveNodeNotificationService.perSource.test.ts uses fake timers to prove four independent scheduling behaviors: single timer, independent cadences, independent cooldowns, and bounded-map growth on deregistration.
  • database.maxNodeAge.perSource.test.ts is an integration test against a real SQLite instance (not just mocks), proving the full stack from settings write through DB query to actual node selection.

One minor gap — zero-users scheduler fix test:

inactiveNodeNotificationService.test.ts is supposed to contain the "one query per tick, five ticks" test for the zero-users hot-loop regression (per the PR body). I didn't read that file in full. If that test is there, the coverage is complete. The perSource test file does not cover the zero-users scheduler cadence directly — it relies on having at least one eligible user (mocked makeRow).

nodeDimmingStartHours/nodeDimmingMinOpacity — unranged behavior is pinned:

nodeDisplayDefaults.test.ts:100-109 explicitly pins that unranged keys accept out-of-range values including negatives. Good — this is the intentional behavior, and pinning it prevents an accidental future range addition from silently breaking stored configs.

Summary

The PR is well-executed. The three main goals — zero global reads of the 10 Node Display keys, zero-users scheduler hot-loop fixed, and two bug-masking tests replaced — are all achieved. The nodeDisplayDefaults.ts / nodeDisplaySettings.ts foundation is reusable for Phase 3 without modification. The batch read path for the unified dashboard is correct and efficient.

The items above are informational for Phase 3/4 planning. No blocking issues found.

Verdict: Ready to merge.

@Yeraze
Yeraze merged commit 0fb935d into main Jul 29, 2026
16 of 17 checks passed
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