import Database from 'better-sqlite3'; //////////////////////////////////////// // 1. Types //////////////////////////////////////// export type SchemaDefinition = Record; export interface CreateDBOptions { idColumn?: string; jsonColumn?: string; debugSql?: boolean; } //////////////////////////////////////// // 2. The shape of what we return //////////////////////////////////////// export interface DBClient { /** Reader: returns plain JS objects, no Proxy. */ rdr: { [TableName in keyof TSchema]: TableReader }; /** Writer: partial updates (Proxies). */ wtr: { [TableName in keyof TSchema]: TableWriter }; } /** Reader interface: bracket-get returns plain objects from memory. */ export interface TableReader { [rowId: string]: TRow | undefined; forEach(callback: (id: string, rowData: TRow) => void): void; keys(): string[]; values(): TRow[]; entries(): Array<[string, TRow]>; dict(): Record; has(id: string): boolean; } /** Writer interface: bracket-get returns a nested Proxy for partial JSON updates. */ export interface TableWriter { [rowId: string]: TRowProxy; forEach(callback: (id: string, rowProxy: TRowProxy) => void): void; keys(): string[]; entries(): Array<[string, TRowProxy]>; has(id: string): boolean; } /** * A nested Proxy that allows partial updates to single fields. * If you do `writer.users['bob'].nested.foo = 123`, * it calls `json_set(..., '$.nested.foo', 123)` in the DB. */ export type TRowProxy = TRow & { [nestedKey: string]: any; }; //////////////////////////////////////// // 3. Main entry point //////////////////////////////////////// export function createDatabaseClient( db: Database.Database, schema: TSchema, options: CreateDBOptions = {} ): DBClient { const idColumn = options.idColumn ?? 'id'; const jsonColumn = options.jsonColumn ?? 'data'; const debugSql = !!options.debugSql; //////////////////////////////////////// // A) In-memory cache: Map> //////////////////////////////////////// const memoryCache = new Map>(); for (const tableName of Object.keys(schema)) { memoryCache.set(tableName, new Map()); } //////////////////////////////////////// // B) Precompiled statements for each table //////////////////////////////////////// function wrapStmt(stmt: ReturnType, label: string) { return { get(...args: any[]) { if (debugSql) { console.log(`[SQL GET] ${label}, params: ${JSON.stringify(args)}`); } return stmt.get(...args); }, run(...args: any[]) { if (debugSql) { console.log(`[SQL RUN] ${label}, params: ${JSON.stringify(args)}`); } return stmt.run(...args); }, all(...args: any[]) { if (debugSql) { console.log(`[SQL ALL] ${label}, params: ${JSON.stringify(args)}`); } return stmt.all(...args); }, }; } const stmts = new Map< string, { selectRow: ReturnType; upsertWholeRow: ReturnType; deleteRow: ReturnType; jsonSet: ReturnType; jsonRemove: ReturnType; checkExistence: ReturnType; selectAllIds: ReturnType; } >(); function getStatementsForTable(tableName: string) { if (stmts.has(tableName)) { return stmts.get(tableName)!; } const selectRowSQL = ` SELECT ${jsonColumn} AS jsonData FROM ${tableName} WHERE ${idColumn} = ?`; const upsertWholeRowSQL = ` INSERT OR REPLACE INTO ${tableName} (${idColumn}, ${jsonColumn}) VALUES (?, json(?))`; const deleteRowSQL = ` DELETE FROM ${tableName} WHERE ${idColumn} = ?`; const jsonSetSQL = ` UPDATE ${tableName} SET ${jsonColumn} = json_set(${jsonColumn}, ?, json(?)) WHERE ${idColumn} = ?`; const jsonRemoveSQL = ` UPDATE ${tableName} SET ${jsonColumn} = json_remove(${jsonColumn}, ?) WHERE ${idColumn} = ?`; const checkExistenceSQL = ` SELECT 1 FROM ${tableName} WHERE ${idColumn} = ?`; const selectAllIdsSQL = ` SELECT ${idColumn} AS id FROM ${tableName}`; const prepared = { selectRow: wrapStmt(db.prepare(selectRowSQL), `${tableName}:selectRow`), upsertWholeRow: wrapStmt(db.prepare(upsertWholeRowSQL), `${tableName}:upsertWholeRow`), deleteRow: wrapStmt(db.prepare(deleteRowSQL), `${tableName}:deleteRow`), jsonSet: wrapStmt(db.prepare(jsonSetSQL), `${tableName}:jsonSet`), jsonRemove: wrapStmt(db.prepare(jsonRemoveSQL), `${tableName}:jsonRemove`), checkExistence: wrapStmt(db.prepare(checkExistenceSQL), `${tableName}:checkExistence`), selectAllIds: wrapStmt(db.prepare(selectAllIdsSQL), `${tableName}:selectAllIds`), }; stmts.set(tableName, prepared); return prepared; } //////////////////////////////////////// // C) Helper: load a row's JSON into memory cache if not loaded //////////////////////////////////////// function loadRow(tableName: string, rowId: string) { const cacheForTable = memoryCache.get(tableName)!; if (cacheForTable.has(rowId)) { return; // already in memory } const { selectRow } = getStatementsForTable(tableName); const row = selectRow.get(rowId); if (!row) return; // not found in DB try { cacheForTable.set(rowId, JSON.parse(row.jsonData)); } catch { cacheForTable.set(rowId, null); } } //////////////////////////////////////// // D) JSON path helpers for partial updates //////////////////////////////////////// function pathToJsonPathString(path: string[]) { if (!path.length) return '$'; return '$.' + path.map(escapeJsonKey).join('.'); } function escapeJsonKey(k: string): string { // naive return k.replace(/"/g, '\\"'); } //////////////////////////////////////// // E) Row-level Proxy for partial updates //////////////////////////////////////// function createRowProxy(tableName: string, rowId: string, pathSoFar: string[] = []): any { return new Proxy( {}, { get(_, propKey) { if (typeof propKey === 'symbol') { return Reflect.get(_, propKey); } loadRow(tableName, rowId); const cacheForTable = memoryCache.get(tableName)!; if (!cacheForTable.has(rowId)) { throw new Error(`Row '${rowId}' not found in table '${tableName}' (read).`); } const rowData = cacheForTable.get(rowId); const newPath = [...pathSoFar, propKey.toString()]; let current: any = rowData; for (const p of newPath) { if (current == null || typeof current !== 'object') { return undefined; } current = current[p]; } // If object or array, return deeper proxy so we can do partial updates if (current && typeof current === 'object') { return createRowProxy(tableName, rowId, newPath); } return current; }, set(_, propKey, value) { loadRow(tableName, rowId); const cacheForTable = memoryCache.get(tableName)!; if (!cacheForTable.has(rowId)) { throw new Error(`Row '${rowId}' not found in table '${tableName}' (write).`); } const { jsonSet } = getStatementsForTable(tableName); const newPath = [...pathSoFar, propKey.toString()]; const jsonPath = pathToJsonPathString(newPath); jsonSet.run(jsonPath, JSON.stringify(value), rowId); // Update local cache const rowData = cacheForTable.get(rowId); let cursor: any = rowData; for (let i = 0; i < newPath.length - 1; i++) { const seg = newPath[i]; if (cursor[seg] == null || typeof cursor[seg] !== 'object') { cursor[seg] = {}; } cursor = cursor[seg]; } cursor[newPath[newPath.length - 1]] = value; return true; }, deleteProperty(_, propKey) { loadRow(tableName, rowId); const cacheForTable = memoryCache.get(tableName)!; if (!cacheForTable.has(rowId)) { throw new Error(`Row '${rowId}' not found in table '${tableName}' (delete).`); } // If it looks like a numeric index => forbid const keyString = propKey.toString(); if (/^\d+$/.test(keyString)) { throw new Error( `Deleting array elements by index is not allowed: .${keyString}` ); } const { jsonRemove } = getStatementsForTable(tableName); const newPath = [...pathSoFar, keyString]; const jsonPath = pathToJsonPathString(newPath); jsonRemove.run(jsonPath, rowId); // Update in-memory object const rowData = cacheForTable.get(rowId); let cursor: any = rowData; for (let i = 0; i < newPath.length - 1; i++) { const seg = newPath[i]; if (cursor[seg] == null || typeof cursor[seg] !== 'object') { return true; } cursor = cursor[seg]; } delete cursor[newPath[newPath.length - 1]]; return true; }, has(_, propKey) { if (typeof propKey === 'symbol') { return Reflect.has(_, propKey); } loadRow(tableName, rowId); const cacheForTable = memoryCache.get(tableName)!; if (!cacheForTable.has(rowId)) { return false; } const rowData = cacheForTable.get(rowId); let current = rowData; for (const p of pathSoFar) { if (current == null || typeof current !== 'object') { return false; } current = current[p]; } if (current && typeof current === 'object') { return Object.prototype.hasOwnProperty.call(current, propKey); } return false; }, } ); } //////////////////////////////////////// // F) Create the "Reader" table object //////////////////////////////////////// function createTableReader(tableName: string): TableReader { const { selectAllIds, checkExistence } = getStatementsForTable(tableName); const cacheForTable = memoryCache.get(tableName)!; const readerImplementation = { forEach(callback: (id: string, data: any) => void) { const rows = selectAllIds.all() as Array<{ id: string }>; for (const r of rows) { loadRow(tableName, r.id); const cached = cacheForTable.get(r.id); if (cached !== undefined) { callback(r.id, cached); } } }, keys(): string[] { return selectAllIds.all().map((r: any) => r.id); }, values(): any[] { return selectAllIds.all().map((r: any) => cacheForTable.get(r.id)); }, dict(): Record { return selectAllIds.all().reduce((acc, r: any) => { acc[r.id] = cacheForTable.get(r.id); return acc; }, {} as Record); }, entries(): Array<[string, any]> { return selectAllIds.all().map((r: any) => { loadRow(tableName, r.id); return [r.id, cacheForTable.get(r.id)] as [string, any]; }); }, has(id: string) { if (cacheForTable.has(id)) return true; const row = checkExistence.get(id); return !!row; }, }; return new Proxy(readerImplementation, { get(target, propKey, receiver) { if (typeof propKey === 'symbol') { return Reflect.get(target, propKey, receiver); } if (Reflect.has(target, propKey)) { return Reflect.get(target, propKey, receiver); } // otherwise treat propKey as rowId const rowId = propKey.toString(); loadRow(tableName, rowId); return cacheForTable.get(rowId); }, set() { throw new Error(`Cannot write via Reader API`); }, deleteProperty() { throw new Error(`Cannot delete via Reader API`); }, has(target, propKey) { if (typeof propKey === 'symbol') { return Reflect.has(target, propKey); } if (Reflect.has(target, propKey)) { return true; } const rowId = propKey.toString(); if (cacheForTable.has(rowId)) { return true; } const row = checkExistence.get(rowId); return !!row; }, }) as TableReader; } //////////////////////////////////////// // G) Create the "Writer" table object //////////////////////////////////////// function createTableWriter(tableName: string): TableWriter { const { checkExistence, selectAllIds, upsertWholeRow, deleteRow } = getStatementsForTable(tableName); const cacheForTable = memoryCache.get(tableName)!; const writerImplementation = { forEach(callback: (id: string, rowProxy: any) => void) { const rows = selectAllIds.all() as Array<{ id: string }>; for (const r of rows) { loadRow(tableName, r.id); callback(r.id, createRowProxy(tableName, r.id)); } }, keys(): string[] { return selectAllIds.all().map((r: any) => r.id); }, entries(): Array<[string, any]> { return selectAllIds.all().map((r: any) => { loadRow(tableName, r.id); return [r.id, createRowProxy(tableName, r.id)] as [string, any]; }); }, has(id: string) { if (cacheForTable.has(id)) return true; const row = checkExistence.get(id); return !!row; }, }; return new Proxy(writerImplementation, { get(target, propKey, receiver) { if (typeof propKey === 'symbol') { return Reflect.get(target, propKey, receiver); } if (Reflect.has(target, propKey)) { return Reflect.get(target, propKey, receiver); } const rowId = propKey.toString(); loadRow(tableName, rowId); return createRowProxy(tableName, rowId); }, set(_, rowId, value) { // upsert entire row const idString = rowId.toString(); cacheForTable.set(idString, value); upsertWholeRow.run(idString, JSON.stringify(value)); return true; }, deleteProperty(_, rowId) { const idString = rowId.toString(); cacheForTable.delete(idString); deleteRow.run(idString); return true; }, has(target, propKey) { if (typeof propKey === 'symbol') { return Reflect.has(target, propKey); } if (Reflect.has(target, propKey)) { return true; } const rowId = propKey.toString(); if (cacheForTable.has(rowId)) { return true; } const row = checkExistence.get(rowId); return !!row; }, }) as TableWriter; } //////////////////////////////////////// // H) Build the overall "rdr" and "wtr" objects //////////////////////////////////////// const rdrObj = {} as DBClient['rdr']; const wtrObj = {} as DBClient['wtr']; for (const tableName of Object.keys(schema)) { Object.defineProperty(rdrObj, tableName, { value: createTableReader(tableName), enumerable: true, configurable: false, writable: false, }); Object.defineProperty(wtrObj, tableName, { value: createTableWriter(tableName), enumerable: true, configurable: false, writable: false, }); } return { rdr: rdrObj, wtr: wtrObj, }; }