Skip to content

fix(nodes): position override writes to live source row, not 'default'#2913

Merged
Yeraze merged 1 commit into
mainfrom
fix/position-override-source-id
May 6, 2026
Merged

fix(nodes): position override writes to live source row, not 'default'#2913
Yeraze merged 1 commit into
mainfrom
fix/position-override-source-id

Conversation

@Yeraze

@Yeraze Yeraze commented May 6, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #2902 — clicking Override Node Position in PostgreSQL/MySQL deployments appeared to succeed, but the node never moved on the map. The nodes table ended up with two rows per node: the live-source row (untouched) and a stray sourceId='default' row containing the override coordinates.

Root cause

DatabaseService.setNodePositionOverride() (Postgres/MySQL path at src/services/database.ts:7187):

  1. Looks up the cached node with the correct live sourceId — OK.
  2. Mutates the cached node's override fields in place — OK.
  3. Calls nodesRepo.upsertNode(existingNode)broken. The second arg (sourceId) was omitted, so NodesRepository.upsertNode fell back to sourceId ?? 'default', ran getNode(nodeNum, 'default') (returned null), and INSERTed a brand-new row with sourceId='default'. The live-source row never received the override, so the map (which renders from the live row) never moved.

The SQLite path used a raw UPDATE ... WHERE sourceId = ? and was unaffected.

Changes

  1. src/db/repositories/nodes.tsupsertNode now falls back to nodeData.sourceId before defaulting to 'default'. Cached node objects always carry their sourceId; honoring it prevents the silent stray-row bug for any current or future caller.
  2. src/services/database.ts:7187setNodePositionOverride now passes sourceId explicitly to nodesRepo.upsertNode. Belt & suspenders with "Claude PR Assistant workflow" #1.
  3. src/services/database.ts:2147 — same explicit-sourceId pattern applied to the other bare nodesRepo.upsertNode call (DatabaseService.upsertNode).

Regression test

src/db/repositories/nodes.test.ts — new test upsertNode - falls back to nodeData.sourceId when sourceId arg omitted (issue #2902) verifies that calling upsertNode with a node object whose sourceId field is set writes to that source's row and does not create a stray 'default' row. Runs against all three backend harnesses (SQLite / Postgres / MySQL).

Test plan

  • CI green on SQLite, Postgres, and MySQL test suites
  • Manual: in a PG deployment with sourceId != 'default', set a position override → live-source row gets the override fields and the marker moves on the map
  • Manual: confirm no new sourceId='default' row is created in the nodes table

🤖 Generated with Claude Code

…ault'

Issue #2902: clicking "Override Node Position" appeared to succeed but
the node never moved on the map. PostgreSQL/MySQL deployments ended up
with two rows per node — the live-source row (untouched) and a stray
sourceId='default' row containing the override coordinates.

Root cause
==========
DatabaseService.setNodePositionOverride() (Postgres/MySQL path):
  - Looks up the cached node with the correct live sourceId — OK.
  - Mutates the cached node's override fields in place — OK.
  - Calls `nodesRepo.upsertNode(existingNode)` — BROKEN.
    The second arg (sourceId) was omitted, so NodesRepository.upsertNode
    fell back to `sourceId ?? 'default'`. It then ran getNode(nodeNum,
    'default'), got null (no such row existed), and INSERTed a brand-new
    row with sourceId='default'. The live-source row never received the
    override, so the map (which renders from the live row) never moved.

Fixes
=====
1. NodesRepository.upsertNode: when the caller omits the sourceId arg,
   fall back to `nodeData.sourceId` before defaulting to 'default'.
   Cached node objects always carry their sourceId; honoring it
   prevents the silent stray-row bug for any current or future caller.

2. DatabaseService.setNodePositionOverride (line ~7187): pass the
   sourceId argument explicitly to nodesRepo.upsertNode. Belt and
   suspenders with fix #1, but makes intent obvious at the call site.

3. DatabaseService.upsertNode (line ~2147): same explicit-sourceId
   pattern applied to the other bare nodesRepo.upsertNode call. The
   merged node already carries `sourceId: upsertSourceId`, but passing
   it explicitly removes the implicit dependency on fix #1.

Regression test
===============
nodes.test.ts: new test 'upsertNode - falls back to nodeData.sourceId
when sourceId arg omitted' verifies that calling upsertNode with a
node object whose sourceId field is set (mirroring the cached-node
path) writes to that source's row and does NOT create a 'default'
row. Runs against all three backend harnesses (SQLite/Postgres/MySQL).

SQLite was unaffected — its raw-SQL path in setNodePositionOverride
already used the correct sourceId.

Fixes #2902

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

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review Complete ✅

I've thoroughly analyzed this pull request fixing the position override bug in PostgreSQL/MySQL deployments. This is a high-quality fix with excellent attention to detail.

Code Quality & Best Practices

Excellent defensive programming:

  • The fix implements both belt-and-suspenders approach: fallback logic in NodesRepository.upsertNode (lines 304) AND explicit sourceId passing at call sites (database.ts:7191, 2149)
  • Clear, descriptive comments explaining the bug and fix rationale
  • Proper error handling maintained throughout

Good architectural decisions:

  • Preserves the repository pattern - fixes the root cause in NodesRepository rather than band-aid solutions
  • Maintains backward compatibility by falling back to nodeData.sourceId before defaulting to 'default'
  • Consistent application of the fix to all upsertNode call sites in DatabaseService

Bug Analysis - Root Cause Correctly Identified

The diagnosis is spot-on:

  • PostgreSQL/MySQL path: setNodePositionOverride mutated cached node objects in place but failed to pass sourceId to upsertNode(), causing fallback to 'default' and creating stray rows
  • SQLite path: Used direct raw SQL UPDATE ... WHERE sourceId = ? so was unaffected
  • Impact: Position overrides appeared to succeed but nodes never moved on the map because the live-source row was never updated

Security Assessment

No security concerns identified:

  • All database operations properly use parameterized queries/Drizzle ORM
  • No SQL injection vectors introduced
  • Access control unchanged - position overrides still require appropriate permissions
  • No sensitive data exposure

Performance Considerations

Implementation is performance-neutral:

  • No additional database queries introduced
  • Fallback logic (sourceId ?? nodeData.sourceId ?? 'default') is O(1)
  • Cache synchronization patterns preserved

Test Coverage - Excellent Regression Protection

The new test upsertNode - falls back to nodeData.sourceId when sourceId arg omitted (issue #2902) is exemplary:

Comprehensive coverage:

  • Tests the exact scenario that caused the bug (cached node object with sourceId field, omitted sourceId argument)
  • Validates the positive case (live-source row gets updated correctly)
  • Validates the negative case (no stray 'default' row created) - this is crucial
  • Runs against all three database backends (SQLite/PostgreSQL/MySQL)

Real-world simulation:

  • Uses realistic test data (position override fields)
  • Mirrors the actual setNodePositionOverride code path

Minor Suggestions

  1. Comment enhancement (optional): Consider adding a brief code comment at the fallback logic in nodes.ts:304 referencing issue [BUG] Node position override saved to non-rendered source row (override not applied) #2902 for future maintainers:

    // Fall back to nodeData.sourceId to prevent stray 'default' rows (issue #2902)
    const effectiveSourceId = sourceId ?? (nodeData as { sourceId?: string }).sourceId ?? 'default';
  2. Test assertion precision (very minor): The test uses toBeCloseTo() for latitude/longitude, which is good practice for floating-point comparisons.

🎯 Overall Assessment

This is an excellent fix that demonstrates:

  • ✅ Correct root cause analysis
  • ✅ Defensive programming with multiple layers of protection
  • ✅ Comprehensive test coverage including edge cases
  • ✅ Clear documentation of the problem and solution
  • ✅ Consistent application across the codebase

The fix is minimal, targeted, and preserves all existing functionality while solving the critical issue. The regression test provides strong protection against future regressions.

Recommendation: APPROVE - This fix is ready for merge.


@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
System Test Results

MeshMonitor System Test Results

Test Run: 2026-05-05 23:50:00 EDT

Test Summary

Test Suite Result
Configuration Import ✅ PASSED
Quick Start Test ✅ PASSED
Security Test ✅ PASSED
V1 API Test ✅ PASSED
Reverse Proxy Test ✅ PASSED
Reverse Proxy + OIDC ✅ PASSED
Virtual Node CLI Test ✅ PASSED
Backup & Restore Test ✅ PASSED
Database Migration Test ✅ PASSED
DB Backing Consistency ✅ PASSED
API Exercise (3 DBs) ✅ PASSED

✅ Overall Result: PASSED

All deployment configurations are working correctly!

Test Details

Configuration Import:

  • Tests configuration import and device reboot cycle
  • Verifies channel roles, PSKs, and LoRa configuration
  • Note: Channel name verification skipped due to architectural limitation

Quick Start Test:

  • Zero-config deployment (no SESSION_SECRET or COOKIE_SECURE required)
  • HTTP access without HSTS
  • Auto-generated admin user with default credentials
  • Session cookies work over HTTP
  • Meshtastic node connection and message exchange verified

Security Test:

  • Verifies Node IP address hidden from anonymous users in API responses
  • Verifies MQTT configuration hidden from anonymous users
  • Verifies Node IP address visible to authenticated users
  • Verifies MQTT configuration visible to authenticated users
  • Verifies protected endpoints require authentication

V1 API Test:

  • Tests v1 REST API endpoints with Bearer token authentication
  • Verifies Bearer token requests bypass CSRF protection
  • Verifies POST/PUT/DELETE work without CSRF token when using Bearer auth
  • Verifies session-based requests still require CSRF token

Reverse Proxy Test:

  • Production deployment with COOKIE_SECURE=true
  • HTTPS-ready configuration
  • Trust proxy enabled for reverse proxy compatibility
  • CORS configured for HTTPS domain
  • Meshtastic node connection and message exchange verified

Reverse Proxy + OIDC Test:

  • OIDC authentication integration
  • Mock OIDC provider health checks
  • Authorization flow and session creation
  • Hybrid mode (OIDC + local auth)
  • Meshtastic node connection verified

Virtual Node CLI Test:

  • Virtual Node Server enabled on TCP port 4404
  • Meshtastic Python client successfully connects
  • Node data download and synchronization verified
  • Test message sent on gauntlet channel (index 3)
  • Message delivery confirmed via Web UI API
  • Virtual Node Server connection logging verified

Backup & Restore Test:

  • System backup created from running dev container
  • New container spun up with RESTORE_FROM_BACKUP env var
  • Data integrity verified (node count, message count, settings)
  • Restore event logged in audit log
  • Dev container unaffected by restore test

Database Migration Test:

  • SQLite to PostgreSQL migration verified
  • SQLite to MySQL migration verified
  • Data integrity confirmed for both target databases
  • Row counts match between source and target

DB Backing Consistency Test:

  • SQLite, PostgreSQL, and MySQL backends tested with same device
  • Node counts within ±10 across all three backends
  • Favorite counts identical across all backends
  • Key station verified as favorite on all backends

@Yeraze Yeraze merged commit 10c0d47 into main May 6, 2026
20 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.

[BUG] Node position override saved to non-rendered source row (override not applied)

1 participant