Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 1.5.0

### Features

- **Editing databases in VS Code for Web (vscode.dev / github.dev)**. The web build was previously read-only — any edit reported "Document is read-only". Editing had been disabled since the original release because the web engine path was unreliable. The in-process WASM engine now works in the web extension host (since 1.3.9), so the read-write editor is enabled in web mode. Edits apply to the in-memory database, then persist on save: the database is serialized and written back through the VS Code filesystem (for example, committing via github.dev).

### Safety

- **No silent data loss on web save failure**. When the underlying filesystem rejects a write (for example, a read-only web filesystem provider), save now surfaces a clear "Failed to save database" error. The edit history is kept uncommitted so changes can be retried or recovered, rather than being marked as saved.
- **WAL databases open read-only in web mode**. When a database has an active write-ahead log (`-wal`), the web build opens it read-only instead of risking a save that writes back a main image missing committed WAL pages. Read-only state is enforced on the cell-editor write path too, not just on save.
- **JSON cell edits apply as merge patches**. Editing a JSON cell applies an RFC 7396 merge patch to the stored value (web and desktop), so a concurrent change to a different key is preserved instead of being overwritten by a stale full-document write.
- **Faithful, atomic recovery of unsaved edits**. Hot-exit restore replays uncommitted edits into a freshly opened in-memory database inside a single SAVEPOINT, rolled back on any error or cancellation, so a partially replayed database is never left behind.

## 1.4.0

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "sqlite-explorer",
"displayName": "SQLite Explorer",
"description": "A powerful SQLite database viewer and editor for VS Code",
"version": "1.4.0",
"version": "1.5.0",
"publisher": "zknpr",
"license": "MIT",
"repository": {
Expand Down
147 changes: 124 additions & 23 deletions src/core/engine/wasm/WasmDatabaseEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,39 @@ export class WasmDatabaseEngine implements DatabaseOperations {
}
}

/**
* Build a quoted SAVEPOINT name that is unique enough for nested engine work.
*/
private createSavepointName(prefix: string): string {
return escapeIdentifier(`${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
}

/**
* Roll back and release a SAVEPOINT without masking the original failure.
*/
private async safeRollbackSavepoint(savepointName: string, context: string): Promise<void> {
try {
await this.executeQuery(`ROLLBACK TO ${savepointName}`);
await this.executeQuery(`RELEASE ${savepointName}`);
} catch (rollbackErr) {
console.warn(`Failed to rollback savepoint (${context}):`, rollbackErr);
}
}

/**
* Normalize serialized cell replay operations for old and malformed history.
*/
private normalizeReplayCellOperation(
operation: unknown,
strict: boolean,
context: string
): 'set' | 'json_patch' {
if (operation === undefined || operation === null) return 'set';
if (operation === 'set' || operation === 'json_patch') return operation;
if (strict) throw new Error(`Cannot apply ${context}: unsupported cell operation ${String(operation)}`);
return 'set';
}

/**
* Execute a SQL query and return structured results.
*
Expand Down Expand Up @@ -183,13 +216,35 @@ export class WasmDatabaseEngine implements DatabaseOperations {

/**
* Apply a batch of modifications.
* Currently no-op as modifications are applied via executeQuery.
*
* Hot-exit restore opens the last saved database bytes first, then replays
* the serialized uncommitted edit entries into that fresh in-memory database.
* Each entry is applied through the same forward path used by redo so restore
* and redo cannot drift apart.
*/
async applyModifications(
_mods: ModificationEntry[],
_signal?: AbortSignal
mods: ModificationEntry[],
signal?: AbortSignal
): Promise<void> {
// Modifications applied directly through executeQuery
signal?.throwIfAborted();
if (mods.length === 0) return;

const savepointName = this.createSavepointName('sp_apply_modifications');
await this.executeQuery(`SAVEPOINT ${savepointName}`);
try {
for (const mod of mods) {
// Check cancellation at the replay boundary so an aborted restore stops
// before the next modification starts mutating the in-memory database.
signal?.throwIfAborted();
await this.forwardApply(mod, true);
signal?.throwIfAborted();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
signal?.throwIfAborted();
Comment on lines +229 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back failed hot-exit replays

If restore cancellation or a later unsupported/malformed edit occurs after one or more entries have been applied, this loop leaves those earlier changes in the in-memory database before DatabaseDocument.create() catches the error and opens the document read-only. In that scenario the restored database no longer matches either the saved file or the full backup history, and users can inspect/export a partially replayed state; apply the batch inside a transaction/savepoint and roll it back on any error or abort.

Useful? React with 👍 / 👎.

await this.executeQuery(`RELEASE ${savepointName}`);
} catch (err) {
await this.safeRollbackSavepoint(savepointName, 'applyModifications');
throw err;
}
}

/**
Expand Down Expand Up @@ -331,69 +386,114 @@ export class WasmDatabaseEngine implements DatabaseOperations {
}

/**
* Redo a modification.
* Apply one modification in the forward direction.
*
* `strict` is enabled for hot-exit restore so malformed or unsupported
* entries fail loudly instead of silently dropping recovered edits. Redo uses
* the historical non-strict behavior so existing undo/redo semantics are not
* changed for entries that lack enough data to replay.
*
* Keep the non-strict redo shape paired with nativeWorker.redoModification so
* ModificationEntry fields keep one interpretation across web and desktop.
*/
async redoModification(mod: ModificationEntry): Promise<void> {
const { modificationType, targetTable, targetRowId, targetColumn, newValue, affectedCells, affectedRowIds, rowData, tableDef, columnDef, deletedColumns } = mod;
if (!targetTable) return;
private async forwardApply(mod: ModificationEntry, strict: boolean): Promise<void> {
const { modificationType, targetTable, targetRowId, targetColumn, newValue, operation, affectedCells, affectedRowIds, rowData, tableDef, columnDef, deletedColumns, droppedIndexes } = mod;
if (!targetTable) {
if (strict) throw new Error(`Cannot apply ${modificationType}: missing target table`);
return;
}

switch (modificationType) {
case 'cell_update':
if (affectedCells) {
// Batch redo
// Batch cell updates preserve the original per-cell order and values.
const updates = affectedCells.map(cell => ({
rowId: cell.rowId,
column: cell.columnName,
Comment on lines 410 to 412

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replay JSON-patch batches as patches, not values

When a multi-cell JSON edit is optimized in HostBridge.updateCellBatch, the history entry records newValue as the generated merge-patch string rather than the final cell contents, and this newly wired browser WASM replay/redo path turns that field into a normal set update. In vscode.dev, Undo→Redo or hot-exit restore for such a batch edit will write the patch document itself into the cell instead of reapplying the JSON change; preserve the operation/final value in history or replay these entries as JSON patches.

Useful? React with 👍 / 👎.

value: cell.newValue ?? null
value: cell.newValue ?? null,
operation: this.normalizeReplayCellOperation(cell.operation, strict, 'cell_update')
}));
await this.updateCellBatch(targetTable, updates);
} else if (targetRowId !== undefined && targetColumn) {
await this.updateCell(targetTable, targetRowId, targetColumn, newValue ?? null);
const replayOperation = this.normalizeReplayCellOperation(operation, strict, 'cell_update');
if (replayOperation === 'json_patch') {
const patch = typeof newValue === 'string' ? newValue : JSON.stringify(newValue ?? null);
await this.updateCell(targetTable, targetRowId, targetColumn, null, patch);
} else {
await this.updateCell(targetTable, targetRowId, targetColumn, newValue ?? null);
}
} else if (strict) {
throw new Error('Cannot apply cell_update: missing target cell or affected cells');
}
break;

case 'row_insert':
// Redo insert = insert again
if (rowData) {
// If we have the original rowId, enforce it to maintain history consistency
// If the history captured a rowid, include it so restored rows
// keep the same identity they had before shutdown.
const dataToInsert = targetRowId !== undefined
? { ...rowData, rowid: targetRowId }
: rowData;
await this.insertRow(targetTable, dataToInsert);
} else if (strict) {
throw new Error('Cannot apply row_insert: missing row data');
}
break;

case 'row_delete':
// Redo delete = delete rows
if (affectedRowIds) {
await this.deleteRows(targetTable, affectedRowIds);
} else if (strict) {
throw new Error('Cannot apply row_delete: missing affected row ids');
}
break;

case 'column_add':
// Redo add column = add column
if (targetColumn && columnDef) {
await this.addColumn(targetTable, targetColumn, columnDef.type, columnDef.defaultValue);
} else if (strict) {
throw new Error('Cannot apply column_add: missing column definition');
}
break;

case 'column_drop':
// Redo drop column = drop column
if (deletedColumns) {
const colNames = deletedColumns.map(c => c.name);
await this.deleteColumns(targetTable, colNames);
await this.deleteColumns(targetTable, colNames, droppedIndexes ?? undefined);
} else if (strict) {
throw new Error('Cannot apply column_drop: missing deleted column data');
}
break;

case 'table_create':
// Redo create table
if (tableDef && tableDef.columns) {
await this.createTable(targetTable, tableDef.columns);
} else if (strict) {
throw new Error('Cannot apply table_create: missing table definition');
}
break;

case 'table_drop':
if (strict) {
throw new Error('Cannot apply table_drop: forward replay is not supported');
}
break;

default:
if (strict) {
throw new Error(`Cannot apply unsupported modification type: ${String(modificationType)}`);
}
break;
}
}

/**
* Redo a modification.
*/
async redoModification(mod: ModificationEntry): Promise<void> {
await this.forwardApply(mod, false);
}

/**
* Flush changes to storage.
* No-op for in-memory database; actual persistence via serializeDatabase.
Expand Down Expand Up @@ -622,9 +722,10 @@ export class WasmDatabaseEngine implements DatabaseOperations {

const escapedTable = escapeIdentifier(table);

// Now drop the columns within a single transaction for better performance
// This avoids N+1 query transaction overhead for multiple columns
await this.executeQuery('BEGIN TRANSACTION');
// Use a SAVEPOINT so column drops remain atomic on their own and can also
// participate in the outer hot-exit restore transaction.
const savepointName = this.createSavepointName('sp_delete_columns');
await this.executeQuery(`SAVEPOINT ${savepointName}`);
try {
// Drop specified dependent indexes first inside the transaction
if (dropDependentIndexes && dropDependentIndexes.length > 0) {
Expand All @@ -638,9 +739,9 @@ export class WasmDatabaseEngine implements DatabaseOperations {
.map((col) => `ALTER TABLE ${escapedTable} DROP COLUMN ${escapeIdentifier(col)};`)
.join('\n');
await this.executeQuery(dropColumnStatements);
await this.executeQuery('COMMIT');
await this.executeQuery(`RELEASE ${savepointName}`);
} catch (e) {
await this.safeRollback('deleteColumns');
await this.safeRollbackSavepoint(savepointName, 'deleteColumns');
throw e;
}
}
Expand Down
31 changes: 28 additions & 3 deletions src/core/sqlite-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import type {
TableCountOptions,
SchemaSnapshot,
ColumnMetadata,
ColumnDefinition
ColumnDefinition,
ModificationEntry
} from './types';
import { getNodeFs } from './platform/fs';
import {
Expand Down Expand Up @@ -174,8 +175,32 @@ export function createWorkerEndpoint() {
return requireEngine().serializeDatabase(name);
},

async updateCell(table: string, rowId: RecordId, column: string, value: CellValue): Promise<void> {
return requireEngine().updateCell(table, rowId, column, value);
// Expose undo/history operations for the browser in-process facade, which
// calls this endpoint directly instead of going through worker RPC.
async applyModifications(mods: ModificationEntry[], signal?: AbortSignal): Promise<void> {
return requireEngine().applyModifications(mods, signal);
},

async undoModification(mod: ModificationEntry): Promise<void> {
return requireEngine().undoModification(mod);
},

async redoModification(mod: ModificationEntry): Promise<void> {
return requireEngine().redoModification(mod);
},

async flushChanges(signal?: AbortSignal): Promise<void> {
return requireEngine().flushChanges(signal);
},

async discardModifications(mods: ModificationEntry[], signal?: AbortSignal): Promise<void> {
return requireEngine().discardModifications(mods, signal);
},

async updateCell(table: string, rowId: RecordId, column: string, value: CellValue, patch?: string): Promise<void> {
// Forward the optional JSON merge patch so browser/in-process cell edits
// can update the current document instead of replacing it with stale data.
return requireEngine().updateCell(table, rowId, column, value, patch);
},

async insertRow(table: string, data: Record<string, CellValue>): Promise<RecordId | undefined> {
Expand Down
13 changes: 12 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ export type ModificationType =
| 'column_drop'
| 'table_drop';

/**
* How a cell value should be applied when replaying a cell update.
*/
export type CellUpdateOperation = 'set' | 'json_patch';

/**
* Record of a single database modification for undo/redo.
*/
Expand All @@ -153,6 +158,8 @@ export interface ModificationEntry {
priorValue?: CellValue;
/** Value after modification */
newValue?: CellValue;
/** Cell update operation; missing values from older backups are treated as set. */
operation?: CellUpdateOperation;
/** Raw SQL executed */
executedQuery?: string;
/** Multiple affected rows */
Expand All @@ -163,6 +170,8 @@ export interface ModificationEntry {
columnName: string;
priorValue?: CellValue;
newValue?: CellValue;
/** Per-cell operation; missing values from older backups are treated as set. */
operation?: CellUpdateOperation;
}[];
/** Row data for insert/delete undo/redo */
rowData?: Record<string, CellValue>;
Expand All @@ -178,6 +187,8 @@ export interface ModificationEntry {
type: string;
data: { rowId: RecordId; value: CellValue }[];
}[];
/** Indexes dropped before a column_drop; missing values from older backups mean none. */
droppedIndexes?: string[];
}

/**
Expand Down Expand Up @@ -280,7 +291,7 @@ export interface CellUpdate {
column: string;
value: CellValue;
originalValue?: CellValue;
operation?: 'set' | 'json_patch';
operation?: CellUpdateOperation;
}

/**
Expand Down
Loading
Loading