Skip to content

Commit 020bdfd

Browse files
committed
Harden local-only queue wipe sync
1 parent 8bd0a0a commit 020bdfd

8 files changed

Lines changed: 332 additions & 28 deletions

File tree

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

Lines changed: 46 additions & 1 deletion
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-");
@@ -181,6 +201,7 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => {
181201
it("does not replicate queue_landing_state overhaul wipe deletes to synced peers", async () => {
182202
const dbPathA = makeDbPath("ade-kvdb-sync-queue-wipe-a-");
183203
const dbA = await openKvDb(dbPathA, createLogger() as any);
204+
const wipeMarker = "queue_landing_state.wiped_for_stacked_overhaul.v1";
184205
const projectId = "project-queue-wipe";
185206
const groupId = "group-queue-wipe";
186207
const queueId = "queue-wipe-1";
@@ -211,7 +232,7 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => {
211232
).toBe(queueId);
212233

213234
const versionBeforeWipe = dbA.sync.getDbVersion();
214-
dbA.run("delete from kv where key = ?", ["queue_landing_state.wiped_for_stacked_overhaul.v1"]);
235+
dbA.run("delete from kv where key = ?", [wipeMarker]);
215236
dbA.close();
216237

217238
const dbAReopened = await openKvDb(dbPathA, createLogger() as any);
@@ -221,12 +242,36 @@ describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => {
221242

222243
const wipeChanges = dbAReopened.sync.exportChangesSince(versionBeforeWipe);
223244
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);
224246

225247
dbB.sync.applyChanges(wipeChanges);
226248
expect(
227249
dbB.get<{ id: string }>("select id from queue_landing_state where id = ?", [queueId])?.id,
228250
).toBe(queueId);
229251

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+
230275
dbAReopened.close();
231276
dbB.close();
232277
});

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

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -553,10 +553,40 @@ function deleteAllRowsWithoutCrrReplication(
553553

554554
const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1";
555555

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+
556584
/**
557585
* One-shot local wipe of legacy queue_landing_state on upgrade to the stacked-PR
558-
* queue overhaul. Must run after migrations and before ensureCrrTables so deletes
559-
* do not replicate to peers that have not upgraded yet.
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.
560590
*/
561591
function wipeQueueLandingStateForStackedOverhaulIfNeeded(db: DatabaseSyncType, logger?: Logger): void {
562592
try {
@@ -2986,17 +3016,19 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> {
29863016
[version]
29873017
);
29883018

2989-
return rows.map((row) => ({
2990-
table: row.table_name,
2991-
pk: encodeSyncScalar(row.pk),
2992-
cid: row.cid,
2993-
val: encodeSyncScalar(row.val),
2994-
col_version: Number(row.col_version),
2995-
db_version: Number(row.db_version),
2996-
site_id: Buffer.from(row.site_id).toString("hex"),
2997-
cl: Number(row.cl),
2998-
seq: Number(row.seq),
2999-
}));
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+
}));
30003032
},
30013033
applyChanges: (changes: CrsqlChangeRow[]) => {
30023034
if (!crsqliteLoaded) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false };
@@ -3005,6 +3037,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise<AdeDb> {
30053037
runStatement(db, "begin");
30063038
try {
30073039
for (const rawChange of changes) {
3040+
if (isLocalOnlyQueueWipeMarkerChange(rawChange)) continue;
30083041
// Skip changes for tables that no longer exist in the schema
30093042
// (e.g. unified_memories removed in #329).
30103043
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/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)