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>
342 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|