diff --git a/src/core/sqlite-db.ts b/src/core/sqlite-db.ts index 6e24e45..1b49a5c 100644 --- a/src/core/sqlite-db.ts +++ b/src/core/sqlite-db.ts @@ -115,94 +115,123 @@ class WasmDatabaseEngine implements DatabaseOperations { * Undo a modification. */ async undoModification(mod: ModificationEntry): Promise { - const { modificationType, targetTable, targetRowId, targetColumn, priorValue, affectedCells, deletedRows, columnDef, deletedColumns } = mod; + const { modificationType, targetTable } = mod; if (!targetTable) return; switch (modificationType) { - case 'cell_update': - if (affectedCells) { - // Batch undo - await this.executeQuery('BEGIN TRANSACTION'); - try { - for (const cell of affectedCells) { - await this.updateCell(targetTable, cell.rowId, cell.columnName, cell.priorValue ?? null); - } - await this.executeQuery('COMMIT'); - } catch (e) { - await this.executeQuery('ROLLBACK'); - throw e; - } - } else if (targetRowId !== undefined && targetColumn) { - // Single cell undo - await this.updateCell(targetTable, targetRowId, targetColumn, priorValue ?? null); - } - break; + case 'cell_update': + await this.undoCellUpdate(targetTable, mod); + break; - case 'row_insert': - // Undo insert = delete row - if (targetRowId !== undefined) { - await this.deleteRows(targetTable, [targetRowId]); - } - break; + case 'row_insert': + await this.undoRowInsert(targetTable, mod); + break; - case 'row_delete': - // Undo delete = re-insert rows - if (deletedRows && deletedRows.length > 0) { - await this.executeQuery('BEGIN TRANSACTION'); - try { - for (const { rowId, row } of deletedRows) { - // row already contains rowid if needed (handled in HostBridge) - await this.insertRow(targetTable, row); - } - await this.executeQuery('COMMIT'); - } catch (e) { - await this.executeQuery('ROLLBACK'); - throw e; - } - } - break; + case 'row_delete': + await this.undoRowDelete(targetTable, mod); + break; - case 'column_add': - // Undo add column = drop column - if (targetColumn) { - await this.deleteColumns(targetTable, [targetColumn]); - } - break; + case 'column_add': + await this.undoColumnAdd(targetTable, mod); + break; - case 'column_drop': - // Undo drop column = add column + restore values - if (deletedColumns) { - await this.executeQuery('BEGIN TRANSACTION'); - try { - for (const col of deletedColumns) { - await this.addColumn(targetTable, col.name, col.type); - // Restore values - - const sql = `UPDATE ${escapeIdentifier(targetTable)} SET ${escapeIdentifier(col.name)} = ? WHERE rowid = ?`; - const stmt = this.instance.prepare(sql); - try { - for (const { rowId, value } of col.data) { - stmt.run([value, Number(rowId)]); - } - } finally { - stmt.free(); - } - } - await this.executeQuery('COMMIT'); - } catch (e) { - await this.executeQuery('ROLLBACK'); - throw e; - } - } - break; + case 'column_drop': + await this.undoColumnDrop(targetTable, mod); + break; - case 'table_create': - // Undo create table = drop table - await this.executeQuery(`DROP TABLE IF EXISTS ${escapeIdentifier(targetTable)}`); - break; + case 'table_create': + await this.undoTableCreate(targetTable); + break; + } + } + + private async undoCellUpdate(targetTable: string, mod: ModificationEntry): Promise { + const { affectedCells, targetRowId, targetColumn, priorValue } = mod; + if (affectedCells) { + // Batch undo + await this.executeQuery('BEGIN TRANSACTION'); + try { + for (const cell of affectedCells) { + await this.updateCell(targetTable, cell.rowId, cell.columnName, cell.priorValue ?? null); + } + await this.executeQuery('COMMIT'); + } catch (e) { + await this.executeQuery('ROLLBACK'); + throw e; + } + } else if (targetRowId !== undefined && targetColumn) { + // Single cell undo + await this.updateCell(targetTable, targetRowId, targetColumn, priorValue ?? null); + } + } + + private async undoRowInsert(targetTable: string, mod: ModificationEntry): Promise { + const { targetRowId } = mod; + // Undo insert = delete row + if (targetRowId !== undefined) { + await this.deleteRows(targetTable, [targetRowId]); + } + } + + private async undoRowDelete(targetTable: string, mod: ModificationEntry): Promise { + const { deletedRows } = mod; + // Undo delete = re-insert rows + if (deletedRows && deletedRows.length > 0) { + await this.executeQuery('BEGIN TRANSACTION'); + try { + for (const { rowId, row } of deletedRows) { + // row already contains rowid if needed (handled in HostBridge) + await this.insertRow(targetTable, row); + } + await this.executeQuery('COMMIT'); + } catch (e) { + await this.executeQuery('ROLLBACK'); + throw e; + } + } + } + + private async undoColumnAdd(targetTable: string, mod: ModificationEntry): Promise { + const { targetColumn } = mod; + // Undo add column = drop column + if (targetColumn) { + await this.deleteColumns(targetTable, [targetColumn]); + } + } + + private async undoColumnDrop(targetTable: string, mod: ModificationEntry): Promise { + const { deletedColumns } = mod; + // Undo drop column = add column + restore values + if (deletedColumns) { + await this.executeQuery('BEGIN TRANSACTION'); + try { + for (const col of deletedColumns) { + await this.addColumn(targetTable, col.name, col.type); + // Restore values + + const sql = `UPDATE ${escapeIdentifier(targetTable)} SET ${escapeIdentifier(col.name)} = ? WHERE rowid = ?`; + const stmt = this.instance.prepare(sql); + try { + for (const { rowId, value } of col.data) { + stmt.run([value, Number(rowId)]); + } + } finally { + stmt.free(); + } + } + await this.executeQuery('COMMIT'); + } catch (e) { + await this.executeQuery('ROLLBACK'); + throw e; + } } } + private async undoTableCreate(targetTable: string): Promise { + // Undo create table = drop table + await this.executeQuery(`DROP TABLE IF EXISTS ${escapeIdentifier(targetTable)}`); + } + /** * Redo a modification. */ diff --git a/tests/unit/undo_redo_integration.test.ts b/tests/unit/undo_redo_integration.test.ts index 59467f9..ad0c28b 100644 --- a/tests/unit/undo_redo_integration.test.ts +++ b/tests/unit/undo_redo_integration.test.ts @@ -1,5 +1,5 @@ -import { describe, it, before, after } from 'node:test'; +import { describe, it, before, beforeEach, after, afterEach } from 'node:test'; import assert from 'node:assert'; import * as fs from 'fs'; import * as path from 'path'; @@ -9,7 +9,8 @@ describe('SQLite Engine Undo/Redo', () => { let engine: any; const dbPath = path.join(__dirname, 'test_undo.db'); - before(async () => { + // Use beforeEach to ensure clean state for each test + beforeEach(async () => { // Initialize with empty DB const result = await createDatabaseEngine({ content: null, @@ -24,6 +25,12 @@ describe('SQLite Engine Undo/Redo', () => { await engine.insertRow('users', { id: 2, name: 'Bob' }); }); + afterEach(() => { + if (engine && typeof engine.shutdown === 'function') { + engine.shutdown(); + } + }); + it('should undo/redo row deletion', async () => { // 1. Delete Row 2 // Emulate HostBridge logic to capture data @@ -108,4 +115,84 @@ describe('SQLite Engine Undo/Redo', () => { assert.ok(true); } }); + + it('should undo/redo cell update', async () => { + // 1. Update Cell (id=1, name='Alice' -> 'Alice Updated') + await engine.updateCell('users', 1, 'name', 'Alice Updated'); + + const verifyUpdate = await engine.executeQuery("SELECT name FROM users WHERE id = 1"); + assert.strictEqual(verifyUpdate[0].rows[0][0], 'Alice Updated'); + + // 2. Undo Update + await engine.undoModification({ + modificationType: 'cell_update', + targetTable: 'users', + description: 'Update cell', + affectedCells: [{ rowId: 1, columnName: 'name', priorValue: 'Alice', newValue: 'Alice Updated' }] + }); + + const verifyRestored = await engine.executeQuery("SELECT name FROM users WHERE id = 1"); + assert.strictEqual(verifyRestored[0].rows[0][0], 'Alice'); + + // 3. Redo Update + await engine.redoModification({ + modificationType: 'cell_update', + targetTable: 'users', + description: 'Update cell', + affectedCells: [{ rowId: 1, columnName: 'name', priorValue: 'Alice', newValue: 'Alice Updated' }] + }); + + const verifyRedone = await engine.executeQuery("SELECT name FROM users WHERE id = 1"); + assert.strictEqual(verifyRedone[0].rows[0][0], 'Alice Updated'); + }); + + it('should undo/redo row insert', async () => { + // 1. Insert Row (id=3, name='Charlie') + const newRow = { id: 3, name: 'Charlie' }; + await engine.insertRow('users', newRow); + + const verifyInsert = await engine.fetchTableCount('users', {}); + // Note: previous tests might have left table state. + // deleteRows test restores state to 2 rows. + // columnDrop test restores state to 2 rows + name column. + // cellUpdate test restores state to 'Alice Updated' (redo) + // Wait, tests run sequentially but `before` runs once. + // Let's check state. + // After cell_update test, id=1 is 'Alice Updated', id=2 is gone? No, row_delete test redid delete of id=2. + // So we likely have 1 row (id=1). + + // Actually, let's just insert and check count increase. + const countBefore = (await engine.executeQuery("SELECT COUNT(*) FROM users"))[0].rows[0][0] as number; + + // Wait, insertRow was done above. Let's do another one. + const row4 = { id: 4, name: 'Dave' }; + await engine.insertRow('users', row4); + + const countAfter = (await engine.executeQuery("SELECT COUNT(*) FROM users"))[0].rows[0][0] as number; + assert.strictEqual(countAfter, countBefore + 1); + + // 2. Undo Insert + await engine.undoModification({ + modificationType: 'row_insert', + targetTable: 'users', + description: 'Insert row', + targetRowId: 4, + rowData: row4 + }); + + const countRestored = (await engine.executeQuery("SELECT COUNT(*) FROM users"))[0].rows[0][0] as number; + assert.strictEqual(countRestored, countBefore); + + // 3. Redo Insert + await engine.redoModification({ + modificationType: 'row_insert', + targetTable: 'users', + description: 'Insert row', + targetRowId: 4, + rowData: row4 + }); + + const countRedone = (await engine.executeQuery("SELECT COUNT(*) FROM users"))[0].rows[0][0] as number; + assert.strictEqual(countRedone, countBefore + 1); + }); });