// Use the asm.js build — pure JS, no .wasm file, works from file:// import initSqlJs from 'sql.js/dist/sql-asm.js'; import type { Database } from 'sql.js'; import type { KVRecord, DocRecord, IndexDefinition } from './types.js'; import { extractIndexValue } from './utils.js'; // ── Value serialization ────────────────────────────────────── function serializeIndexValue(v: unknown): unknown { if (typeof v === 'number' || typeof v === 'string') return v; if (v instanceof Date) return v.getTime(); if (Array.isArray(v)) return JSON.stringify(v); return String(v); } // ── SqlJsStore ─────────────────────────────────────────────── export class SqlJsStore { private constructor(private db: Database) {} // ── Open ─────────────────────────────────────────────────── static async open( _name: string, existingData?: Uint8Array, ): Promise { // sql.js asm.js build: no .wasm file, no fetch, no headers const SQL = await initSqlJs(); const db = existingData ? new SQL.Database(existingData) : new SQL.Database(); const store = new SqlJsStore(db); store.createSchema(); // no-op if tables already exist (IF NOT EXISTS) return store; } // ── Disk persistence ──────────────────────────────────────── /** Export the entire SQLite database as a binary Uint8Array. */ exportDB(): Uint8Array { return this.db.export(); } private createSchema(): void { this.db.run(` CREATE TABLE IF NOT EXISTS kv ( key TEXT PRIMARY KEY, value TEXT NOT NULL, ts INTEGER NOT NULL, rev INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS docs ( store TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, ts INTEGER NOT NULL, rev INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (store, id) ); CREATE INDEX IF NOT EXISTS docs_by_store ON docs (store) WHERE deleted = 0; CREATE TABLE IF NOT EXISTS idx_entries ( store TEXT NOT NULL, index_name TEXT NOT NULL, id TEXT NOT NULL, value ANY, PRIMARY KEY (store, index_name, id) ); CREATE INDEX IF NOT EXISTS idx_lookup ON idx_entries (store, index_name, value); CREATE INDEX IF NOT EXISTS idx_by_doc ON idx_entries (store, id); CREATE TABLE IF NOT EXISTS applied ( filename TEXT PRIMARY KEY, applied_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT ); `); } // ── KV operations ────────────────────────────────────────── async getKV(key: string): Promise { const rows = this.query( 'SELECT key, value, ts, rev FROM kv WHERE key = ?', [key], ); if (rows.length === 0) return undefined; const r = rows[0]; return { key: r.key as string, value: JSON.parse(r.value as string), ts: r.ts as number, rev: r.rev as number, }; } async putKV(record: KVRecord): Promise { this.db.run( 'INSERT OR REPLACE INTO kv (key, value, ts, rev) VALUES (?,?,?,?)', [record.key, JSON.stringify(record.value), record.ts, record.rev], ); } async deleteKV(key: string): Promise { this.db.run('DELETE FROM kv WHERE key = ?', [key]); } async getAllKVKeys(): Promise { return this.query('SELECT key FROM kv').map((r) => r.key as string); } async getAllKVEntries(): Promise { return this.query('SELECT key, value, ts, rev FROM kv').map((r) => ({ key: r.key as string, value: JSON.parse(r.value as string), ts: r.ts as number, rev: r.rev as number, })); } // ── Doc operations ───────────────────────────────────────── async getDoc(store: string, id: string): Promise { const rows = this.query( 'SELECT store, id, data, ts, rev, deleted FROM docs WHERE store = ? AND id = ? AND deleted = 0', [store, id], ); if (rows.length === 0) return undefined; return this.rowToDoc(rows[0]); } async getRawDoc(store: string, id: string): Promise { const rows = this.query( 'SELECT store, id, data, ts, rev, deleted FROM docs WHERE store = ? AND id = ?', [store, id], ); if (rows.length === 0) return undefined; return this.rowToDoc(rows[0]); } async putDoc(record: DocRecord, indexes: IndexDefinition[]): Promise { this.db.run( `INSERT OR REPLACE INTO docs (store, id, data, ts, rev, deleted) VALUES (?,?,?,?,?,?)`, [ record.store, record.id, JSON.stringify(record.data), record.ts, record.rev, record.deleted ? 1 : 0, ], ); // Update index entries this.db.run( 'DELETE FROM idx_entries WHERE store = ? AND id = ?', [record.store, record.id], ); if (!record.deleted) { for (const def of indexes) { const val = extractIndexValue(record.data, def.fields as string[]); if (val !== undefined) { this.db.run( `INSERT OR REPLACE INTO idx_entries (store, index_name, id, value) VALUES (?,?,?,?)`, [record.store, def.name, record.id, serializeIndexValue(val)], ); } } } } async deleteDoc( store: string, id: string, ts: number, rev: number, indexes: IndexDefinition[], ): Promise { return this.putDoc( { store, id, data: null, ts, rev, deleted: true }, indexes, ); } async getAllDocs(store: string): Promise { return this.query( 'SELECT store, id, data, ts, rev, deleted FROM docs WHERE store = ? AND deleted = 0', [store], ).map((r) => this.rowToDoc(r)); } // ── Index queries ────────────────────────────────────────── async findByIndex( store: string, indexName: string, value: unknown, ): Promise { return this.query( 'SELECT id FROM idx_entries WHERE store = ? AND index_name = ? AND value = ?', [store, indexName, serializeIndexValue(value)], ).map((r) => r.id as string); } async queryByIndex( store: string, indexName: string, range: { gt?: unknown; gte?: unknown; lt?: unknown; lte?: unknown }, ): Promise { const conditions = ['store = ?', 'index_name = ?']; const params: unknown[] = [store, indexName]; if (range.gte !== undefined) { conditions.push('value >= ?'); params.push(serializeIndexValue(range.gte)); } else if (range.gt !== undefined) { conditions.push('value > ?'); params.push(serializeIndexValue(range.gt)); } if (range.lte !== undefined) { conditions.push('value <= ?'); params.push(serializeIndexValue(range.lte)); } else if (range.lt !== undefined) { conditions.push('value < ?'); params.push(serializeIndexValue(range.lt)); } return this.query( `SELECT id FROM idx_entries WHERE ${conditions.join(' AND ')}`, params, ).map((r) => r.id as string); } async rebuildIndexes( store: string, indexes: IndexDefinition[], ): Promise { this.db.run('DELETE FROM idx_entries WHERE store = ?', [store]); const docs = this.query( 'SELECT id, data FROM docs WHERE store = ? AND deleted = 0', [store], ); for (const row of docs) { const data = JSON.parse(row.data as string); for (const def of indexes) { const val = extractIndexValue(data, def.fields as string[]); if (val !== undefined) { this.db.run( `INSERT OR REPLACE INTO idx_entries (store, index_name, id, value) VALUES (?,?,?,?)`, [store, def.name, row.id, serializeIndexValue(val)], ); } } } } // ── Applied-event tracking ───────────────────────────────── async isEventApplied(filename: string): Promise { return ( this.query('SELECT 1 FROM applied WHERE filename = ?', [filename]) .length > 0 ); } async markEventApplied(filename: string): Promise { this.db.run( 'INSERT OR IGNORE INTO applied (filename, applied_at) VALUES (?,?)', [filename, Date.now()], ); } async getAppliedSet(): Promise> { const rows = this.query('SELECT filename FROM applied'); return new Set(rows.map((r) => r.filename as string)); } // ── Meta ─────────────────────────────────────────────────── async getMeta(key: string): Promise { const rows = this.query('SELECT value FROM meta WHERE key = ?', [key]); if (rows.length === 0) return undefined; const raw = rows[0].value as string | null; if (raw === null) return undefined; return JSON.parse(raw) as T; } async setMeta(key: string, value: unknown): Promise { this.db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?,?)', [ key, JSON.stringify(value), ]); } // ── Lifecycle ────────────────────────────────────────────── close(): void { this.db.close(); } // ── Helpers ──────────────────────────────────────────────── private query( sql: string, params?: unknown[], ): Record[] { const results = this.db.exec(sql, params); if (results.length === 0) return []; const { columns, values } = results[0]; return values.map((row) => { const obj: Record = {}; for (let i = 0; i < columns.length; i++) { obj[columns[i]] = row[i]; } return obj; }); } private rowToDoc(row: Record): DocRecord { return { store: row.store as string, id: row.id as string, data: JSON.parse(row.data as string), ts: row.ts as number, rev: row.rev as number, deleted: (row.deleted as number) === 1, }; } }