From aed83ceec6e19014c2b7f28603f38691e910c6cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 06:18:49 +0000 Subject: [PATCH 1/2] Fix synced queue wipe replication and preserve dirty file session buffers The stacked-PR queue overhaul migration deleted queue_landing_state while CRR triggers were active, replicating DELETE tombstones to peers that had not upgraded yet. Strip CRR metadata before the one-shot local wipe and run it after migrations but before ensureCrrTables re-registers replication. Files tab session caching dropped unsaved buffers over 8 MiB when switching project/lane scope. Always retain dirty tabs in the session snapshot so large in-flight edits are not lost silently on tab reentry. Co-authored-by: Arul Sharma --- .../src/main/services/state/kvDb.sync.test.ts | 53 +++++++++++ apps/desktop/src/main/services/state/kvDb.ts | 87 +++++++++++++------ .../renderer/components/files/FilesPage.tsx | 5 +- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/services/state/kvDb.sync.test.ts b/apps/desktop/src/main/services/state/kvDb.sync.test.ts index 31e115718..920126493 100644 --- a/apps/desktop/src/main/services/state/kvDb.sync.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.sync.test.ts @@ -178,6 +178,59 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { repaired.close(); }); + it("does not replicate queue_landing_state overhaul wipe deletes to synced peers", async () => { + const dbPathA = makeDbPath("ade-kvdb-sync-queue-wipe-a-"); + const dbA = await openKvDb(dbPathA, createLogger() as any); + const projectId = "project-queue-wipe"; + const groupId = "group-queue-wipe"; + const queueId = "queue-wipe-1"; + + dbA.run( + `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values (?, ?, ?, ?, ?, ?)`, + [projectId, "/repo/queue-wipe", "Queue Wipe", "main", "2026-03-15T00:00:00.000Z", "2026-03-15T00:00:00.000Z"], + ); + dbA.run( + `insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, ?, ?, 0, 0, ?, ?)`, + [groupId, projectId, "stack", "Stack", "main", "2026-03-15T00:00:00.000Z"], + ); + dbA.run( + `insert into queue_landing_state( + id, group_id, project_id, state, entries_json, config_json, current_position, started_at + ) values (?, ?, ?, ?, ?, ?, 0, ?)`, + [queueId, groupId, projectId, "active", "[]", "{}", "2026-03-15T00:00:00.000Z"], + ); + + const dbB = await openKvDb(makeDbPath("ade-kvdb-sync-queue-wipe-b-"), createLogger() as any); + const baselineChanges = dbA.sync.exportChangesSince(0); + expect(baselineChanges.some((change) => change.table === "queue_landing_state")).toBe(true); + dbB.sync.applyChanges(baselineChanges); + expect( + dbB.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId])?.id, + ).toBe(queueId); + + const versionBeforeWipe = dbA.sync.getDbVersion(); + dbA.run("delete from kv where key = ?", ["queue_landing_state.wiped_for_stacked_overhaul.v1"]); + dbA.close(); + + const dbAReopened = await openKvDb(dbPathA, createLogger() as any); + expect( + dbAReopened.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId]), + ).toBeNull(); + + const wipeChanges = dbAReopened.sync.exportChangesSince(versionBeforeWipe); + expect(wipeChanges.some((change) => change.table === "queue_landing_state")).toBe(false); + + dbB.sync.applyChanges(wipeChanges); + expect( + dbB.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId])?.id, + ).toBe(queueId); + + dbAReopened.close(); + dbB.close(); + }); + it("ignores CRDT changes for legacy unified_memories tables removed in #329", async () => { const db2 = await openKvDb(makeDbPath("ade-kvdb-sync-mem-skip-"), createLogger() as any); const legacyChange = { diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 48fc4d32c..322d856f5 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -521,6 +521,65 @@ function dropCrrTriggers(db: DatabaseSyncType, tableName: string, logger?: Logge return triggers.length; } +/** Strip CRR triggers/metadata so row deletes stay local (no replicated tombstones). */ +function deleteAllRowsWithoutCrrReplication( + db: DatabaseSyncType, + tableName: string, + logger?: Logger, +): void { + if (!rawHasTable(db, tableName)) return; + + const clockTableName = `${tableName}__crsql_clock`; + const pksTableName = `${tableName}__crsql_pks`; + if (rawHasTable(db, clockTableName) || rawHasTable(db, pksTableName) || listCrrTriggers(db, tableName).length > 0) { + if (rawHasTable(db, "crsql_master") && rawHasColumn(db, "crsql_master", "tbl_name")) { + runStatement(db, "delete from crsql_master where tbl_name = ?", [tableName]); + } + if (rawHasTable(db, "crsql_changes") && rawHasColumn(db, "crsql_changes", "table")) { + runStatement(db, "delete from crsql_changes where [table] = ?", [tableName]); + } + try { + getRow(db, "select crsql_as_table(?) as ok", [tableName]); + } catch { + // Table may not be registered enough for crsql_as_table; shadow cleanup below still applies. + } + dropCrrTriggers(db, tableName, logger); + runStatement(db, `drop table if exists ${quoteIdentifier(clockTableName)}`); + runStatement(db, `drop table if exists ${quoteIdentifier(pksTableName)}`); + } + + runStatement(db, `delete from ${quoteIdentifier(tableName)}`); +} + +const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1"; + +/** + * One-shot local wipe of legacy queue_landing_state on upgrade to the stacked-PR + * queue overhaul. Must run after migrations and before ensureCrrTables so deletes + * do not replicate to peers that have not upgraded yet. + */ +function wipeQueueLandingStateForStackedOverhaulIfNeeded(db: DatabaseSyncType, logger?: Logger): void { + try { + const row = getRow<{ value: string }>( + db, + "select value from kv where key = ?", + [QUEUE_OVERHAUL_WIPE_MARKER], + ); + if (row) return; + + deleteAllRowsWithoutCrrReplication(db, "queue_landing_state", logger); + runStatement( + db, + "insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value", + [QUEUE_OVERHAUL_WIPE_MARKER, new Date().toISOString()], + ); + } catch { + // Table may not exist on a brand-new DB; initialization will create both + // tables and the next startup will record the marker. Skipping the wipe + // on a fresh DB is correct (nothing to wipe). + } +} + function removeExcludedCrrMetadata(db: DatabaseSyncType, logger?: Logger): void { for (const tableName of LOCAL_ONLY_CRR_EXCLUDED_TABLES) { const clockTableName = `${tableName}__crsql_clock`; @@ -1821,32 +1880,6 @@ function migrate(db: MigrationDb) { try { db.run("alter table queue_landing_state add column wait_reason text"); } catch {} try { db.run("alter table queue_landing_state add column updated_at text"); } catch {} - // One-shot wipe of legacy queue_landing_state on upgrade to the stacked-PR - // queue overhaul. The new queue creates PRs with chain bases (PR_N's base = - // previous lane's branch) instead of all-into-main, so any in-flight queue - // from the old code path would be misinterpreted by the new landing loop. - // Wiping rather than migrating is a deliberate choice — the user accepts - // losing in-flight queues in exchange for not maintaining a translation - // layer for every legacy field shape. - const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1"; - try { - const row = db.get<{ value: string }>( - "select value from kv where key = ?", - [QUEUE_OVERHAUL_WIPE_MARKER], - ); - if (!row) { - db.run("delete from queue_landing_state"); - db.run( - "insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value", - [QUEUE_OVERHAUL_WIPE_MARKER, new Date().toISOString()], - ); - } - } catch { - // Table may not exist on a brand-new DB; initialization will create both - // tables and the next startup will record the marker. Skipping the wipe - // on a fresh DB is correct (nothing to wipe). - } - // Rebase dismiss/defer persistence db.run(` create table if not exists rebase_dismissed ( @@ -2849,6 +2882,8 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { removeExcludedCrrMetadata(db, logger); } + wipeQueueLandingStateForStackedOverhaulIfNeeded(db, logger); + if (crsqliteLoaded) { loadCrsqliteIfAvailable(); ensureCrrTables(db, logger); diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index be2dd7c2b..71d8da6b5 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -159,7 +159,6 @@ const filesRootTreeCacheByWorkspace = new Map(); const MAX_FILES_PAGE_CACHED_SCOPES = 8; const MAX_FILES_TREE_CACHED_WORKSPACES = 32; const MAX_CACHED_CLEAN_TAB_CHARS = 256 * 1024; -const MAX_CACHED_DIRTY_TAB_CHARS = 8 * 1024 * 1024; const MAX_QUEUED_TREE_PARENT_REFRESHES = 24; const FILES_WATCH_START_DELAY_MS = import.meta.env.MODE === "test" || (window as any).__adeBrowserMock ? 0 : 2_000; @@ -249,8 +248,10 @@ function filesPageSessionHasUnsavedTabs(session: FilesPageSessionState | undefin function cacheableSessionTabs(openTabs: OpenTab[]): OpenTab[] { return openTabs .filter((tab) => { + // Always retain unsaved buffers across session scope changes; size limits + // apply only to clean tabs so large in-flight edits are not dropped silently. if (tab.content !== tab.savedContent) { - return tab.content.length <= MAX_CACHED_DIRTY_TAB_CHARS; + return true; } return isTextTab(tab) && tab.content.length <= MAX_CACHED_CLEAN_TAB_CHARS; }) From 73fd64321af6eb66c1aa7fdb56ae748d5612c68b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 28 May 2026 03:21:14 -0400 Subject: [PATCH 2/2] Harden local-only queue wipe sync --- .../src/main/services/state/kvDb.sync.test.ts | 47 ++++++- apps/desktop/src/main/services/state/kvDb.ts | 59 +++++++-- .../components/files/FilesPage.test.tsx | 68 ++++++++++ apps/ios/ADE/Resources/DatabaseBootstrap.sql | 2 - apps/ios/ADE/Services/Database.swift | 117 ++++++++++++++++-- apps/ios/ADETests/ADETests.swift | 56 +++++++++ .../file-watcher-and-trust.md | 6 + .../sync-and-multi-device/crdt-model.md | 5 + 8 files changed, 332 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/services/state/kvDb.sync.test.ts b/apps/desktop/src/main/services/state/kvDb.sync.test.ts index 920126493..134aa1b51 100644 --- a/apps/desktop/src/main/services/state/kvDb.sync.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.sync.test.ts @@ -22,6 +22,26 @@ function makeDbPath(prefix: string): string { return path.join(root, ".ade", "kv.sqlite"); } +function packedTextPrimaryKey(text: string): { type: "bytes"; base64: string } { + const textBytes = Buffer.from(text, "utf8"); + return { + type: "bytes", + base64: Buffer.concat([Buffer.from([0x01, 0x0b, textBytes.length]), textBytes]).toString("base64"), + }; +} + +function syncPrimaryKeyMatchesText(value: unknown, text: string): boolean { + if (value === text) return true; + return Boolean( + value + && typeof value === "object" + && "type" in value + && (value as { type?: unknown }).type === "bytes" + && "base64" in value + && (value as { base64?: unknown }).base64 === packedTextPrimaryKey(text).base64, + ); +} + describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { it("persists a stable local site id and marks CRR tables", async () => { const dbPath = makeDbPath("ade-kvdb-sync-site-"); @@ -181,6 +201,7 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { it("does not replicate queue_landing_state overhaul wipe deletes to synced peers", async () => { const dbPathA = makeDbPath("ade-kvdb-sync-queue-wipe-a-"); const dbA = await openKvDb(dbPathA, createLogger() as any); + const wipeMarker = "queue_landing_state.wiped_for_stacked_overhaul.v1"; const projectId = "project-queue-wipe"; const groupId = "group-queue-wipe"; const queueId = "queue-wipe-1"; @@ -211,7 +232,7 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { ).toBe(queueId); const versionBeforeWipe = dbA.sync.getDbVersion(); - dbA.run("delete from kv where key = ?", ["queue_landing_state.wiped_for_stacked_overhaul.v1"]); + dbA.run("delete from kv where key = ?", [wipeMarker]); dbA.close(); const dbAReopened = await openKvDb(dbPathA, createLogger() as any); @@ -221,12 +242,36 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => { const wipeChanges = dbAReopened.sync.exportChangesSince(versionBeforeWipe); expect(wipeChanges.some((change) => change.table === "queue_landing_state")).toBe(false); + expect(wipeChanges.some((change) => change.table === "kv" && syncPrimaryKeyMatchesText(change.pk, wipeMarker))).toBe(false); dbB.sync.applyChanges(wipeChanges); expect( dbB.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId])?.id, ).toBe(queueId); + const markerValueBeforeApply = dbB.get<{ value: string }>( + "select value from kv where key = ?", + [wipeMarker], + )?.value ?? null; + const versionBeforeMarkerApply = dbB.sync.getDbVersion(); + const markerApplyResult = dbB.sync.applyChanges([{ + table: "kv", + pk: packedTextPrimaryKey(wipeMarker), + cid: "value", + val: "remote-marker-should-not-apply", + col_version: 999, + db_version: versionBeforeMarkerApply + 1, + site_id: "f".repeat(32), + cl: 1, + seq: 1, + }]); + expect(markerApplyResult.appliedCount).toBe(0); + expect(markerApplyResult.touchedTables).toEqual([]); + expect(dbB.sync.getDbVersion()).toBe(versionBeforeMarkerApply); + expect( + dbB.get<{ value: string }>("select value from kv where key = ?", [wipeMarker])?.value ?? null, + ).toBe(markerValueBeforeApply); + dbAReopened.close(); dbB.close(); }); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 322d856f5..b19622d4e 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -553,10 +553,40 @@ function deleteAllRowsWithoutCrrReplication( const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1"; +function rawCrsqlPrimaryKeyMatchesText(value: unknown, text: string): boolean { + if (value === text) return true; + if (!(value instanceof Uint8Array)) return false; + + const packed = packedCrsqlPrimaryKey(text); + return isSyncScalarBytes(packed) + && Buffer.from(value).equals(Buffer.from(packed.base64, "base64")); +} + +function syncScalarPrimaryKeyMatchesText(value: SyncScalar, text: string): boolean { + if (value === text) return true; + + const packed = packedCrsqlPrimaryKey(text); + return isSyncScalarBytes(value) + && isSyncScalarBytes(packed) + && value.base64 === packed.base64; +} + +function isLocalOnlyQueueWipeMarkerRawChange(change: { table_name: string; pk: unknown }): boolean { + return change.table_name === "kv" + && rawCrsqlPrimaryKeyMatchesText(change.pk, QUEUE_OVERHAUL_WIPE_MARKER); +} + +function isLocalOnlyQueueWipeMarkerChange(change: CrsqlChangeRow): boolean { + return change.table === "kv" + && syncScalarPrimaryKeyMatchesText(change.pk, QUEUE_OVERHAUL_WIPE_MARKER); +} + /** * One-shot local wipe of legacy queue_landing_state on upgrade to the stacked-PR - * queue overhaul. Must run after migrations and before ensureCrrTables so deletes - * do not replicate to peers that have not upgraded yet. + * queue overhaul. Must run after migrations and before ensureCrrTables so queue + * deletes do not replicate to peers that have not upgraded yet. The marker is + * stored in kv for compatibility with the original migration, but filtered from + * CRDT import/export because it records local upgrade work, not shared state. */ function wipeQueueLandingStateForStackedOverhaulIfNeeded(db: DatabaseSyncType, logger?: Logger): void { try { @@ -2986,17 +3016,19 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { [version] ); - return rows.map((row) => ({ - table: row.table_name, - pk: encodeSyncScalar(row.pk), - cid: row.cid, - val: encodeSyncScalar(row.val), - col_version: Number(row.col_version), - db_version: Number(row.db_version), - site_id: Buffer.from(row.site_id).toString("hex"), - cl: Number(row.cl), - seq: Number(row.seq), - })); + return rows + .filter((row) => !isLocalOnlyQueueWipeMarkerRawChange(row)) + .map((row) => ({ + table: row.table_name, + pk: encodeSyncScalar(row.pk), + cid: row.cid, + val: encodeSyncScalar(row.val), + col_version: Number(row.col_version), + db_version: Number(row.db_version), + site_id: Buffer.from(row.site_id).toString("hex"), + cl: Number(row.cl), + seq: Number(row.seq), + })); }, applyChanges: (changes: CrsqlChangeRow[]) => { if (!crsqliteLoaded) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false }; @@ -3005,6 +3037,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { runStatement(db, "begin"); try { for (const rawChange of changes) { + if (isLocalOnlyQueueWipeMarkerChange(rawChange)) continue; // Skip changes for tables that no longer exist in the schema // (e.g. unified_memories removed in #329). if (!rawHasTable(db, rawChange.table)) continue; diff --git a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx index b453fdfd9..11b1d5826 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx @@ -836,6 +836,74 @@ describe("FilesPage", () => { } }); + it("preserves oversized dirty tabs when switching lane scopes", async () => { + const laneA = "lane-large-a"; + const laneB = "lane-large-b"; + useAppStore.setState({ + selectedLaneId: laneA, + lanes: [ + { id: laneA, name: "Large A", branchRef: "refs/heads/large-a" }, + { id: laneB, name: "Large B", branchRef: "refs/heads/large-b" }, + ] as any, + }); + vi.mocked(window.ade.files.listWorkspaces).mockResolvedValue([ + { + id: "primary", + kind: "primary", + laneId: null, + name: "ADE", + branchRef: "refs/heads/main", + rootPath: projectRoot, + isReadOnlyByDefault: false, + }, + { + id: "lane-large-a-ws", + kind: "worktree", + laneId: laneA, + name: "Large A", + branchRef: "refs/heads/large-a", + rootPath: `${projectRoot}/.ade/worktrees/large-a`, + isReadOnlyByDefault: false, + }, + { + id: "lane-large-b-ws", + kind: "worktree", + laneId: laneB, + name: "Large B", + branchRef: "refs/heads/large-b", + rootPath: `${projectRoot}/.ade/worktrees/large-b`, + isReadOnlyByDefault: false, + }, + ]); + + renderFilesPage({ + openFilePath: "src/index.ts", + }); + + await waitForEditorText("value = 1"); + + const oversizedDirtyContent = `dirty-start\n${"x".repeat(8 * 1024 * 1024 + 1)}\ndirty-end`; + act(() => { + latestMockEditor?.setValue(oversizedDirtyContent); + }); + expect(latestMockEditor?.getValue().length).toBe(oversizedDirtyContent.length); + + act(() => { + useAppStore.setState({ selectedLaneId: laneB }); + }); + await waitFor(() => { + expect(screen.getByText(/OPEN A FILE TO START EDITING/i)).toBeTruthy(); + }); + + act(() => { + useAppStore.setState({ selectedLaneId: laneA }); + }); + await waitFor(() => { + expect(latestMockEditor?.getValue().length).toBe(oversizedDirtyContent.length); + expect(latestMockEditor?.getValue().endsWith("dirty-end")).toBe(true); + }); + }); + it("treats Windows workspace paths case-insensitively for open tabs and watcher events", async () => { projectRoot = "C:/Repo"; resetStore(); diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index aec364101..c058111b7 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -817,8 +817,6 @@ alter table queue_landing_state add column wait_reason text; alter table queue_landing_state add column updated_at text; -delete from queue_landing_state; - create table if not exists rebase_dismissed ( lane_id text not null, project_id text not null, diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 008ffae86..1219bcc21 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -4,6 +4,7 @@ import SQLite3 private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) private let localDeleteColumnId = "-1" private let legacyDeleteColumnId = "__ade_deleted" +private let queueOverhaulWipeMarkerKey = "queue_landing_state.wiped_for_stacked_overhaul.v1" extension Notification.Name { static let adeDatabaseDidChange = Notification.Name("ADE.DatabaseDidChange") @@ -310,19 +311,20 @@ final class DatabaseService { while sqlite3_step(statement) == SQLITE_ROW { let table = stringValue(statement, index: 0) ?? "" let rawPk = scalarValue(statement, index: 1) - rows.append( - CrsqlChangeRow( - table: table, - pk: encodeOutgoingCrsqlPrimaryKey(table: table, pk: rawPk), - cid: stringValue(statement, index: 2) ?? "", - val: scalarValue(statement, index: 3), - colVersion: Int(sqlite3_column_int64(statement, 4)), - dbVersion: Int(sqlite3_column_int64(statement, 5)), - siteId: blobHexValue(statement, index: 6) ?? "", - cl: Int(sqlite3_column_int64(statement, 7)), - seq: Int(sqlite3_column_int64(statement, 8)), - ) + let change = CrsqlChangeRow( + table: table, + pk: encodeOutgoingCrsqlPrimaryKey(table: table, pk: rawPk), + cid: stringValue(statement, index: 2) ?? "", + val: scalarValue(statement, index: 3), + colVersion: Int(sqlite3_column_int64(statement, 4)), + dbVersion: Int(sqlite3_column_int64(statement, 5)), + siteId: blobHexValue(statement, index: 6) ?? "", + cl: Int(sqlite3_column_int64(statement, 7)), + seq: Int(sqlite3_column_int64(statement, 8)), ) + if !isLocalOnlyQueueWipeMarkerChange(change) { + rows.append(change) + } } return rows } @@ -350,6 +352,9 @@ final class DatabaseService { values (?, ?, ?, ?, ?, ?, ?, ?, ?) """ for rawChange in changes { + if isLocalOnlyQueueWipeMarkerChange(rawChange) { + continue + } if shouldIgnoreIncomingSyncTable(rawChange.table) { continue } @@ -2181,6 +2186,7 @@ final class DatabaseService { let bootstrapSQL = try loadBootstrapSQL() try executeBootstrapSQL(bootstrapSQL) try ensureHydrationProjectionColumns() + try wipeQueueLandingStateForStackedOverhaulIfNeeded() try ensureSyncMetadataTables() try ensureCrrTables() try repairPullRequestProjectionIntegrity() @@ -2692,6 +2698,77 @@ final class DatabaseService { } } + private func wipeQueueLandingStateForStackedOverhaulIfNeeded() throws { + guard hasTable(named: "kv") else { return } + let markerExists = queryInt64("select 1 from kv where key = ? limit 1", bind: { [self] statement in + try self.bindText(queueOverhaulWipeMarkerKey, to: statement, index: 1) + }) != nil + if markerExists { return } + + try deleteAllRowsWithoutCrrReplication(tableName: "queue_landing_state") + + let previousCaptureState = shouldCaptureLocalChanges + shouldCaptureLocalChanges = false + defer { shouldCaptureLocalChanges = previousCaptureState } + _ = try execute( + "insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value" + ) { statement in + try bindText(queueOverhaulWipeMarkerKey, to: statement, index: 1) + try bindText(ISO8601DateFormatter().string(from: Date()), to: statement, index: 2) + } + } + + private func deleteAllRowsWithoutCrrReplication(tableName: String) throws { + guard hasTable(named: tableName) else { return } + let clockTableName = "\(tableName)__crsql_clock" + let pksTableName = "\(tableName)__crsql_pks" + let hasCrrTriggers = queryInt64( + "select 1 from sqlite_master where type = 'trigger' and name in (?, ?, ?) limit 1", + bind: { [self] statement in + try self.bindText(insertTriggerName(for: tableName), to: statement, index: 1) + try self.bindText(updateTriggerName(for: tableName), to: statement, index: 2) + try self.bindText(deleteTriggerName(for: tableName), to: statement, index: 3) + } + ) != nil + let hasMasterRows = hasTable(named: "crsql_master") && queryInt64( + "select 1 from crsql_master where tbl_name = ? limit 1", + bind: { [self] statement in + try self.bindText(tableName, to: statement, index: 1) + } + ) != nil + let hasChangesRows = hasTable(named: "crsql_changes") && queryInt64( + "select 1 from crsql_changes where [table] = ? limit 1", + bind: { [self] statement in + try self.bindText(tableName, to: statement, index: 1) + } + ) != nil + let hasCrrMetadata = ( + hasTable(named: clockTableName) + || hasTable(named: pksTableName) + || hasCrrTriggers + || hasMasterRows + || hasChangesRows + ) + + if hasCrrMetadata { + try dropCrrTriggers(for: tableName) + try exec("drop table if exists \(quoteIdentifier(clockTableName))") + try exec("drop table if exists \(quoteIdentifier(pksTableName))") + if hasMasterRows { + _ = try execute("delete from crsql_master where tbl_name = ?") { statement in + try bindText(tableName, to: statement, index: 1) + } + } + if hasChangesRows { + _ = try execute("delete from crsql_changes where [table] = ?") { statement in + try bindText(tableName, to: statement, index: 1) + } + } + } + + try exec("delete from \(quoteIdentifier(tableName))") + } + /// Tables that exist on the iOS client only as local read-through caches. /// They are populated from sync responses, never edited by the user, and /// the host does NOT register them as CRR — so exporting CRDT changes for @@ -3797,6 +3874,22 @@ final class DatabaseService { return pk } + private func isLocalOnlyQueueWipeMarkerChange(_ change: CrsqlChangeRow) -> Bool { + change.table == "kv" && primaryKey(change.pk, matchesText: queueOverhaulWipeMarkerKey) + } + + private func primaryKey(_ value: SyncScalarValue, matchesText text: String) -> Bool { + if case .string(let stringValue) = value { + return stringValue == text + } + guard case .bytes(let bytesValue) = value, + case .bytes(let expectedBytes) = packedCrsqlPrimaryKey(.string(text)) + else { + return false + } + return bytesValue.base64 == expectedBytes.base64 + } + private func packedCrsqlPrimaryKey(_ value: SyncScalarValue) -> SyncScalarValue? { var bytes = Data([0x01]) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index d7637f987..74c64aa06 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -2942,6 +2942,47 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseTreatsQueueWipeMarkerAsLocalOnlySyncState() throws { + let baseURL = makeTemporaryDirectory() + let database = DatabaseService(baseURL: baseURL, bootstrapSQL: """ + create table if not exists kv (key text primary key, value text not null); + """) + XCTAssertNil(database.initializationError) + + let marker = "queue_landing_state.wiped_for_stacked_overhaul.v1" + let initialVersion = database.currentDbVersion() + try database.executeSqlForTesting(""" + update kv set value = 'locally-updated' where key = '\(marker)' + """) + + let exported = database.exportChangesSince(version: initialVersion) + XCTAssertFalse(exported.contains { + $0.table == "kv" && ($0.pk == packedDesktopTextPrimaryKey(marker) || $0.pk == .string(marker)) + }) + + let markerValueBeforeApply = try kvValue(in: baseURL, key: marker) + let versionBeforeApply = database.currentDbVersion() + let result = try database.applyChanges([ + CrsqlChangeRow( + table: "kv", + pk: packedDesktopTextPrimaryKey(marker), + cid: "value", + val: .string("remote-marker-should-not-apply"), + colVersion: 999, + dbVersion: versionBeforeApply + 1, + siteId: "ffffffffffffffffffffffffffffffff", + cl: 1, + seq: 0 + ) + ]) + + XCTAssertEqual(result.appliedCount, 0) + XCTAssertTrue(result.touchedTables.isEmpty) + XCTAssertEqual(database.currentDbVersion(), versionBeforeApply) + XCTAssertEqual(try kvValue(in: baseURL, key: marker), markerValueBeforeApply) + database.close() + } + func testDatabaseRejectsUnknownIncomingSyncTable() throws { let database = makeDatabase(baseURL: makeTemporaryDirectory()) XCTAssertNil(database.initializationError) @@ -8883,6 +8924,21 @@ final class ADETests: XCTestCase { return Int(sqlite3_column_int64(statement, 0)) } + private func kvValue(in baseURL: URL, key: String) throws -> String? { + let dbURL = baseURL.appendingPathComponent("ADE", isDirectory: true).appendingPathComponent("ade.db") + var handle: OpaquePointer? + XCTAssertEqual(sqlite3_open(dbURL.path, &handle), SQLITE_OK) + defer { sqlite3_close(handle) } + + var statement: OpaquePointer? + XCTAssertEqual(sqlite3_prepare_v2(handle, "select value from kv where key = ? limit 1", -1, &statement, nil), SQLITE_OK) + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, (key as NSString).utf8String, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + guard let raw = sqlite3_column_text(statement, 0) else { return nil } + return String(cString: raw) + } + private func tableExists(in baseURL: URL, table: String) throws -> Bool { let dbURL = baseURL.appendingPathComponent("ADE", isDirectory: true).appendingPathComponent("ade.db") var handle: OpaquePointer? diff --git a/docs/features/files-and-editor/file-watcher-and-trust.md b/docs/features/files-and-editor/file-watcher-and-trust.md index 4d642bcf8..e57342e28 100644 --- a/docs/features/files-and-editor/file-watcher-and-trust.md +++ b/docs/features/files-and-editor/file-watcher-and-trust.md @@ -213,6 +213,12 @@ Rename detection on the renderer side: because watcher events come as renderer inspects the modified timestamp and file size to correlate them when possible. +The Files page also keeps a per-project/lane in-memory session cache +for open tabs when the user switches scopes. Clean text tabs are capped +at 256 KB in that cache, but dirty tabs are retained regardless of +size so unsaved edits are never silently discarded while moving between +lanes. + ## IPC surface The primary route is the runtime daemon's `file` action domain. diff --git a/docs/features/sync-and-multi-device/crdt-model.md b/docs/features/sync-and-multi-device/crdt-model.md index 2e83a5d66..a3eb6c74f 100644 --- a/docs/features/sync-and-multi-device/crdt-model.md +++ b/docs/features/sync-and-multi-device/crdt-model.md @@ -150,6 +150,11 @@ changesets are byte-for-byte wire compatible. A row originating on an iPhone is indistinguishable from a row originating on a Mac (beyond the `site_id`), and round-trips through the host without translation. +Both desktop and iOS filter the +`queue_landing_state.wiped_for_stacked_overhaul.v1` kv marker from +CRDT import/export. The marker records local upgrade work for the +stacked-PR queue overhaul and must not replicate as shared state. + ### Legacy iOS cache DB On first launch the iOS app detects and replaces the legacy