Files
Affaan Mustafa bae1129209 feat: add SQLite state store and query CLI (#510)
* feat: add SQLite state store and ECC status CLI

* fix: replace better-sqlite3 with sql.js to eliminate native module CI failures

better-sqlite3 requires native C++ compilation (node-gyp, prebuild-install)
which fails in CI across npm/pnpm on all platforms:
- npm ci: lock file out of sync with native transitive deps
- pnpm: native bindings not found at runtime
- Windows: native compilation fails entirely

sql.js is a pure JavaScript/WASM SQLite implementation with zero native
dependencies. The adapter in index.js wraps the sql.js API to match the
better-sqlite3 interface used by migrations.js and queries.js.

Key implementation detail: sql.js db.export() implicitly ends active
transactions, so the adapter defers disk writes (saveToDisk) until
after transaction commit via an inTransaction guard flag.

createStateStore is now async (sql.js requires async WASM init).
Updated status.js, sessions-cli.js, and tests accordingly.
2026-03-16 01:32:21 -07:00

192 lines
4.9 KiB
JavaScript

'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const initSqlJs = require('sql.js');
const { applyMigrations, getAppliedMigrations } = require('./migrations');
const { createQueryApi } = require('./queries');
const { assertValidEntity, validateEntity } = require('./schema');
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
function resolveStateStorePath(options = {}) {
if (options.dbPath) {
if (options.dbPath === ':memory:') {
return options.dbPath;
}
return path.resolve(options.dbPath);
}
const homeDir = options.homeDir || process.env.HOME || os.homedir();
return path.join(homeDir, DEFAULT_STATE_STORE_RELATIVE_PATH);
}
/**
* Wraps a sql.js Database with a better-sqlite3-compatible API surface so
* that the rest of the state-store code (migrations.js, queries.js) can
* operate without knowing which driver is in use.
*
* IMPORTANT: sql.js db.export() implicitly ends any active transaction, so
* we must defer all disk writes until after the transaction commits.
*/
function wrapSqlJsDatabase(rawDb, dbPath) {
let inTransaction = false;
function saveToDisk() {
if (dbPath === ':memory:' || inTransaction) {
return;
}
const data = rawDb.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
}
const db = {
exec(sql) {
rawDb.run(sql);
saveToDisk();
},
pragma(pragmaStr) {
try {
rawDb.run(`PRAGMA ${pragmaStr}`);
} catch (_error) {
// Ignore unsupported pragmas (e.g. WAL for in-memory databases).
}
},
prepare(sql) {
return {
all(...positionalArgs) {
const stmt = rawDb.prepare(sql);
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
stmt.bind([positionalArgs[0]]);
} else if (positionalArgs.length > 1) {
stmt.bind(positionalArgs);
}
const rows = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
stmt.free();
return rows;
},
get(...positionalArgs) {
const stmt = rawDb.prepare(sql);
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
stmt.bind([positionalArgs[0]]);
} else if (positionalArgs.length > 1) {
stmt.bind(positionalArgs);
}
let row = null;
if (stmt.step()) {
row = stmt.getAsObject();
}
stmt.free();
return row;
},
run(namedParams) {
const stmt = rawDb.prepare(sql);
if (namedParams && typeof namedParams === 'object' && !Array.isArray(namedParams)) {
const sqlJsParams = {};
for (const [key, value] of Object.entries(namedParams)) {
sqlJsParams[`@${key}`] = value === undefined ? null : value;
}
stmt.bind(sqlJsParams);
}
stmt.step();
stmt.free();
saveToDisk();
},
};
},
transaction(fn) {
return (...args) => {
rawDb.run('BEGIN');
inTransaction = true;
try {
const result = fn(...args);
rawDb.run('COMMIT');
inTransaction = false;
saveToDisk();
return result;
} catch (error) {
try {
rawDb.run('ROLLBACK');
} catch (_rollbackError) {
// Transaction may already be rolled back.
}
inTransaction = false;
throw error;
}
};
},
close() {
saveToDisk();
rawDb.close();
},
};
return db;
}
async function openDatabase(SQL, dbPath) {
if (dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
let rawDb;
if (dbPath !== ':memory:' && fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
rawDb = new SQL.Database(fileBuffer);
} else {
rawDb = new SQL.Database();
}
const db = wrapSqlJsDatabase(rawDb, dbPath);
db.pragma('foreign_keys = ON');
try {
db.pragma('journal_mode = WAL');
} catch (_error) {
// Some SQLite environments reject WAL for in-memory or readonly contexts.
}
return db;
}
async function createStateStore(options = {}) {
const dbPath = resolveStateStorePath(options);
const SQL = await initSqlJs();
const db = await openDatabase(SQL, dbPath);
const appliedMigrations = applyMigrations(db);
const queryApi = createQueryApi(db);
return {
dbPath,
close() {
db.close();
},
getAppliedMigrations() {
return getAppliedMigrations(db);
},
validateEntity,
assertValidEntity,
...queryApi,
_database: db,
_migrations: appliedMigrations,
};
}
module.exports = {
DEFAULT_STATE_STORE_RELATIVE_PATH,
createStateStore,
resolveStateStorePath,
};