LocalHtmlDataTest/sql-js/src/sqljs-store.ts
Jason Tudisco 6ebe02ad56 Initial commit: local-first browser sync library experiment
Four variants of the same sync library (IndexedDB, NeDB, SQLite WASM, sql.js)
plus a paste-bin demo app for testing multi-browser sync via shared folders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:04:08 -06:00

342 lines
11 KiB
TypeScript

// 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<SqlJsStore> {
// 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<KVRecord | undefined> {
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<void> {
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<void> {
this.db.run('DELETE FROM kv WHERE key = ?', [key]);
}
async getAllKVKeys(): Promise<string[]> {
return this.query('SELECT key FROM kv').map((r) => r.key as string);
}
async getAllKVEntries(): Promise<KVRecord[]> {
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<DocRecord | undefined> {
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<DocRecord | undefined> {
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<void> {
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<void> {
return this.putDoc(
{ store, id, data: null, ts, rev, deleted: true },
indexes,
);
}
async getAllDocs(store: string): Promise<DocRecord[]> {
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<string[]> {
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<string[]> {
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<void> {
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<boolean> {
return (
this.query('SELECT 1 FROM applied WHERE filename = ?', [filename])
.length > 0
);
}
async markEventApplied(filename: string): Promise<void> {
this.db.run(
'INSERT OR IGNORE INTO applied (filename, applied_at) VALUES (?,?)',
[filename, Date.now()],
);
}
async getAppliedSet(): Promise<Set<string>> {
const rows = this.query('SELECT filename FROM applied');
return new Set(rows.map((r) => r.filename as string));
}
// ── Meta ───────────────────────────────────────────────────
async getMeta<T = unknown>(key: string): Promise<T | undefined> {
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<void> {
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<string, unknown>[] {
const results = this.db.exec(sql, params);
if (results.length === 0) return [];
const { columns, values } = results[0];
return values.map((row) => {
const obj: Record<string, unknown> = {};
for (let i = 0; i < columns.length; i++) {
obj[columns[i]] = row[i];
}
return obj;
});
}
private rowToDoc(row: Record<string, unknown>): 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,
};
}
}