Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.

- **A database transaction error cannot leave subsequent operations uncommitted.** When an operation inside a transaction fails and the database aborts the transaction before a rollback finishes, the connection's transaction counter is now guaranteed to reset so later transactions on the same connection preserve their ACID isolation.

## [1.6.0] - 2026-08-26

### Highlights
Expand Down
53 changes: 53 additions & 0 deletions __tests__/sqlite-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,57 @@ describe('DatabaseConnection — backend reporting', () => {
cg.destroy();
}
});

describe('transaction depth and rollback safety (CG-SQLITE-TX-01)', () => {
it('resets transaction depth to 0 on standard error rollback and allows subsequent transactions', () => {
const conn = DatabaseConnection.initialize(path.join(dir, 'tx-test.db'));
const db = (conn as any).db;
db.exec('CREATE TABLE items (id INT, val TEXT)');

const failingTx = db.transaction(() => {
db.exec("INSERT INTO items VALUES (1, 'one')");
throw new Error('boom');
});

expect(() => failingTx()).toThrow('boom');

// Subsequent transaction should work normally, demonstrating _txDepth reset to 0
const successTx = db.transaction(() => {
db.exec("INSERT INTO items VALUES (2, 'two')");
});
expect(() => successTx()).not.toThrow();

const rows = db.prepare('SELECT * FROM items').all();
expect(rows).toHaveLength(1);
expect((rows[0] as any).id).toBe(2);
conn.close();
});

it('resets transaction depth to 0 even if ROLLBACK fails or transaction was already aborted', () => {
const conn = DatabaseConnection.initialize(path.join(dir, 'tx-abort-test.db'));
const db = (conn as any).db;
db.exec('CREATE TABLE items (id INT, val TEXT)');

// Simulate a case where SQLite aborts the transaction or ROLLBACK is invoked when no tx is active
const doubleAbortTx = db.transaction(() => {
db.exec("INSERT INTO items VALUES (1, 'one')");
// Manually rollback inside, so when the outer transaction block tries to ROLLBACK, it errors:
(db as any)._db.exec('ROLLBACK');
throw new Error('closure error after manual abort');
});

expect(() => doubleAbortTx()).toThrow('closure error after manual abort');

// Verify that _txDepth is safely reset to 0 and a new transaction can be created and committed
const nextTx = db.transaction(() => {
db.exec("INSERT INTO items VALUES (3, 'three')");
});
expect(() => nextTx()).not.toThrow();

const rows = db.prepare('SELECT * FROM items').all();
expect(rows).toHaveLength(1);
expect((rows[0] as any).id).toBe(3);
conn.close();
});
});
});
14 changes: 10 additions & 4 deletions src/db/sqlite-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,21 @@ class NodeSqliteAdapter implements SqliteDatabase {
}
this._db.exec('BEGIN');
this._txDepth = 1;
let committed = false;
try {
const result = fn(...args);
this._db.exec('COMMIT');
this._txDepth = 0;
committed = true;
return result;
} catch (error) {
this._db.exec('ROLLBACK');
} finally {
if (!committed) {
try {
this._db.exec('ROLLBACK');
} catch {
// Ignore rollback errors if SQLite automatically aborted/rolled back
}
}
this._txDepth = 0;
throw error;
}
};
}
Expand Down