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
183 changes: 106 additions & 77 deletions src/core/sqlite-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,94 +115,123 @@ class WasmDatabaseEngine implements DatabaseOperations {
* Undo a modification.
*/
async undoModification(mod: ModificationEntry): Promise<void> {
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<void> {
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<void> {
const { targetRowId } = mod;
// Undo insert = delete row
if (targetRowId !== undefined) {
await this.deleteRows(targetTable, [targetRowId]);
}
}

private async undoRowDelete(targetTable: string, mod: ModificationEntry): Promise<void> {
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<void> {
const { targetColumn } = mod;
// Undo add column = drop column
if (targetColumn) {
await this.deleteColumns(targetTable, [targetColumn]);
}
}

private async undoColumnDrop(targetTable: string, mod: ModificationEntry): Promise<void> {
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<void> {
// Undo create table = drop table
await this.executeQuery(`DROP TABLE IF EXISTS ${escapeIdentifier(targetTable)}`);
}

/**
* Redo a modification.
*/
Expand Down
91 changes: 89 additions & 2 deletions tests/unit/undo_redo_integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
});
});