Skip to content

Commit b8ec8aa

Browse files
authored
Fix synced queue wipe replication and preserve dirty file session buffers (#372)
1 parent e6d9b87 commit b8ec8aa

9 files changed

Lines changed: 446 additions & 53 deletions

File tree

apps/desktop/src/main/services/state/kvDb.sync.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ function makeDbPath(prefix: string): string {
2222
return path.join(root, ".ade", "kv.sqlite");
2323
}
2424

25+
function packedTextPrimaryKey(text: string): { type: "bytes"; base64: string } {
26+
const textBytes = Buffer.from(text, "utf8");
27+
return {
28+
type: "bytes",
29+
base64: Buffer.concat([Buffer.from([0x01, 0x0b, textBytes.length]), textBytes]).toString("base64"),
30+
};
31+
}
32+
33+
function syncPrimaryKeyMatchesText(value: unknown, text: string): boolean {
34+
if (value === text) return true;
35+
return Boolean(
36+
value
37+
&& typeof value === "object"
38+
&& "type" in value
39+
&& (value as { type?: unknown }).type === "bytes"
40+
&& "base64" in value
41+
&& (value as { base64?: unknown }).base64 === packedTextPrimaryKey(text).base64,
42+
);
43+
}
44+
2545
describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => {
2646
it("persists a stable local site id and marks CRR tables", async () => {
2747
const dbPath = makeDbPath("ade-kvdb-sync-site-");
@@ -178,6 +198,84 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => {
178198
repaired.close();
179199
});
180200

201+
it("does not replicate queue_landing_state overhaul wipe deletes to synced peers", async () => {
202+
const dbPathA = makeDbPath("ade-kvdb-sync-queue-wipe-a-");
203+
const dbA = await openKvDb(dbPathA, createLogger() as any);
204+
const wipeMarker = "queue_landing_state.wiped_for_stacked_overhaul.v1";
205+
const projectId = "project-queue-wipe";
206+
const groupId = "group-queue-wipe";
207+
const queueId = "queue-wipe-1";
208+
209+
dbA.run(
210+
`insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at)
211+
values (?, ?, ?, ?, ?, ?)`,
212+
[projectId, "/repo/queue-wipe", "Queue Wipe", "main", "2026-03-15T00:00:00.000Z", "2026-03-15T00:00:00.000Z"],
213+
);
214+
dbA.run(
215+
`insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at)
216+
values (?, ?, ?, ?, 0, 0, ?, ?)`,
217+
[groupId, projectId, "stack", "Stack", "main", "2026-03-15T00:00:00.000Z"],
218+
);
219+
dbA.run(
220+
`insert into queue_landing_state(
221+
id, group_id, project_id, state, entries_json, config_json, current_position, started_at
222+
) values (?, ?, ?, ?, ?, ?, 0, ?)`,
223+
[queueId, groupId, projectId, "active", "[]", "{}", "2026-03-15T00:00:00.000Z"],
224+
);
225+
226+
const dbB = await openKvDb(makeDbPath("ade-kvdb-sync-queue-wipe-b-"), createLogger() as any);
227+
const baselineChanges = dbA.sync.exportChangesSince(0);
228+
expect(baselineChanges.some((change) => change.table === "queue_landing_state")).toBe(true);
229+
dbB.sync.applyChanges(baselineChanges);
230+
expect(
231+
dbB.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId])?.id,
232+
).toBe(queueId);
233+
234+
const versionBeforeWipe = dbA.sync.getDbVersion();
235+
dbA.run("delete from kv where key = ?", [wipeMarker]);
236+
dbA.close();
237+
238+
const dbAReopened = await openKvDb(dbPathA, createLogger() as any);
239+
expect(
240+
dbAReopened.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId]),
241+
).toBeNull();
242+
243+
const wipeChanges = dbAReopened.sync.exportChangesSince(versionBeforeWipe);
244+
expect(wipeChanges.some((change) => change.table === "queue_landing_state")).toBe(false);
245+
expect(wipeChanges.some((change) => change.table === "kv" && syncPrimaryKeyMatchesText(change.pk, wipeMarker))).toBe(false);
246+
247+
dbB.sync.applyChanges(wipeChanges);
248+
expect(
249+
dbB.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId])?.id,
250+
).toBe(queueId);
251+
252+
const markerValueBeforeApply = dbB.get<{ value: string }>(
253+
"select value from kv where key = ?",
254+
[wipeMarker],
255+
)?.value ?? null;
256+
const versionBeforeMarkerApply = dbB.sync.getDbVersion();
257+
const markerApplyResult = dbB.sync.applyChanges([{
258+
table: "kv",
259+
pk: packedTextPrimaryKey(wipeMarker),
260+
cid: "value",
261+
val: "remote-marker-should-not-apply",
262+
col_version: 999,
263+
db_version: versionBeforeMarkerApply + 1,
264+
site_id: "f".repeat(32),
265+
cl: 1,
266+
seq: 1,
267+
}]);
268+
expect(markerApplyResult.appliedCount).toBe(0);
269+
expect(markerApplyResult.touchedTables).toEqual([]);
270+
expect(dbB.sync.getDbVersion()).toBe(versionBeforeMarkerApply);
271+
expect(
272+
dbB.get<{ value: string }>("select value from kv where key = ?", [wipeMarker])?.value ?? null,
273+
).toBe(markerValueBeforeApply);
274+
275+
dbAReopened.close();
276+
dbB.close();
277+
});
278+
181279
it("ignores CRDT changes for legacy unified_memories tables removed in #329", async () => {
182280
const db2 = await openKvDb(makeDbPath("ade-kvdb-sync-mem-skip-"), createLogger() as any);
183281
const legacyChange = {

apps/desktop/src/main/services/state/kvDb.ts

Lines changed: 105 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,95 @@ function dropCrrTriggers(db: DatabaseSyncType, tableName: string, logger?: Logge
521521
return triggers.length;
522522
}
523523

524+
/** Strip CRR triggers/metadata so row deletes stay local (no replicated tombstones). */
525+
function deleteAllRowsWithoutCrrReplication(
526+
db: DatabaseSyncType,
527+
tableName: string,
528+
logger?: Logger,
529+
): void {
530+
if (!rawHasTable(db, tableName)) return;
531+
532+
const clockTableName = `${tableName}__crsql_clock`;
533+
const pksTableName = `${tableName}__crsql_pks`;
534+
if (rawHasTable(db, clockTableName) || rawHasTable(db, pksTableName) || listCrrTriggers(db, tableName).length > 0) {
535+
if (rawHasTable(db, "crsql_master") && rawHasColumn(db, "crsql_master", "tbl_name")) {
536+
runStatement(db, "delete from crsql_master where tbl_name = ?", [tableName]);
537+
}
538+
if (rawHasTable(db, "crsql_changes") && rawHasColumn(db, "crsql_changes", "table")) {
539+
runStatement(db, "delete from crsql_changes where [table] = ?", [tableName]);
540+
}
541+
try {
542+
getRow(db, "select crsql_as_table(?) as ok", [tableName]);
543+
} catch {
544+
// Table may not be registered enough for crsql_as_table; shadow cleanup below still applies.
545+
}
546+
dropCrrTriggers(db, tableName, logger);
547+
runStatement(db, `drop table if exists ${quoteIdentifier(clockTableName)}`);
548+
runStatement(db, `drop table if exists ${quoteIdentifier(pksTableName)}`);
549+
}
550+
551+
runStatement(db, `delete from ${quoteIdentifier(tableName)}`);
552+
}
553+
554+
const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1";
555+
556+
function rawCrsqlPrimaryKeyMatchesText(value: unknown, text: string): boolean {
557+
if (value === text) return true;
558+
if (!(value instanceof Uint8Array)) return false;
559+
560+
const packed = packedCrsqlPrimaryKey(text);
561+
return isSyncScalarBytes(packed)
562+
&& Buffer.from(value).equals(Buffer.from(packed.base64, "base64"));
563+
}
564+
565+
function syncScalarPrimaryKeyMatchesText(value: SyncScalar, text: string): boolean {
566+
if (value === text) return true;
567+
568+
const packed = packedCrsqlPrimaryKey(text);
569+
return isSyncScalarBytes(value)
570+
&& isSyncScalarBytes(packed)
571+
&& value.base64 === packed.base64;
572+
}
573+
574+
function isLocalOnlyQueueWipeMarkerRawChange(change: { table_name: string; pk: unknown }): boolean {
575+
return change.table_name === "kv"
576+
&& rawCrsqlPrimaryKeyMatchesText(change.pk, QUEUE_OVERHAUL_WIPE_MARKER);
577+
}
578+
579+
function isLocalOnlyQueueWipeMarkerChange(change: CrsqlChangeRow): boolean {
580+
return change.table === "kv"
581+
&& syncScalarPrimaryKeyMatchesText(change.pk, QUEUE_OVERHAUL_WIPE_MARKER);
582+
}
583+
584+
/**
585+
* One-shot local wipe of legacy queue_landing_state on upgrade to the stacked-PR
586+
* queue overhaul. Must run after migrations and before ensureCrrTables so queue
587+
* deletes do not replicate to peers that have not upgraded yet. The marker is
588+
* stored in kv for compatibility with the original migration, but filtered from
589+
* CRDT import/export because it records local upgrade work, not shared state.
590+
*/
591+
function wipeQueueLandingStateForStackedOverhaulIfNeeded(db: DatabaseSyncType, logger?: Logger): void {
592+
try {
593+
const row = getRow<{ value: string }>(
594+
db,
595+
"select value from kv where key = ?",
596+
[QUEUE_OVERHAUL_WIPE_MARKER],
597+
);
598+
if (row) return;
599+
600+
deleteAllRowsWithoutCrrReplication(db, "queue_landing_state", logger);
601+
runStatement(
602+
db,
603+
"insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value",
604+
[QUEUE_OVERHAUL_WIPE_MARKER, new Date().toISOString()],
605+
);
606+
} catch {
607+
// Table may not exist on a brand-new DB; initialization will create both
608+
// tables and the next startup will record the marker. Skipping the wipe
609+
// on a fresh DB is correct (nothing to wipe).
610+
}
611+
}
612+
524613
function removeExcludedCrrMetadata(db: DatabaseSyncType, logger?: Logger): void {
525614
for (const tableName of LOCAL_ONLY_CRR_EXCLUDED_TABLES) {
526615
const clockTableName = `${tableName}__crsql_clock`;
@@ -1821,32 +1910,6 @@ function migrate(db: MigrationDb) {
18211910
try { db.run("alter table queue_landing_state add column wait_reason text"); } catch {}
18221911
try { db.run("alter table queue_landing_state add column updated_at text"); } catch {}
18231912

1824-
// One-shot wipe of legacy queue_landing_state on upgrade to the stacked-PR
1825-
// queue overhaul. The new queue creates PRs with chain bases (PR_N's base =
1826-
// previous lane's branch) instead of all-into-main, so any in-flight queue
1827-
// from the old code path would be misinterpreted by the new landing loop.
1828-
// Wiping rather than migrating is a deliberate choice — the user accepts
1829-
// losing in-flight queues in exchange for not maintaining a translation
1830-
// layer for every legacy field shape.
1831-
const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1";
1832-
try {
1833-
const row = db.get<{ value: string }>(
1834-
"select value from kv where key = ?",
1835-
[QUEUE_OVERHAUL_WIPE_MARKER],
1836-
);
1837-
if (!row) {
1838-
db.run("delete from queue_landing_state");
1839-
db.run(
1840-
"insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value",
1841-
[QUEUE_OVERHAUL_WIPE_MARKER, new Date().toISOString()],
1842-
);
1843-
}
1844-
} catch {
1845-
// Table may not exist on a brand-new DB; initialization will create both
1846-
// tables and the next startup will record the marker. Skipping the wipe
1847-
// on a fresh DB is correct (nothing to wipe).
1848-
}
1849-
18501913
// Rebase dismiss/defer persistence
18511914
db.run(`
18521915
create table if not exists rebase_dismissed (
@@ -2849,6 +2912,8 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> {
28492912
removeExcludedCrrMetadata(db, logger);
28502913
}
28512914

2915+
wipeQueueLandingStateForStackedOverhaulIfNeeded(db, logger);
2916+
28522917
if (crsqliteLoaded) {
28532918
loadCrsqliteIfAvailable();
28542919
ensureCrrTables(db, logger);
@@ -2951,17 +3016,19 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> {
29513016
[version]
29523017
);
29533018

2954-
return rows.map((row) => ({
2955-
table: row.table_name,
2956-
pk: encodeSyncScalar(row.pk),
2957-
cid: row.cid,
2958-
val: encodeSyncScalar(row.val),
2959-
col_version: Number(row.col_version),
2960-
db_version: Number(row.db_version),
2961-
site_id: Buffer.from(row.site_id).toString("hex"),
2962-
cl: Number(row.cl),
2963-
seq: Number(row.seq),
2964-
}));
3019+
return rows
3020+
.filter((row) => !isLocalOnlyQueueWipeMarkerRawChange(row))
3021+
.map((row) => ({
3022+
table: row.table_name,
3023+
pk: encodeSyncScalar(row.pk),
3024+
cid: row.cid,
3025+
val: encodeSyncScalar(row.val),
3026+
col_version: Number(row.col_version),
3027+
db_version: Number(row.db_version),
3028+
site_id: Buffer.from(row.site_id).toString("hex"),
3029+
cl: Number(row.cl),
3030+
seq: Number(row.seq),
3031+
}));
29653032
},
29663033
applyChanges: (changes: CrsqlChangeRow[]) => {
29673034
if (!crsqliteLoaded) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false };
@@ -2970,6 +3037,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> {
29703037
runStatement(db, "begin");
29713038
try {
29723039
for (const rawChange of changes) {
3040+
if (isLocalOnlyQueueWipeMarkerChange(rawChange)) continue;
29733041
// Skip changes for tables that no longer exist in the schema
29743042
// (e.g. unified_memories removed in #329).
29753043
if (!rawHasTable(db, rawChange.table)) continue;

apps/desktop/src/renderer/components/files/FilesPage.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,74 @@ describe("FilesPage", () => {
836836
}
837837
});
838838

839+
it("preserves oversized dirty tabs when switching lane scopes", async () => {
840+
const laneA = "lane-large-a";
841+
const laneB = "lane-large-b";
842+
useAppStore.setState({
843+
selectedLaneId: laneA,
844+
lanes: [
845+
{ id: laneA, name: "Large A", branchRef: "refs/heads/large-a" },
846+
{ id: laneB, name: "Large B", branchRef: "refs/heads/large-b" },
847+
] as any,
848+
});
849+
vi.mocked(window.ade.files.listWorkspaces).mockResolvedValue([
850+
{
851+
id: "primary",
852+
kind: "primary",
853+
laneId: null,
854+
name: "ADE",
855+
branchRef: "refs/heads/main",
856+
rootPath: projectRoot,
857+
isReadOnlyByDefault: false,
858+
},
859+
{
860+
id: "lane-large-a-ws",
861+
kind: "worktree",
862+
laneId: laneA,
863+
name: "Large A",
864+
branchRef: "refs/heads/large-a",
865+
rootPath: `${projectRoot}/.ade/worktrees/large-a`,
866+
isReadOnlyByDefault: false,
867+
},
868+
{
869+
id: "lane-large-b-ws",
870+
kind: "worktree",
871+
laneId: laneB,
872+
name: "Large B",
873+
branchRef: "refs/heads/large-b",
874+
rootPath: `${projectRoot}/.ade/worktrees/large-b`,
875+
isReadOnlyByDefault: false,
876+
},
877+
]);
878+
879+
renderFilesPage({
880+
openFilePath: "src/index.ts",
881+
});
882+
883+
await waitForEditorText("value = 1");
884+
885+
const oversizedDirtyContent = `dirty-start\n${"x".repeat(8 * 1024 * 1024 + 1)}\ndirty-end`;
886+
act(() => {
887+
latestMockEditor?.setValue(oversizedDirtyContent);
888+
});
889+
expect(latestMockEditor?.getValue().length).toBe(oversizedDirtyContent.length);
890+
891+
act(() => {
892+
useAppStore.setState({ selectedLaneId: laneB });
893+
});
894+
await waitFor(() => {
895+
expect(screen.getByText(/OPEN A FILE TO START EDITING/i)).toBeTruthy();
896+
});
897+
898+
act(() => {
899+
useAppStore.setState({ selectedLaneId: laneA });
900+
});
901+
await waitFor(() => {
902+
expect(latestMockEditor?.getValue().length).toBe(oversizedDirtyContent.length);
903+
expect(latestMockEditor?.getValue().endsWith("dirty-end")).toBe(true);
904+
});
905+
});
906+
839907
it("treats Windows workspace paths case-insensitively for open tabs and watcher events", async () => {
840908
projectRoot = "C:/Repo";
841909
resetStore();

apps/desktop/src/renderer/components/files/FilesPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ const filesRootTreeCacheByWorkspace = new Map<string, FileTreeNode[]>();
159159
const MAX_FILES_PAGE_CACHED_SCOPES = 8;
160160
const MAX_FILES_TREE_CACHED_WORKSPACES = 32;
161161
const MAX_CACHED_CLEAN_TAB_CHARS = 256 * 1024;
162-
const MAX_CACHED_DIRTY_TAB_CHARS = 8 * 1024 * 1024;
163162
const MAX_QUEUED_TREE_PARENT_REFRESHES = 24;
164163
const FILES_WATCH_START_DELAY_MS = import.meta.env.MODE === "test" || (window as any).__adeBrowserMock ? 0 : 2_000;
165164

@@ -249,8 +248,10 @@ function filesPageSessionHasUnsavedTabs(session: FilesPageSessionState | undefin
249248
function cacheableSessionTabs(openTabs: OpenTab[]): OpenTab[] {
250249
return openTabs
251250
.filter((tab) => {
251+
// Always retain unsaved buffers across session scope changes; size limits
252+
// apply only to clean tabs so large in-flight edits are not dropped silently.
252253
if (tab.content !== tab.savedContent) {
253-
return tab.content.length <= MAX_CACHED_DIRTY_TAB_CHARS;
254+
return true;
254255
}
255256
return isTextTab(tab) && tab.content.length <= MAX_CACHED_CLEAN_TAB_CHARS;
256257
})

apps/ios/ADE/Resources/DatabaseBootstrap.sql

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -817,8 +817,6 @@ alter table queue_landing_state add column wait_reason text;
817817

818818
alter table queue_landing_state add column updated_at text;
819819

820-
delete from queue_landing_state;
821-
822820
create table if not exists rebase_dismissed (
823821
lane_id text not null,
824822
project_id text not null,

0 commit comments

Comments
 (0)