diff --git a/backend/src/__tests__/database-service-pilot-mesh-stacks.test.ts b/backend/src/__tests__/database-service-pilot-mesh-stacks.test.ts new file mode 100644 index 00000000..d24d4536 --- /dev/null +++ b/backend/src/__tests__/database-service-pilot-mesh-stacks.test.ts @@ -0,0 +1,76 @@ +/** + * Regression guard for F-A8: on pilot-mode hosts, `mesh_stacks` is dropped + * at boot and the CRUD methods short-circuit. + * + * - Per the C-3 design, mesh state lives on central. Pilots learn aliases + * via the D-1 override push and hold them in MeshService.pilotAliasOverlay. + * They never write to the local DB; the table is dead on pilots. + * + * - DatabaseService runs `DROP TABLE IF EXISTS mesh_stacks` in + * `migrateMeshTables()` when `SENCHO_MODE === 'pilot'` and skips the + * CREATE. The four CRUD methods (`listMeshStacks`, `isMeshStackEnabled`, + * `insertMeshStack`, `deleteMeshStack`) short-circuit on the same check + * so the dropped table is never queried. + * + * - Central-mode behavior is unchanged: every other mesh test in the suite + * (mesh-service.test.ts, mesh-diagnostic-local.test.ts, ...) exercises + * the table normally with `SENCHO_MODE` unset and serves as the implicit + * negative control. + * + * The test sets `SENCHO_MODE=pilot` BEFORE `setupTestDb` so the singleton's + * constructor runs `migrateMeshTables()` in pilot mode against the freshly + * copied baseline DB. The gate-function assertions then exercise the runtime + * short-circuits. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + process.env.SENCHO_MODE = 'pilot'; + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => { + delete process.env.SENCHO_MODE; + cleanupTestDb(tmpDir); +}); + +describe('DatabaseService mesh_stacks lifecycle on pilot-mode hosts (F-A8)', () => { + it('drops the mesh_stacks table during migration on pilot mode', () => { + const db = DatabaseService.getInstance(); + const row = db.getDb().prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'mesh_stacks'" + ).get(); + expect(row).toBeUndefined(); + }); + + it('listMeshStacks returns [] without querying the dropped table', () => { + const db = DatabaseService.getInstance(); + expect(db.listMeshStacks()).toEqual([]); + expect(db.listMeshStacks(1)).toEqual([]); + }); + + it('isMeshStackEnabled returns false without querying the dropped table', () => { + const db = DatabaseService.getInstance(); + expect(db.isMeshStackEnabled(1, 'any-stack')).toBe(false); + }); + + it('insertMeshStack is a no-op (no row written, no SQL touched)', () => { + const db = DatabaseService.getInstance(); + expect(() => db.insertMeshStack(1, 'guarded-stack', 'tester')).not.toThrow(); + expect(db.listMeshStacks(1)).toEqual([]); + const tableStillAbsent = db.getDb().prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'mesh_stacks'" + ).get(); + expect(tableStillAbsent).toBeUndefined(); + }); + + it('deleteMeshStack is a no-op', () => { + const db = DatabaseService.getInstance(); + expect(() => db.deleteMeshStack(1, 'any-stack')).not.toThrow(); + }); +}); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 293839ae..2f99b6e3 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -4,6 +4,10 @@ import fs from 'fs'; import { CryptoService } from './CryptoService'; import { isSeverityAtLeast } from '../utils/severity'; +function isPilotMode(): boolean { + return process.env.SENCHO_MODE === 'pilot'; +} + export interface Agent { id?: number; type: 'discord' | 'slack' | 'webhook'; @@ -1462,20 +1466,28 @@ export class DatabaseService { private migrateMeshTables(): void { try { - this.db.prepare(` - CREATE TABLE IF NOT EXISTS mesh_stacks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - node_id INTEGER NOT NULL, - stack_name TEXT NOT NULL, - created_at INTEGER NOT NULL, - created_by TEXT, - UNIQUE(node_id, stack_name), - FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE - ) - `).run(); - this.db.prepare('CREATE INDEX IF NOT EXISTS idx_mesh_stacks_node ON mesh_stacks(node_id)').run(); + if (isPilotMode()) { + // Per C-3 design, mesh state lives on central. Pilots never write + // to mesh_stacks; alias data arrives via the D-1 override push + // and lives in MeshService.pilotAliasOverlay. Drop any leftover + // rows from a prior central-mode boot and skip the CREATE. + this.db.prepare('DROP TABLE IF EXISTS mesh_stacks').run(); + } else { + this.db.prepare(` + CREATE TABLE IF NOT EXISTS mesh_stacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + stack_name TEXT NOT NULL, + created_at INTEGER NOT NULL, + created_by TEXT, + UNIQUE(node_id, stack_name), + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ) + `).run(); + this.db.prepare('CREATE INDEX IF NOT EXISTS idx_mesh_stacks_node ON mesh_stacks(node_id)').run(); + } } catch (e) { - console.warn('[DatabaseService] Could not create mesh_stacks:', (e as Error).message); + console.warn('[DatabaseService] mesh_stacks migration:', (e as Error).message); } try { this.db.prepare(` @@ -1600,6 +1612,7 @@ export class DatabaseService { // --- Sencho Mesh --- public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> { + if (isPilotMode()) return []; const sql = nodeId !== undefined ? 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks WHERE node_id = ?' : 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks'; @@ -1610,17 +1623,23 @@ export class DatabaseService { } public isMeshStackEnabled(nodeId: number, stackName: string): boolean { + if (isPilotMode()) return false; const row = this.db.prepare('SELECT 1 FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName); return !!row; } public insertMeshStack(nodeId: number, stackName: string, createdBy: string | null): void { + if (isPilotMode()) { + console.warn(`[DatabaseService] insertMeshStack ignored on pilot (node=${nodeId}, stack=${stackName})`); + return; + } this.db.prepare( 'INSERT INTO mesh_stacks (node_id, stack_name, created_at, created_by) VALUES (?, ?, ?, ?)' ).run(nodeId, stackName, Date.now(), createdBy); } public deleteMeshStack(nodeId: number, stackName: string): void { + if (isPilotMode()) return; this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); }