Skip to content
Closed
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 1.5.1

### Fixes

- **Undo of a JSON cell edit no longer clobbers concurrent changes to other keys.** When a JSON cell was edited as a merge patch, undo previously restored the whole captured document, which could overwrite a concurrent change to a different key of the same cell. Undo now applies an inverse merge patch that touches only the keys the original edit changed (web and desktop): a key the edit added is removed, a key it changed is restored to its prior value. Non-JSON edits and edits whose prior value was not a JSON object keep the previous full-value undo.
- **JSON merge patches treat arrays as atomic values** (RFC 7396), aligning the JavaScript merge-patch fallback with SQLite's native `json_patch()` semantics.

## 1.5.0

### Features
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.5.0",
"version": "1.5.1",
"publisher": "zknpr",
"license": "MIT",
"repository": {
Expand Down
55 changes: 46 additions & 9 deletions src/core/engine/wasm/WasmDatabaseEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
} from '../../types';
import { escapeIdentifier, validateSqlType, validateRowId, validateRowIds } from '../../sql-utils';
import { buildSelectQuery, buildCountQuery } from '../../query-builder';
import { applyMergePatch } from '../../json-utils';
import { applyMergePatch, tryCreateInverseMergePatch } from '../../json-utils';
import { getNodeFs } from '../../platform/fs';

// ============================================================================
Expand Down Expand Up @@ -282,21 +282,58 @@ export class WasmDatabaseEngine implements DatabaseOperations {
}

private async undoCellUpdate(targetTable: string, mod: ModificationEntry): Promise<void> {
const { affectedCells, targetRowId, targetColumn, priorValue } = mod;
const { affectedCells, targetRowId, targetColumn, priorValue, newValue, operation } = mod;
if (affectedCells) {
// Batch undo
const updates = affectedCells.map(cell => ({
rowId: cell.rowId,
column: cell.columnName,
value: cell.priorValue ?? null
}));
await this.updateCellBatch(targetTable, updates);
const updates = affectedCells.map(cell =>
this.createUndoCellUpdate(cell.rowId, cell.columnName, cell.priorValue, cell.newValue, cell.operation)
);
if (updates.some(update => update.operation === 'json_patch')) {
for (const update of updates) {
await this.applyUndoCellUpdate(targetTable, update);
}
Comment on lines +292 to +294
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 Preserve atomicity for JSON batch undo

When a batch undo contains any JSON patch, this path applies every cell update one at a time instead of using the existing updateCellBatch savepoint/transaction. If a later cell fails during discard/undo (for example because its column was removed externally or a constraint rejects the restored value), earlier cells in the same recorded batch have already been reverted and are not rolled back, leaving a partially undone modification; wrap this loop in a savepoint/transaction like updateCellBatch does. The same sequential pattern is mirrored in src/nativeWorker.ts, while native execBatch is explicitly transactional.

Useful? React with 👍 / 👎.

} else {
await this.updateCellBatch(targetTable, updates);
}
Comment on lines +291 to +297
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When a batch of updates contains at least one json_patch operation, all updates in the batch are currently executed sequentially. We can optimize this by partitioning the updates: batching all standard set updates together using updateCellBatch, and only executing the json_patch updates sequentially. This significantly improves performance for mixed batches.

Suggested change
if (updates.some(update => update.operation === 'json_patch')) {
for (const update of updates) {
await this.applyUndoCellUpdate(targetTable, update);
}
} else {
await this.updateCellBatch(targetTable, updates);
}
const patchUpdates = updates.filter(u => u.operation === 'json_patch');
const setUpdates = updates.filter(u => u.operation !== 'json_patch');
if (setUpdates.length > 0) {
await this.updateCellBatch(targetTable, setUpdates);
}
for (const update of patchUpdates) {
await this.applyUndoCellUpdate(targetTable, update);
}

} else if (targetRowId !== undefined && targetColumn) {
// Single cell undo
await this.updateCell(targetTable, targetRowId, targetColumn, priorValue ?? null);
const update = this.createUndoCellUpdate(targetRowId, targetColumn, priorValue, newValue, operation);
await this.applyUndoCellUpdate(targetTable, update);
}
}

private async applyUndoCellUpdate(targetTable: string, update: CellUpdate): Promise<void> {
if (update.operation === 'json_patch') {
try {
await this.updateCell(targetTable, update.rowId, update.column, null, update.value as string);
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 Fall back for non-object current JSON values

If the cell is changed to SQL NULL or a valid JSON scalar/array between the tracked JSON edit and undo, this json_patch call does not throw, so the catch-based full-restore fallback never runs. SQLite treats non-object targets as an empty object here (and COALESCE explicitly maps SQL NULL to {}), so undoing a patch like prior {"status":"draft","owner":"ada"} with inverse {"status":"draft"} leaves only status and drops untouched prior keys; guard the current value/object shape before applying the inverse patch or fall back to originalValue.

Useful? React with 👍 / 👎.

} catch {
// The current cell can become non-JSON after the tracked edit; the recorded prior value is the safe full-cell fallback.
await this.updateCell(targetTable, update.rowId, update.column, update.originalValue ?? null);
}
} else {
await this.updateCell(targetTable, update.rowId, update.column, update.value);
}
}

private createUndoCellUpdate(
rowId: RecordId,
column: string,
priorValue: CellValue | undefined,
newValue: CellValue | undefined,
operation: ModificationEntry['operation']
): CellUpdate {
if (operation === 'json_patch') {
const inversePatch = tryCreateInverseMergePatch(newValue, priorValue);
if (inversePatch !== undefined) {
// The inverse merge patch touches only keys from the original forward patch.
return { rowId, column, value: inversePatch, originalValue: priorValue ?? null, operation: 'json_patch' };
}
}

// Non-patch edits and unparseable/non-object priors retain legacy value replacement undo.
return { rowId, column, value: priorValue ?? null, originalValue: priorValue ?? null, operation: 'set' };
}
Comment on lines +318 to +335
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The createUndoCellUpdate helper function is duplicated exactly between WasmDatabaseEngine.ts and nativeWorker.ts. To improve maintainability and prevent future logic drift, consider extracting this function to a shared utility file (such as src/core/json-utils.ts or a dedicated shared helpers file) and importing it in both places.


private async undoRowInsert(targetTable: string, mod: ModificationEntry): Promise<void> {
const { targetRowId } = mod;
// Undo insert = delete row
Expand Down
106 changes: 105 additions & 1 deletion src/core/json-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@

const MAX_DEPTH = 1000;

class UnfaithfulInverseMergePatchError extends Error {
constructor() {
super('JSON inverse merge patch would not faithfully restore the prior value');
}
}

export function generateMergePatch(original: unknown, modified: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) {
throw new Error('JSON merge patch depth limit exceeded');
Expand Down Expand Up @@ -108,6 +114,104 @@ export function applyMergePatch(target: unknown, patch: unknown, depth = 0): unk
return targetObj;
}

/**
* Build a JSON Merge Patch that reverses the keys touched by a forward patch.
*
* The inverse is intentionally restricted to the forward patch key structure so
* undo can restore the edited keys without replacing concurrent sibling edits.
*/
export function invertMergePatch(forwardPatch: unknown, prior: unknown): unknown {
return invertMergePatchAtDepth(forwardPatch, prior, 0);
}

/**
* Convert recorded cell values into a serialized inverse merge patch.
*
* Undo uses this only when both recorded values are JSON strings and the prior
* document is an object, matching SQLite json_patch object-merge semantics.
*/
export function tryCreateInverseMergePatch(forwardPatchValue: unknown, priorValue: unknown): string | undefined {
if (typeof forwardPatchValue !== 'string' || typeof priorValue !== 'string') {
return undefined;
}

const forwardPatch = parseJsonValue(forwardPatchValue);
const prior = parseJsonValue(priorValue);
if (!forwardPatch.ok || !prior.ok || !isObject(forwardPatch.value) || !isObject(prior.value)) {
return undefined;
}

try {
return JSON.stringify(invertMergePatch(forwardPatch.value, prior.value));
} catch (err) {
if (err instanceof UnfaithfulInverseMergePatchError) {
return undefined;
}
throw err;
}
}

function invertMergePatchAtDepth(forwardPatch: unknown, prior: unknown, depth: number): unknown {
if (depth > MAX_DEPTH) {
throw new Error('JSON invert merge patch depth limit exceeded');
}

if (!isObject(forwardPatch)) {
return prior === undefined ? null : prior;
}

const inverse: Record<string, unknown> = {};
const priorObj = isObject(prior) ? prior : {};

for (const key of Object.keys(forwardPatch)) {
const priorHas = Object.prototype.hasOwnProperty.call(priorObj, key);
const priorVal = priorHas ? priorObj[key] : undefined;
const forwardVal = forwardPatch[key];

if (isObject(forwardVal)) {
if (priorHas && isObject(priorVal)) {
inverse[key] = invertMergePatchAtDepth(forwardVal, priorVal, depth + 1);
} else if (priorHas && priorVal === null) {
throw new UnfaithfulInverseMergePatchError();
} else if (priorHas) {
// A forward object patch replaced a scalar or array prior, so the inverse must restore that whole value.
inverse[key] = priorVal;
} else {
// Recurse into added objects so undo deletes only the leaves touched by the forward patch.
inverse[key] = invertMergePatchAtDepth(forwardVal, {}, depth + 1);
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 Delete newly added objects completely on undo

When the tracked patch adds a whole object and no nested sibling was added later (for example prior {} and forward patch {"meta":{"reviewed":true}}), this branch emits an inverse like {"meta":{"reviewed":null}}; SQLite merge patching that over {"meta":{"reviewed":true}} leaves {"meta":{}} instead of restoring the prior {}. This makes a plain undo of nested object additions leave empty container keys behind; fall back to deleting the parent key when there are no concurrent nested keys, or otherwise clean up the empty object after the inverse patch.

Useful? React with 👍 / 👎.

}
} else if (forwardVal === null) {
inverse[key] = createInverseLeafValue(priorHas, priorVal);
} else {
inverse[key] = createInverseLeafValue(priorHas, priorVal);
}
Comment on lines +183 to +187
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The else if (forwardVal === null) branch is redundant because it executes the exact same logic as the else branch. Simplifying this improves code readability and maintainability.

        } else {
            inverse[key] = createInverseLeafValue(priorHas, priorVal);
        }

}

return inverse;
}

function createInverseLeafValue(priorHas: boolean, priorVal: unknown): unknown {
if (!priorHas) {
// RFC 7396 null deletes a key, which is faithful when the forward patch added that key.
return null;
}

if (priorVal === null) {
// RFC 7396 null would delete this existing key instead of restoring its explicit JSON null value.
throw new UnfaithfulInverseMergePatchError();
}

return priorVal;
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 Fall back when restored subtrees contain nulls

The fresh case here is that this helper only rejects priorVal === null; when the prior value being emitted is an object that contains explicit nulls, those nested nulls are still serialized into the inverse merge patch and are interpreted as deletions. For example, undoing a forward delete of a from prior {"a":{"x":null,"y":1}} emits {"a":{"x":null,"y":1}}, and SQLite restores {"a":{"y":1}} instead of the captured prior value, so recursively detect nulls in whole-subtree restore values and fall back to a full set.

Useful? React with 👍 / 👎.

}

function parseJsonValue(value: string): { ok: true; value: unknown } | { ok: false } {
try {
return { ok: true, value: JSON.parse(value) };
} catch {
return { ok: false };
}
}

function isObject(val: unknown): val is Record<string, unknown> {
return val !== null && typeof val === 'object';
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
63 changes: 48 additions & 15 deletions src/nativeWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
} from './core/types';
import { escapeIdentifier, validateSqlType, validateRowId, validateRowIds } from './core/sql-utils';
import { buildSelectQuery, buildCountQuery } from './core/query-builder';
import { tryCreateInverseMergePatch } from './core/json-utils';

// ============================================================================
// Utility Functions
Expand Down Expand Up @@ -90,6 +91,27 @@ const INIT_TIMEOUT = 10000;
/** Timeout for individual queries (ms) */
const QUERY_TIMEOUT = 30000;

// Keep this paired with WasmDatabaseEngine.undoCellUpdate so web and native
// undo interpret ModificationEntry cell_update fields the same way.
function createUndoCellUpdate(
rowId: RecordId,
column: string,
priorValue: CellValue | undefined,
newValue: CellValue | undefined,
operation: ModificationEntry['operation']
): CellUpdate {
if (operation === 'json_patch') {
const inversePatch = tryCreateInverseMergePatch(newValue, priorValue);
if (inversePatch !== undefined) {
// The inverse merge patch restores only keys touched by the forward patch.
return { rowId, column, value: inversePatch, originalValue: priorValue ?? null, operation: 'json_patch' };
}
}

// Non-patch edits and unparseable/non-object priors retain legacy value replacement undo.
return { rowId, column, value: priorValue ?? null, originalValue: priorValue ?? null, operation: 'set' };
}

// ============================================================================
// Platform Detection
// ============================================================================
Expand Down Expand Up @@ -535,28 +557,39 @@ export async function createNativeDatabaseConnection(
* Undo a modification by executing the inverse SQL.
*/
undoModification: async (mod: ModificationEntry) => {
const { modificationType, targetTable, targetRowId, targetColumn, priorValue, affectedCells, deletedRows, columnDef, deletedColumns } = mod;
const { modificationType, targetTable, targetRowId, targetColumn, priorValue, newValue, operation, affectedCells, deletedRows, columnDef, deletedColumns } = mod;
if (!targetTable) return;

const applyUndoCellUpdate = async (update: CellUpdate): Promise<void> => {
if (update.operation === 'json_patch') {
try {
await operationsFacade.updateCell(targetTable, update.rowId, update.column, null, update.value as string);
} catch {
// A json_patch undo can fail if another edit replaced the current cell with non-JSON text.
await operationsFacade.updateCell(targetTable, update.rowId, update.column, update.originalValue ?? null);
}
} else {
await operationsFacade.updateCell(targetTable, update.rowId, update.column, update.value);
}
};

switch (modificationType) {
case 'cell_update':
if (affectedCells) {
// Batch undo
const updates = affectedCells.map(c => ({
rowId: c.rowId,
column: c.columnName,
value: c.priorValue
} as CellUpdate));
await worker.call('execBatch', [
updates.map(u => ({
sql: `UPDATE ${escapeIdentifier(targetTable)} SET ${escapeIdentifier(u.column)} = ? WHERE rowid = ?`,
params: [u.value, Number(u.rowId)]
}))
]);
const updates = affectedCells.map(c =>
createUndoCellUpdate(c.rowId, c.columnName, c.priorValue, c.newValue, c.operation)
);
if (updates.some(update => update.operation === 'json_patch')) {
for (const update of updates) {
await applyUndoCellUpdate(update);
}
} else {
await operationsFacade.updateCellBatch(targetTable, updates);
}
Comment on lines +583 to +589
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When a batch of updates contains at least one json_patch operation, all updates in the batch are currently executed sequentially. We can optimize this by partitioning the updates: batching all standard set updates together using operationsFacade.updateCellBatch, and only executing the json_patch updates sequentially. This significantly improves performance for mixed batches.

Suggested change
if (updates.some(update => update.operation === 'json_patch')) {
for (const update of updates) {
await applyUndoCellUpdate(update);
}
} else {
await operationsFacade.updateCellBatch(targetTable, updates);
}
const patchUpdates = updates.filter(u => u.operation === 'json_patch');
const setUpdates = updates.filter(u => u.operation !== 'json_patch');
if (setUpdates.length > 0) {
await operationsFacade.updateCellBatch(targetTable, setUpdates);
}
for (const update of patchUpdates) {
await applyUndoCellUpdate(update);
}

} else if (targetRowId !== undefined && targetColumn) {
const rowIdNum = Number(targetRowId);
const sql = `UPDATE ${escapeIdentifier(targetTable)} SET ${escapeIdentifier(targetColumn)} = ? WHERE rowid = ?`;
await worker.call('run', [sql, [priorValue, rowIdNum]]);
const update = createUndoCellUpdate(targetRowId, targetColumn, priorValue, newValue, operation);
await applyUndoCellUpdate(update);
}
break;

Expand Down
Loading
Loading