-
-
Notifications
You must be signed in to change notification settings - Fork 0
fix(undo): operation-aware json_patch undo — preserve concurrent keys (1.5.1) #426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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'; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // ============================================================================ | ||||||||||||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||
| await this.updateCellBatch(targetTable, updates); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+291
to
+297
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a batch of updates contains at least one
Suggested change
|
||||||||||||||||||||||||||||||||
| } 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); | ||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the cell is changed to SQL 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| private async undoRowInsert(targetTable: string, mod: ModificationEntry): Promise<void> { | ||||||||||||||||||||||||||||||||
| const { targetRowId } = mod; | ||||||||||||||||||||||||||||||||
| // Undo insert = delete row | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'); | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the tracked patch adds a whole object and no nested sibling was added later (for example prior Useful? React with 👍 / 👎. |
||
| } | ||
| } else if (forwardVal === null) { | ||
| inverse[key] = createInverseLeafValue(priorHas, priorVal); | ||
| } else { | ||
| inverse[key] = createInverseLeafValue(priorHas, priorVal); | ||
| } | ||
|
Comment on lines
+183
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fresh case here is that this helper only rejects 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); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||
| // ============================================================================ | ||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a batch of updates contains at least one
Suggested change
|
||||||||||||||||||||||||||||||||
| } 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; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a batch undo contains any JSON patch, this path applies every cell update one at a time instead of using the existing
updateCellBatchsavepoint/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 likeupdateCellBatchdoes. The same sequential pattern is mirrored insrc/nativeWorker.ts, while nativeexecBatchis explicitly transactional.Useful? React with 👍 / 👎.