From 7a683e49ef6ff137114d8eb978ad47c353377c2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 09:16:23 +0000 Subject: [PATCH] Fix viewer join broadcasting device registry DELETE changesets When a desktop connects to a remote brain as viewer, clearClusterRegistryForViewerJoin wiped local devices/sync_cluster_state and flushLocalChanges relayed those DELETEs to the brain, which could tombstone the entire paired device registry cluster-wide. Discard unpublished crsql_changes for those tables after the local clear and reset the peer outbound cursor before connecting so only post-handshake updates (e.g. touchLocalDevice) are synced. Co-authored-by: Arul Sharma --- .../services/sync/deviceRegistryService.ts | 6 +++ .../src/services/sync/syncPeerService.ts | 5 +++ apps/ade-cli/src/services/sync/syncService.ts | 1 + .../services/history/operationService.test.ts | 1 + .../onboarding/onboardingService.test.ts | 1 + .../services/prs/prIssueResolution.test.ts | 8 +++- .../src/main/services/prs/prService.test.ts | 8 +++- apps/desktop/src/main/services/state/kvDb.ts | 19 ++++++++ .../sync/deviceRegistryService.test.ts | 43 +++++++++++++++++++ 9 files changed, 90 insertions(+), 2 deletions(-) diff --git a/apps/ade-cli/src/services/sync/deviceRegistryService.ts b/apps/ade-cli/src/services/sync/deviceRegistryService.ts index c4facdf0f..c7b52742d 100644 --- a/apps/ade-cli/src/services/sync/deviceRegistryService.ts +++ b/apps/ade-cli/src/services/sync/deviceRegistryService.ts @@ -629,6 +629,12 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { }); args.db.run("delete from sync_cluster_state"); args.db.run("delete from devices"); + // Local DELETEs are CRR changesets; discard them so connectToBrain / + // disconnectFromBrain cannot broadcast mass device tombstones to the brain + // or paired peers. + if (args.db.sync.isAvailable?.()) { + args.db.sync.discardUnpublishedChangesForTables(["devices", "sync_cluster_state"]); + } }; const forgetDevice = (deviceId: string): void => { diff --git a/apps/ade-cli/src/services/sync/syncPeerService.ts b/apps/ade-cli/src/services/sync/syncPeerService.ts index 3ec1a2d77..2ea288879 100644 --- a/apps/ade-cli/src/services/sync/syncPeerService.ts +++ b/apps/ade-cli/src/services/sync/syncPeerService.ts @@ -538,6 +538,11 @@ export function createSyncPeerService(args: SyncPeerServiceArgs) { sendLocalChanges(); }, + acknowledgeLocalDbVersion(): void { + pendingOutboundChangeset = null; + outboundLocalDbVersion = args.db.sync.getDbVersion(); + }, + async executeRemoteCommand(action: SyncRemoteCommandAction | (string & {}), commandArgs: Record): Promise { if (!ws || ws.readyState !== WebSocket.OPEN) { throw new Error("Not connected to a host device."); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 083140baf..ce0ac18d8 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -1019,6 +1019,7 @@ export function createSyncService(args: SyncServiceArgs) { } await stopHostIfRunning(); deviceRegistryService.clearClusterRegistryForViewerJoin(); + syncPeerService.acknowledgeLocalDbVersion(); writeSavedDraft(draft); syncPeerService.setSavedDraft(draft); try { diff --git a/apps/desktop/src/main/services/history/operationService.test.ts b/apps/desktop/src/main/services/history/operationService.test.ts index b4f2faf1a..4415a7c7d 100644 --- a/apps/desktop/src/main/services/history/operationService.test.ts +++ b/apps/desktop/src/main/services/history/operationService.test.ts @@ -82,6 +82,7 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { touchedTables: [], rebuiltFts: false, }), + discardUnpublishedChangesForTables: () => {}, }, flushNow: () => undefined, close: () => raw.close(), diff --git a/apps/desktop/src/main/services/onboarding/onboardingService.test.ts b/apps/desktop/src/main/services/onboarding/onboardingService.test.ts index 0acad3eef..5b9f12ecd 100644 --- a/apps/desktop/src/main/services/onboarding/onboardingService.test.ts +++ b/apps/desktop/src/main/services/onboarding/onboardingService.test.ts @@ -30,6 +30,7 @@ function createInMemoryAdeDb(): AdeDb { getDbVersion: () => 0, exportChangesSince: () => [], applyChanges: () => ({ appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false }), + discardUnpublishedChangesForTables: () => {}, }, flushNow: () => {}, close: () => {} diff --git a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts index c64c700f2..31130e4fa 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts @@ -33,7 +33,13 @@ function makeMockDb() { run: vi.fn(), getJson: vi.fn(() => null), setJson: vi.fn(), - sync: { getSiteId: vi.fn(), getDbVersion: vi.fn(), exportChangesSince: vi.fn(), applyChanges: vi.fn() }, + sync: { + getSiteId: vi.fn(), + getDbVersion: vi.fn(), + exportChangesSince: vi.fn(), + applyChanges: vi.fn(), + discardUnpublishedChangesForTables: vi.fn(), + }, flushNow: vi.fn(), close: vi.fn(), } as any; diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 4897a71ce..af1f8f7b5 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -59,7 +59,13 @@ function makeMockDb() { run: vi.fn(), getJson: vi.fn(() => null), setJson: vi.fn(), - sync: { getSiteId: vi.fn(), getDbVersion: vi.fn(), exportChangesSince: vi.fn(), applyChanges: vi.fn() }, + sync: { + getSiteId: vi.fn(), + getDbVersion: vi.fn(), + exportChangesSince: vi.fn(), + applyChanges: vi.fn(), + discardUnpublishedChangesForTables: vi.fn(), + }, flushNow: vi.fn(), close: vi.fn(), } as any; diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 48fc4d32c..9ff0f9fa9 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -23,6 +23,12 @@ export type AdeDbSyncApi = { getDbVersion: () => number; exportChangesSince: (version: number) => CrsqlChangeRow[]; applyChanges: (changes: CrsqlChangeRow[]) => ApplyRemoteChangesResult; + /** + * Drop unpublished local-site rows from crsql_changes for the given tables. + * Used when a viewer clears local registry state that must not be relayed to + * the brain or paired peers. + */ + discardUnpublishedChangesForTables: (tableNames: string[]) => void; }; /** @@ -3006,6 +3012,19 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { rebuiltFts: false, }; }, + discardUnpublishedChangesForTables: (tableNames: string[]) => { + if (!crsqliteLoaded || tableNames.length === 0) return; + runStatement(db, "begin"); + try { + for (const tableName of tableNames) { + runStatement(db, "delete from crsql_changes where [table] = ?", [tableName]); + } + runStatement(db, "commit"); + } catch (err) { + runStatement(db, "rollback"); + throw err; + } + }, }; return { diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 9539d4e61..8c7e6a328 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../shared/types/sync"; import { openKvDb } from "../state/kvDb"; +import { isCrsqliteAvailable } from "../state/crsqliteExtension"; +import { nowIso } from "../shared/utils"; import { createDeviceRegistryService } from "./deviceRegistryService"; function createLogger() { @@ -154,6 +156,47 @@ describe("deviceRegistryService", () => { dbB.close(); }); + it("does not leave device-registry DELETE changesets after viewer join clear", async () => { + if (!isCrsqliteAvailable()) return; + + const projectRoot = makeProjectRoot("ade-device-registry-viewer-clear-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + const registry = createDeviceRegistryService({ + db, + logger: createLogger() as any, + projectRoot, + }); + + const local = registry.ensureLocalDevice(); + registry.upsertPeerMetadata( + { + deviceId: "peer-phone", + deviceName: "Phone", + platform: "iOS", + deviceType: "phone", + siteId: "site-phone", + dbVersion: 0, + capabilities: [], + }, + { lastSeenAt: nowIso() }, + ); + expect(registry.listDevices().length).toBeGreaterThan(1); + + const versionBeforeClear = db.sync.getDbVersion(); + registry.clearClusterRegistryForViewerJoin(); + expect(registry.listDevices()).toHaveLength(0); + + const deviceChanges = db.sync.exportChangesSince(versionBeforeClear).filter((change) => change.table === "devices"); + expect(deviceChanges).toHaveLength(0); + + const recreated = registry.ensureLocalDevice(); + expect(recreated.deviceId).toBe(local.deviceId); + expect(registry.listDevices()).toHaveLength(1); + + db.close(); + }); + it("persists notification preferences in device metadata across registry restarts", async () => { const projectRoot = makeProjectRoot("ade-device-registry-prefs-"); const dbPath = path.join(projectRoot, ".ade", "ade.db");