Skip to content

Commit 5216898

Browse files
zknprclaude
andcommitted
fix(web): harden web edit against data loss — 3 P1 fixes (#424 review)
Codex review found 3 confirmed data-integrity bugs that made web editing unsafe. All fixed: P1-A — WAL pages dropped on save. sql.js opens only the main DB image and cannot merge a separate -wal, so saving a WAL-mode DB would overwrite the main file and drop committed WAL pages. Now: when walContent is non-empty, the browser connection opens read-only (viewable, not silently corruptible). P1-B — undo/redo/revert/hot-exit were no-ops. The in-process facade wired applyModifications/undoModification/redoModification/flushChanges/ discardModifications as async()=>{}, so VS Code Undo/Revert/backup-restore marked document state handled while the in-memory DB was unchanged (zombie/lost edits). createWorkerEndpoint() now exposes these ops, and the facade delegates to the already-implemented WasmDatabaseEngine methods. P1-C — checkpoint raced an in-flight save. createCheckpoint() ran after the async writeFile, marking clean any edit that arrived during the write (dropped on reload). ModificationTracker gains getCurrentPosition()/createCheckpointAt(); save() snapshots the position right after serializeDatabase() and only commits that snapshot after a successful write. Position is absolute (timelineOffset) so it survives front-eviction during the write. Tests +3, each revert-proof-verified (fail when its fix is undone): WAL-read-only, undo-delegation, save-race-stays-dirty. Verified: build OK (extension-browser.js exports activate, worker-free); tsc --noEmit clean; npm test 341/341. Desktop path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a1cde57 commit 5216898

6 files changed

Lines changed: 278 additions & 9 deletions

File tree

src/core/sqlite-db.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import type {
2121
TableCountOptions,
2222
SchemaSnapshot,
2323
ColumnMetadata,
24-
ColumnDefinition
24+
ColumnDefinition,
25+
ModificationEntry
2526
} from './types';
2627
import { getNodeFs } from './platform/fs';
2728
import {
@@ -174,6 +175,28 @@ export function createWorkerEndpoint() {
174175
return requireEngine().serializeDatabase(name);
175176
},
176177

178+
// Expose undo/history operations for the browser in-process facade, which
179+
// calls this endpoint directly instead of going through worker RPC.
180+
async applyModifications(mods: ModificationEntry[], signal?: AbortSignal): Promise<void> {
181+
return requireEngine().applyModifications(mods, signal);
182+
},
183+
184+
async undoModification(mod: ModificationEntry): Promise<void> {
185+
return requireEngine().undoModification(mod);
186+
},
187+
188+
async redoModification(mod: ModificationEntry): Promise<void> {
189+
return requireEngine().redoModification(mod);
190+
},
191+
192+
async flushChanges(signal?: AbortSignal): Promise<void> {
193+
return requireEngine().flushChanges(signal);
194+
},
195+
196+
async discardModifications(mods: ModificationEntry[], signal?: AbortSignal): Promise<void> {
197+
return requireEngine().discardModifications(mods, signal);
198+
},
199+
177200
async updateCell(table: string, rowId: RecordId, column: string, value: CellValue): Promise<void> {
178201
return requireEngine().updateCell(table, rowId, column, value);
179202
},

src/core/undo-history.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ interface TrackerState<T> {
142142
export class ModificationTracker<T extends LabeledModification = LabeledModification> {
143143
private timeline: T[] = [];
144144
private timelineSizes: number[] = [];
145+
/** Number of entries that were evicted from the front of the retained timeline. */
146+
private timelineOffset: number = 0;
145147

146148
private futureStack: T[] = [];
147149
private futureStackSizes: number[] = [];
@@ -208,6 +210,7 @@ export class ModificationTracker<T extends LabeledModification = LabeledModifica
208210

209211
// Adjust checkpoint index since we shifted the array
210212
this.checkpointIndex = Math.max(0, this.checkpointIndex - 1);
213+
this.timelineOffset++;
211214
}
212215
}
213216

@@ -261,6 +264,27 @@ export class ModificationTracker<T extends LabeledModification = LabeledModifica
261264
this.checkpointIndex = this.timeline.length;
262265
}
263266

267+
/**
268+
* Return the absolute timeline position after the latest retained entry.
269+
*
270+
* This position is stable across later front-eviction because it includes the
271+
* count of entries already removed from the retained timeline.
272+
*/
273+
getCurrentPosition(): number {
274+
return this.timelineOffset + this.timeline.length;
275+
}
276+
277+
/**
278+
* Mark a previously captured absolute timeline position as the saved state.
279+
*
280+
* The position is translated back into the retained timeline, clamped when
281+
* old entries were evicted or when callers provide a future position.
282+
*/
283+
async createCheckpointAt(position: number): Promise<void> {
284+
const relativePosition = position - this.timelineOffset;
285+
this.checkpointIndex = Math.max(0, Math.min(this.timeline.length, relativePosition));
286+
}
287+
264288
/**
265289
* Get all modifications since last checkpoint.
266290
*

src/databaseModel.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ export class DatabaseDocument extends Disposable implements vsc.CustomDocument {
357357

358358
const { filename } = this.fileParts;
359359
const binaryContent = await this.databaseOperations.serializeDatabase(filename);
360+
// Capture the tracker position immediately after serialization. The bytes
361+
// below represent edits up to this position only; edits recorded while the
362+
// asynchronous workspace write is pending must remain dirty.
363+
const serializedCheckpoint = this.#modificationTracker.getCurrentPosition();
360364
try {
361365
await vsc.workspace.fs.writeFile(this.uri, binaryContent);
362366
} catch (err) {
@@ -365,7 +369,9 @@ export class DatabaseDocument extends Disposable implements vsc.CustomDocument {
365369
}
366370
// Only mark the tracker clean after bytes are persisted. If a web filesystem
367371
// rejects writeFile, the edit history remains uncommitted for backup/retry.
368-
await this.#modificationTracker.createCheckpoint();
372+
// The saved checkpoint is limited to the serialized snapshot so concurrent
373+
// edits are not acknowledged before their bytes reach storage.
374+
await this.#modificationTracker.createCheckpointAt(serializedCheckpoint);
369375
}
370376

371377
/**

src/workerFactory.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ async function createInProcessWasmDatabaseConnection(
214214
// There is no file-path fast path because the web extension host cannot
215215
// access local disk paths directly.
216216
const [dbContent, walContent] = await loadDatabaseFiles(fileUri);
217+
const hasActiveWal = !!walContent && walContent.byteLength > 0;
218+
// sql.js opens one main database image and cannot merge a separate WAL
219+
// file, so browser editing is disabled when committed WAL pages may be
220+
// absent from the main database bytes that save() would later overwrite.
221+
const readOnlyMode = (forceReadOnly ?? false) || hasActiveWal;
217222

218223
// Preload sql.js WASM bytes from the extension assets directory so
219224
// WebAssembly instantiation does not depend on worker-relative URLs.
@@ -226,7 +231,7 @@ async function createInProcessWasmDatabaseConnection(
226231
maxSize: getMaximumFileSizeBytes(),
227232
resourceMap: {},
228233
wasmBinary: wasmContent,
229-
readOnlyMode: forceReadOnly ?? false,
234+
readOnlyMode,
230235
queryTimeout: getQueryTimeout()
231236
};
232237

@@ -237,11 +242,16 @@ async function createInProcessWasmDatabaseConnection(
237242
executeQuery: (sql: string, params?: CellValue[]) =>
238243
endpoint.runQuery(sql, params),
239244
serializeDatabase: (name: string) => endpoint.exportDatabase(name),
240-
applyModifications: async () => {},
241-
undoModification: async () => {},
242-
redoModification: async () => {},
243-
flushChanges: async () => {},
244-
discardModifications: async () => {},
245+
applyModifications: (mods: ModificationEntry[], signal?: AbortSignal) =>
246+
endpoint.applyModifications(mods, signal),
247+
undoModification: (mod: ModificationEntry) =>
248+
endpoint.undoModification(mod),
249+
redoModification: (mod: ModificationEntry) =>
250+
endpoint.redoModification(mod),
251+
flushChanges: (signal?: AbortSignal) =>
252+
endpoint.flushChanges(signal),
253+
discardModifications: (mods: ModificationEntry[], signal?: AbortSignal) =>
254+
endpoint.discardModifications(mods, signal),
245255
updateCell: (table: string, rowId: string | number, column: string, value: CellValue) =>
246256
endpoint.updateCell(table, rowId, column, value),
247257
insertRow: (table: string, data: Record<string, CellValue>) =>

tests/unit/databaseModel.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,78 @@ describe('DatabaseDocument save/saveAs fallback', () => {
491491
mockVscode.workspace.fs = originalFs;
492492
}
493493
});
494+
495+
it('save: does not checkpoint edits recorded while writeFile is still pending', async () => {
496+
const sourceUri = createUri('vscode-vfs', '/github/user/repo/test.db');
497+
let resolveWrite: () => void = () => {};
498+
let markWriteStarted: () => void = () => {};
499+
const writeMayFinish = new Promise<void>(resolve => {
500+
resolveWrite = resolve;
501+
});
502+
const writeStarted = new Promise<void>(resolve => {
503+
markWriteStarted = resolve;
504+
});
505+
506+
const firstModification = {
507+
label: 'First Update',
508+
description: 'Update first item',
509+
modificationType: 'cell_update' as const,
510+
targetTable: 'items',
511+
targetRowId: 1,
512+
targetColumn: 'name',
513+
priorValue: 'before',
514+
newValue: 'after'
515+
};
516+
const concurrentModification = {
517+
label: 'Concurrent Update',
518+
description: 'Update concurrent item',
519+
modificationType: 'cell_update' as const,
520+
targetTable: 'items',
521+
targetRowId: 2,
522+
targetColumn: 'name',
523+
priorValue: 'old',
524+
newValue: 'new'
525+
};
526+
let discardedModifications: unknown[] | undefined;
527+
528+
const dbOps = {
529+
engineKind: Promise.resolve('wasm'),
530+
serializeDatabase: async () => new Uint8Array([7, 8, 9]),
531+
discardModifications: async (mods: unknown[]) => {
532+
discardedModifications = mods;
533+
}
534+
};
535+
536+
const doc = createDocBypassingFactory(dbOps, sourceUri);
537+
doc.recordModification(firstModification);
538+
539+
const originalFs = mockVscode.workspace.fs;
540+
mockVscode.workspace.fs = {
541+
...originalFs,
542+
writeFile: async (uri: any, content: any) => {
543+
assert.strictEqual(uri, sourceUri);
544+
assert.deepStrictEqual(content, new Uint8Array([7, 8, 9]));
545+
markWriteStarted();
546+
await writeMayFinish;
547+
},
548+
readFile: async () => new Uint8Array([])
549+
} as any;
550+
551+
try {
552+
const savePromise = doc.save();
553+
await writeStarted;
554+
555+
doc.recordModification(concurrentModification);
556+
resolveWrite();
557+
await savePromise;
558+
559+
await doc.revert(undefined);
560+
561+
assert.deepStrictEqual(discardedModifications, [concurrentModification]);
562+
} finally {
563+
mockVscode.workspace.fs = originalFs;
564+
}
565+
});
494566
});
495567

496568

tests/unit/workerFactory_browser.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import fs from 'node:fs';
77
import Module from 'node:module';
88
import esbuild from 'esbuild';
99
import { mockVscode } from './mocks/vscode';
10-
import type { CellUpdate, CellValue, DatabaseInitConfig } from '../../src/core/types';
10+
import type { CellUpdate, CellValue, DatabaseInitConfig, ModificationEntry } from '../../src/core/types';
1111

1212
const workerFactoryPath = path.resolve(__dirname, '../../src/workerFactory.ts');
1313
const workerFactorySource = fs.readFileSync(workerFactoryPath, 'utf8');
@@ -16,6 +16,11 @@ interface FakeEndpoint {
1616
initializeDatabase(filename: string, config: DatabaseInitConfig): Promise<{ isReadOnly: boolean }>;
1717
runQuery(sql: string, params?: CellValue[]): Promise<unknown[]>;
1818
exportDatabase(name: string): Promise<Uint8Array>;
19+
applyModifications?(mods: ModificationEntry[], signal?: AbortSignal): Promise<void>;
20+
undoModification?(mod: ModificationEntry): Promise<void>;
21+
redoModification?(mod: ModificationEntry): Promise<void>;
22+
flushChanges?(signal?: AbortSignal): Promise<void>;
23+
discardModifications?(mods: ModificationEntry[], signal?: AbortSignal): Promise<void>;
1924
updateCell(table: string, rowId: string | number, column: string, value: CellValue): Promise<void>;
2025
insertRow(table: string, data: Record<string, CellValue>): Promise<string | number | undefined>;
2126
updateCellBatch(table: string, updates: CellUpdate[]): Promise<void>;
@@ -167,4 +172,133 @@ describe('workerFactory browser WASM connection', () => {
167172
assert.strictEqual(insertRowValue, blobValue);
168173
assert.strictEqual(updateBatchValue, blobValue);
169174
});
175+
176+
it('opens browser WAL databases read-only instead of silently writing without WAL pages', async () => {
177+
const dbContent = new Uint8Array([1, 2, 3]);
178+
const walContent = new Uint8Array([9, 9, 9]);
179+
const wasmContent = new Uint8Array([4, 5, 6]);
180+
let initConfig: DatabaseInitConfig | undefined;
181+
182+
Object.defineProperty(mockVscode.workspace, 'fs', {
183+
value: {
184+
stat: async () => ({ size: dbContent.byteLength }),
185+
readFile: async (uri: { path?: string; fsPath?: string }) => {
186+
const pathValue = uri.path ?? uri.fsPath ?? '';
187+
if (pathValue.endsWith('-wal')) {
188+
return walContent;
189+
}
190+
if (pathValue.endsWith('sqlite3.wasm')) {
191+
return wasmContent;
192+
}
193+
return dbContent;
194+
}
195+
},
196+
writable: true,
197+
configurable: true
198+
});
199+
200+
const endpoint: FakeEndpoint = {
201+
initializeDatabase: async (_filename, config) => {
202+
initConfig = config;
203+
return { isReadOnly: config.readOnlyMode ?? false };
204+
},
205+
runQuery: async () => [],
206+
exportDatabase: async () => new Uint8Array(),
207+
updateCell: async () => {},
208+
insertRow: async () => 1,
209+
updateCellBatch: async () => {},
210+
ping: async () => true
211+
};
212+
213+
const workerFactory = loadBrowserWorkerFactory(endpoint);
214+
const extensionUri = { scheme: 'vscode-vfs', fsPath: '/ext', path: '/ext' } as any;
215+
const fileUri = {
216+
scheme: 'vscode-vfs',
217+
fsPath: '/workspace/test.db',
218+
path: '/workspace/test.db',
219+
with: ({ path: nextPath }: { path: string }) => ({
220+
scheme: 'vscode-vfs',
221+
fsPath: nextPath,
222+
path: nextPath
223+
})
224+
} as any;
225+
226+
const bundle = await workerFactory.createDatabaseConnection(extensionUri, null as any);
227+
const connection = await bundle.establishConnection(fileUri, 'test.db');
228+
229+
assert.strictEqual(initConfig?.walContent, walContent);
230+
assert.strictEqual(initConfig?.readOnlyMode, true);
231+
assert.strictEqual(connection.isReadOnly, true);
232+
});
233+
234+
it('delegates in-process modification operations through the endpoint', async () => {
235+
const calls: string[] = [];
236+
const mod = {
237+
label: 'Update',
238+
description: 'Update item',
239+
modificationType: 'cell_update' as const,
240+
targetTable: 'items',
241+
targetRowId: 1,
242+
targetColumn: 'name',
243+
priorValue: 'before',
244+
newValue: 'after'
245+
};
246+
const abortController = new AbortController();
247+
248+
const endpoint: FakeEndpoint = {
249+
initializeDatabase: async () => ({ isReadOnly: false }),
250+
runQuery: async () => [],
251+
exportDatabase: async () => new Uint8Array(),
252+
applyModifications: async (mods, signal) => {
253+
assert.deepStrictEqual(mods, [mod]);
254+
assert.strictEqual(signal, abortController.signal);
255+
calls.push('apply');
256+
},
257+
undoModification: async (entry) => {
258+
assert.strictEqual(entry, mod);
259+
calls.push('undo');
260+
},
261+
redoModification: async (entry) => {
262+
assert.strictEqual(entry, mod);
263+
calls.push('redo');
264+
},
265+
flushChanges: async (signal) => {
266+
assert.strictEqual(signal, abortController.signal);
267+
calls.push('flush');
268+
},
269+
discardModifications: async (mods, signal) => {
270+
assert.deepStrictEqual(mods, [mod]);
271+
assert.strictEqual(signal, abortController.signal);
272+
calls.push('discard');
273+
},
274+
updateCell: async () => {},
275+
insertRow: async () => 1,
276+
updateCellBatch: async () => {},
277+
ping: async () => true
278+
};
279+
280+
const workerFactory = loadBrowserWorkerFactory(endpoint);
281+
const extensionUri = { scheme: 'vscode-vfs', fsPath: '/ext', path: '/ext' } as any;
282+
const fileUri = {
283+
scheme: 'vscode-vfs',
284+
fsPath: '/workspace/test.db',
285+
path: '/workspace/test.db',
286+
with: ({ path: nextPath }: { path: string }) => ({
287+
scheme: 'vscode-vfs',
288+
fsPath: nextPath,
289+
path: nextPath
290+
})
291+
} as any;
292+
293+
const bundle = await workerFactory.createDatabaseConnection(extensionUri, null as any);
294+
const connection = await bundle.establishConnection(fileUri, 'test.db');
295+
296+
await connection.databaseOps.applyModifications([mod], abortController.signal);
297+
await connection.databaseOps.undoModification(mod);
298+
await connection.databaseOps.redoModification(mod);
299+
await connection.databaseOps.flushChanges(abortController.signal);
300+
await connection.databaseOps.discardModifications([mod], abortController.signal);
301+
302+
assert.deepStrictEqual(calls, ['apply', 'undo', 'redo', 'flush', 'discard']);
303+
});
170304
});

0 commit comments

Comments
 (0)