@korajs/store 1.0.0-beta.1 → 1.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.js +1 -1
- package/dist/adapters/indexeddb.cjs +196 -39
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +1 -1
- package/dist/adapters/indexeddb.d.ts +1 -1
- package/dist/adapters/indexeddb.js +15 -8
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-shared-host.cjs +80 -53
- package/dist/adapters/sqlite-wasm-shared-host.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-shared-host.d.cts +63 -1
- package/dist/adapters/sqlite-wasm-shared-host.d.ts +63 -1
- package/dist/adapters/sqlite-wasm-shared-host.js +56 -52
- package/dist/adapters/sqlite-wasm-shared-host.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs +25 -3
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +25 -3
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +186 -34
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +29 -3
- package/dist/adapters/sqlite-wasm.d.ts +29 -3
- package/dist/adapters/sqlite-wasm.js +3 -3
- package/dist/{backup-ZV4Y424B.js → backup-IQE4QC7Q.js} +2 -2
- package/dist/{chunk-RBPVEAVM.js → chunk-2ZRQGFEF.js} +179 -36
- package/dist/chunk-2ZRQGFEF.js.map +1 -0
- package/dist/{chunk-IOEFNHHP.js → chunk-COJSQBXW.js} +2 -2
- package/dist/{chunk-X57MWW35.js → chunk-OCOGQRHW.js} +12 -9
- package/dist/chunk-OCOGQRHW.js.map +1 -0
- package/dist/{chunk-FFYA7J5H.js → chunk-RCTG3IJG.js} +3 -2
- package/dist/chunk-RCTG3IJG.js.map +1 -0
- package/dist/{chunk-QL2XVZRN.js → chunk-TUR7RGEP.js} +12 -1
- package/dist/{chunk-QL2XVZRN.js.map → chunk-TUR7RGEP.js.map} +1 -1
- package/dist/{chunk-ZLWAYQEQ.js → chunk-UXRWVXBP.js} +32 -28
- package/dist/chunk-UXRWVXBP.js.map +1 -0
- package/dist/{export-audit-AJ7RORNK.js → export-audit-MT2LTMIN.js} +2 -2
- package/dist/index.cjs +167 -141
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +51 -41
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +45 -40
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.js +2 -2
- package/dist/{sqlite-wasm-channel-DUDPclTE.d.cts → sqlite-wasm-channel-CNHRf-m2.d.cts} +1 -1
- package/dist/{sqlite-wasm-channel-DUDPclTE.d.ts → sqlite-wasm-channel-CNHRf-m2.d.ts} +1 -1
- package/dist/sqlite-wasm-channel-NGSBJR44.js +12 -0
- package/package.json +2 -2
- package/dist/chunk-FFYA7J5H.js.map +0 -1
- package/dist/chunk-RBPVEAVM.js.map +0 -1
- package/dist/chunk-X57MWW35.js.map +0 -1
- package/dist/chunk-ZLWAYQEQ.js.map +0 -1
- package/dist/sqlite-wasm-channel-H43KEZLM.js +0 -12
- /package/dist/{backup-ZV4Y424B.js.map → backup-IQE4QC7Q.js.map} +0 -0
- /package/dist/{chunk-IOEFNHHP.js.map → chunk-COJSQBXW.js.map} +0 -0
- /package/dist/{export-audit-AJ7RORNK.js.map → export-audit-MT2LTMIN.js.map} +0 -0
- /package/dist/{sqlite-wasm-channel-H43KEZLM.js.map → sqlite-wasm-channel-NGSBJR44.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/adapters/better-sqlite3.ts","../../src/adapters/better-sqlite3-adapter.ts","../../src/errors.ts","../../src/adapters/mutex.ts"],"sourcesContent":["// Entry point for @korajs/store/better-sqlite3\nexport { BetterSqlite3Adapter } from './better-sqlite3-adapter'\n","import { generateFullDDL } from '@korajs/core'\nimport type { SchemaDefinition } from '@korajs/core'\nimport type Database from 'better-sqlite3'\nimport { AdapterError, StoreNotOpenError } from '../errors'\nimport type { MigrationPlan, StorageAdapter, Transaction } from '../types'\nimport { Mutex } from './mutex'\n\n/**\n * Storage adapter backed by better-sqlite3 for Node.js environments.\n * Used for testing and server-side usage.\n *\n * @example\n * ```typescript\n * import { BetterSqlite3Adapter } from '@korajs/store/better-sqlite3'\n *\n * const adapter = new BetterSqlite3Adapter(':memory:')\n * ```\n */\nexport class BetterSqlite3Adapter implements StorageAdapter {\n\tprivate db: Database.Database | null = null\n\n\t/**\n\t * Serializes transactions. better-sqlite3 is synchronous, but our\n\t * `transaction()` is async and yields the event loop at each `await` inside\n\t * the callback. Without this mutex, a transaction started while another is\n\t * mid-flight (e.g. a relayed remote operation applied during a local write)\n\t * issues a nested `BEGIN`, which SQLite rejects, and the operation is lost.\n\t */\n\tprivate readonly txMutex = new Mutex()\n\n\t/**\n\t * @param path - Database file path, or ':memory:' for in-memory database\n\t */\n\tconstructor(private readonly path: string = ':memory:') {}\n\n\tasync open(schema: SchemaDefinition): Promise<void> {\n\t\t// Dynamic import so better-sqlite3 is only loaded when this adapter is used\n\t\tconst BetterSqlite3 = (await import('better-sqlite3')).default\n\t\tthis.db = new BetterSqlite3(this.path)\n\n\t\t// WAL mode for better concurrent read/write performance\n\t\tthis.db.pragma('journal_mode = WAL')\n\t\t// Enable foreign keys\n\t\tthis.db.pragma('foreign_keys = ON')\n\n\t\tconst statements = generateFullDDL(schema)\n\t\tfor (const sql of statements) {\n\t\t\tif (sql.startsWith('--kora:safe-alter')) {\n\t\t\t\t// Safe ALTER TABLE — ignore \"duplicate column name\" errors for existing columns\n\t\t\t\ttry {\n\t\t\t\t\tthis.db.exec(sql.replace('--kora:safe-alter\\n', ''))\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconst msg = (e as Error).message || ''\n\t\t\t\t\t// Tolerate duplicate columns (already exists) and NOT NULL without defaults\n\t\t\t\t\t// (column may be added or renamed by a migration step instead)\n\t\t\t\t\tif (\n\t\t\t\t\t\t!msg.includes('duplicate column name') &&\n\t\t\t\t\t\t!msg.includes('Cannot add a NOT NULL column with default value NULL')\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow e\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.db.exec(sql)\n\t\t\t}\n\t\t}\n\t}\n\n\tasync close(): Promise<void> {\n\t\tif (this.db) {\n\t\t\tthis.db.close()\n\t\t\tthis.db = null\n\t\t}\n\t}\n\n\tasync execute(sql: string, params?: unknown[]): Promise<void> {\n\t\tconst db = this.getDb()\n\t\ttry {\n\t\t\tdb.prepare(sql).run(...(params ?? []))\n\t\t} catch (error) {\n\t\t\tthrow new AdapterError(`Execute failed: ${(error as Error).message}`, {\n\t\t\t\tsql,\n\t\t\t\tparams,\n\t\t\t})\n\t\t}\n\t}\n\n\tasync query<T>(sql: string, params?: unknown[]): Promise<T[]> {\n\t\tconst db = this.getDb()\n\t\ttry {\n\t\t\treturn db.prepare(sql).all(...(params ?? [])) as T[]\n\t\t} catch (error) {\n\t\t\tthrow new AdapterError(`Query failed: ${(error as Error).message}`, {\n\t\t\t\tsql,\n\t\t\t\tparams,\n\t\t\t})\n\t\t}\n\t}\n\n\tasync transaction(fn: (tx: Transaction) => Promise<void>): Promise<void> {\n\t\tconst db = this.getDb()\n\n\t\t// Serialize with any other in-flight transaction so BEGIN is never nested.\n\t\tconst release = await this.txMutex.acquire()\n\t\ttry {\n\t\t\t// better-sqlite3's transaction() is synchronous, but our interface is async.\n\t\t\t// We use BEGIN/COMMIT/ROLLBACK manually for the async callback.\n\t\t\tdb.exec('BEGIN')\n\t\t\ttry {\n\t\t\t\tconst tx: Transaction = {\n\t\t\t\t\texecute: async (sql: string, params?: unknown[]): Promise<void> => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tdb.prepare(sql).run(...(params ?? []))\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tthrow new AdapterError(`Transaction execute failed: ${(error as Error).message}`, {\n\t\t\t\t\t\t\t\tsql,\n\t\t\t\t\t\t\t\tparams,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tquery: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\treturn db.prepare(sql).all(...(params ?? [])) as T[]\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tthrow new AdapterError(`Transaction query failed: ${(error as Error).message}`, {\n\t\t\t\t\t\t\t\tsql,\n\t\t\t\t\t\t\t\tparams,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tawait fn(tx)\n\t\t\t\tdb.exec('COMMIT')\n\t\t\t} catch (error) {\n\t\t\t\tdb.exec('ROLLBACK')\n\t\t\t\tthrow error\n\t\t\t}\n\t\t} finally {\n\t\t\trelease()\n\t\t}\n\t}\n\n\tasync migrate(from: number, to: number, migration: MigrationPlan): Promise<void> {\n\t\tconst db = this.getDb()\n\t\t// Serialize against transactions so migration's BEGIN is never nested.\n\t\tconst release = await this.txMutex.acquire()\n\t\ttry {\n\t\t\tdb.exec('BEGIN')\n\t\t\ttry {\n\t\t\t\tfor (const sql of migration.statements) {\n\t\t\t\t\tdb.exec(sql)\n\t\t\t\t}\n\t\t\t\tdb.exec('COMMIT')\n\t\t\t} catch (error) {\n\t\t\t\tdb.exec('ROLLBACK')\n\t\t\t\tthrow new AdapterError(\n\t\t\t\t\t`Migration from v${from} to v${to} failed: ${(error as Error).message}`,\n\t\t\t\t\t{\n\t\t\t\t\t\tfrom,\n\t\t\t\t\t\tto,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\t\t} finally {\n\t\t\trelease()\n\t\t}\n\t}\n\n\tprivate getDb(): Database.Database {\n\t\tif (!this.db) {\n\t\t\tthrow new StoreNotOpenError()\n\t\t}\n\t\treturn this.db\n\t}\n}\n","import { KoraError } from '@korajs/core'\n\n/**\n * Thrown when a query is invalid (bad field names, invalid operators, etc.).\n */\nexport class QueryError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(message, 'QUERY_ERROR', context)\n\t\tthis.name = 'QueryError'\n\t}\n}\n\n/**\n * Thrown when a record is not found by ID (findById, update, delete on missing record).\n */\nexport class RecordNotFoundError extends KoraError {\n\tconstructor(collection: string, recordId: string) {\n\t\tsuper(`Record \"${recordId}\" not found in collection \"${collection}\"`, 'RECORD_NOT_FOUND', {\n\t\t\tcollection,\n\t\t\trecordId,\n\t\t})\n\t\tthis.name = 'RecordNotFoundError'\n\t}\n}\n\n/**\n * Thrown inside `applyRemoteOperation` when the row's version state changed\n * between the caller's snapshot read and the guarded write (a concurrent local\n * mutation landed in the window). The transaction is rolled back with nothing\n * written; the caller re-reads fresh state, recomputes its merge, and retries.\n * This is the optimistic-concurrency guard that keeps merge-engine results\n * (richtext, add-wins arrays, constraint resolutions) from clobbering newer\n * local edits they never saw.\n */\nexport class OptimisticLockError extends KoraError {\n\tconstructor(collection: string, recordId: string) {\n\t\tsuper(\n\t\t\t`Row version changed while merging record \"${recordId}\" in \"${collection}\"; retry with fresh state`,\n\t\t\t'OPTIMISTIC_LOCK',\n\t\t\t{ collection, recordId },\n\t\t)\n\t\tthis.name = 'OptimisticLockError'\n\t}\n}\n\n/**\n * Thrown when a storage adapter operation fails.\n */\nexport class AdapterError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(message, 'ADAPTER_ERROR', context)\n\t\tthis.name = 'AdapterError'\n\t}\n}\n\n/**\n * Thrown when an operation is attempted on a store that has not been opened.\n */\nexport class StoreNotOpenError extends KoraError {\n\tconstructor() {\n\t\tsuper('Store is not open. Call store.open() before performing operations.', 'STORE_NOT_OPEN')\n\t\tthis.name = 'StoreNotOpenError'\n\t}\n}\n\n/**\n * Thrown when the Web Worker fails to initialize (WASM load failure, OPFS unavailable, etc.).\n */\nexport class WorkerInitError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(`Worker initialization failed: ${message}`, 'WORKER_INIT_ERROR', context)\n\t\tthis.name = 'WorkerInitError'\n\t}\n}\n\n/**\n * Thrown when the Web Worker does not respond within the configured timeout.\n */\nexport class WorkerTimeoutError extends KoraError {\n\tconstructor(operation: string, timeoutMs: number) {\n\t\tsuper(\n\t\t\t`Worker did not respond within ${timeoutMs}ms for operation \"${operation}\"`,\n\t\t\t'WORKER_TIMEOUT',\n\t\t\t{ operation, timeoutMs },\n\t\t)\n\t\tthis.name = 'WorkerTimeoutError'\n\t}\n}\n\n/**\n * Thrown when IndexedDB persistence operations fail (serialize/deserialize).\n */\nexport class PersistenceError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(`Persistence error: ${message}`, 'PERSISTENCE_ERROR', context)\n\t\tthis.name = 'PersistenceError'\n\t}\n}\n","/**\n * Async mutex for serializing access to a resource across `await` boundaries.\n *\n * This exists because an adapter's `transaction()` is async (its callback may\n * await), but the underlying database transaction (BEGIN…COMMIT) is not\n * reentrant: starting a second transaction while one is open is an error. When\n * a client applies a relayed remote operation while a local write is in flight,\n * the two `transaction()` calls interleave at their `await` points, and without\n * serialization the second `BEGIN` collides with the first, silently dropping\n * the operation. Serializing every transaction through this mutex guarantees at\n * most one is active at a time.\n */\nexport class Mutex {\n\tprivate locked = false\n\tprivate readonly waiters: Array<() => void> = []\n\n\t/**\n\t * Acquire the mutex. Resolves with a release function once the lock is held.\n\t * If the mutex is already held, the caller waits (FIFO) until it is released.\n\t */\n\tasync acquire(): Promise<() => void> {\n\t\tif (!this.locked) {\n\t\t\tthis.locked = true\n\t\t\treturn this.createRelease()\n\t\t}\n\n\t\treturn new Promise<() => void>((resolve) => {\n\t\t\tthis.waiters.push(() => {\n\t\t\t\tresolve(this.createRelease())\n\t\t\t})\n\t\t})\n\t}\n\n\tprivate createRelease(): () => void {\n\t\tlet released = false\n\t\treturn () => {\n\t\t\tif (released) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\treleased = true\n\t\t\tconst next = this.waiters.shift()\n\t\t\tif (next) {\n\t\t\t\tnext()\n\t\t\t} else {\n\t\t\t\tthis.locked = false\n\t\t\t}\n\t\t}\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,IAAAA,eAAgC;;;ACAhC,kBAA0B;AAgDnB,IAAM,eAAN,cAA2B,sBAAU;AAAA,EAC3C,YAAY,SAAiB,SAAmC;AAC/D,UAAM,SAAS,iBAAiB,OAAO;AACvC,SAAK,OAAO;AAAA,EACb;AACD;AAKO,IAAM,oBAAN,cAAgC,sBAAU;AAAA,EAChD,cAAc;AACb,UAAM,sEAAsE,gBAAgB;AAC5F,SAAK,OAAO;AAAA,EACb;AACD;;;ACnDO,IAAM,QAAN,MAAY;AAAA,EACV,SAAS;AAAA,EACA,UAA6B,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,EAM/C,MAAM,UAA+B;AACpC,QAAI,CAAC,KAAK,QAAQ;AACjB,WAAK,SAAS;AACd,aAAO,KAAK,cAAc;AAAA,IAC3B;AAEA,WAAO,IAAI,QAAoB,CAAC,YAAY;AAC3C,WAAK,QAAQ,KAAK,MAAM;AACvB,gBAAQ,KAAK,cAAc,CAAC;AAAA,MAC7B,CAAC;AAAA,IACF,CAAC;AAAA,EACF;AAAA,EAEQ,gBAA4B;AACnC,QAAI,WAAW;AACf,WAAO,MAAM;AACZ,UAAI,UAAU;AACb;AAAA,MACD;AACA,iBAAW;AACX,YAAM,OAAO,KAAK,QAAQ,MAAM;AAChC,UAAI,MAAM;AACT,aAAK;AAAA,MACN,OAAO;AACN,aAAK,SAAS;AAAA,MACf;AAAA,IACD;AAAA,EACD;AACD;;;AF9BO,IAAM,uBAAN,MAAqD;AAAA;AAAA;AAAA;AAAA,EAe3D,YAA6B,OAAe,YAAY;AAA3B;AAAA,EAA4B;AAAA,EAA5B;AAAA,EAdrB,KAA+B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAStB,UAAU,IAAI,MAAM;AAAA,EAOrC,MAAM,KAAK,QAAyC;AAEnD,UAAM,iBAAiB,MAAM,OAAO,gBAAgB,GAAG;AACvD,SAAK,KAAK,IAAI,cAAc,KAAK,IAAI;AAGrC,SAAK,GAAG,OAAO,oBAAoB;AAEnC,SAAK,GAAG,OAAO,mBAAmB;AAElC,UAAM,iBAAa,8BAAgB,MAAM;AACzC,eAAW,OAAO,YAAY;AAC7B,UAAI,IAAI,WAAW,mBAAmB,GAAG;AAExC,YAAI;AACH,eAAK,GAAG,KAAK,IAAI,QAAQ,uBAAuB,EAAE,CAAC;AAAA,QACpD,SAAS,GAAG;AACX,gBAAM,MAAO,EAAY,WAAW;AAGpC,cACC,CAAC,IAAI,SAAS,uBAAuB,KACrC,CAAC,IAAI,SAAS,sDAAsD,GACnE;AACD,kBAAM;AAAA,UACP;AAAA,QACD;AAAA,MACD,OAAO;AACN,aAAK,GAAG,KAAK,GAAG;AAAA,MACjB;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAM,QAAuB;AAC5B,QAAI,KAAK,IAAI;AACZ,WAAK,GAAG,MAAM;AACd,WAAK,KAAK;AAAA,IACX;AAAA,EACD;AAAA,EAEA,MAAM,QAAQ,KAAa,QAAmC;AAC7D,UAAM,KAAK,KAAK,MAAM;AACtB,QAAI;AACH,SAAG,QAAQ,GAAG,EAAE,IAAI,GAAI,UAAU,CAAC,CAAE;AAAA,IACtC,SAAS,OAAO;AACf,YAAM,IAAI,aAAa,mBAAoB,MAAgB,OAAO,IAAI;AAAA,QACrE;AAAA,QACA;AAAA,MACD,CAAC;AAAA,IACF;AAAA,EACD;AAAA,EAEA,MAAM,MAAS,KAAa,QAAkC;AAC7D,UAAM,KAAK,KAAK,MAAM;AACtB,QAAI;AACH,aAAO,GAAG,QAAQ,GAAG,EAAE,IAAI,GAAI,UAAU,CAAC,CAAE;AAAA,IAC7C,SAAS,OAAO;AACf,YAAM,IAAI,aAAa,iBAAkB,MAAgB,OAAO,IAAI;AAAA,QACnE;AAAA,QACA;AAAA,MACD,CAAC;AAAA,IACF;AAAA,EACD;AAAA,EAEA,MAAM,YAAY,IAAuD;AACxE,UAAM,KAAK,KAAK,MAAM;AAGtB,UAAM,UAAU,MAAM,KAAK,QAAQ,QAAQ;AAC3C,QAAI;AAGH,SAAG,KAAK,OAAO;AACf,UAAI;AACH,cAAM,KAAkB;AAAA,UACvB,SAAS,OAAO,KAAa,WAAsC;AAClE,gBAAI;AACH,iBAAG,QAAQ,GAAG,EAAE,IAAI,GAAI,UAAU,CAAC,CAAE;AAAA,YACtC,SAAS,OAAO;AACf,oBAAM,IAAI,aAAa,+BAAgC,MAAgB,OAAO,IAAI;AAAA,gBACjF;AAAA,gBACA;AAAA,cACD,CAAC;AAAA,YACF;AAAA,UACD;AAAA,UACA,OAAO,OAAU,KAAa,WAAqC;AAClE,gBAAI;AACH,qBAAO,GAAG,QAAQ,GAAG,EAAE,IAAI,GAAI,UAAU,CAAC,CAAE;AAAA,YAC7C,SAAS,OAAO;AACf,oBAAM,IAAI,aAAa,6BAA8B,MAAgB,OAAO,IAAI;AAAA,gBAC/E;AAAA,gBACA;AAAA,cACD,CAAC;AAAA,YACF;AAAA,UACD;AAAA,QACD;AACA,cAAM,GAAG,EAAE;AACX,WAAG,KAAK,QAAQ;AAAA,MACjB,SAAS,OAAO;AACf,WAAG,KAAK,UAAU;AAClB,cAAM;AAAA,MACP;AAAA,IACD,UAAE;AACD,cAAQ;AAAA,IACT;AAAA,EACD;AAAA,EAEA,MAAM,QAAQ,MAAc,IAAY,WAAyC;AAChF,UAAM,KAAK,KAAK,MAAM;AAEtB,UAAM,UAAU,MAAM,KAAK,QAAQ,QAAQ;AAC3C,QAAI;AACH,SAAG,KAAK,OAAO;AACf,UAAI;AACH,mBAAW,OAAO,UAAU,YAAY;AACvC,aAAG,KAAK,GAAG;AAAA,QACZ;AACA,WAAG,KAAK,QAAQ;AAAA,MACjB,SAAS,OAAO;AACf,WAAG,KAAK,UAAU;AAClB,cAAM,IAAI;AAAA,UACT,mBAAmB,IAAI,QAAQ,EAAE,YAAa,MAAgB,OAAO;AAAA,UACrE;AAAA,YACC;AAAA,YACA;AAAA,UACD;AAAA,QACD;AAAA,MACD;AAAA,IACD,UAAE;AACD,cAAQ;AAAA,IACT;AAAA,EACD;AAAA,EAEQ,QAA2B;AAClC,QAAI,CAAC,KAAK,IAAI;AACb,YAAM,IAAI,kBAAkB;AAAA,IAC7B;AACA,WAAO,KAAK;AAAA,EACb;AACD;","names":["import_core"]}
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/better-sqlite3.ts","../../src/adapters/better-sqlite3-adapter.ts","../../src/errors.ts","../../src/adapters/mutex.ts"],"sourcesContent":["// Entry point for @korajs/store/better-sqlite3\nexport { BetterSqlite3Adapter } from './better-sqlite3-adapter'\n","import { generateFullDDL } from '@korajs/core'\nimport type { SchemaDefinition } from '@korajs/core'\nimport type Database from 'better-sqlite3'\nimport { AdapterError, StoreNotOpenError } from '../errors'\nimport type { MigrationPlan, StorageAdapter, Transaction } from '../types'\nimport { Mutex } from './mutex'\n\n/**\n * Storage adapter backed by better-sqlite3 for Node.js environments.\n * Used for testing and server-side usage.\n *\n * @example\n * ```typescript\n * import { BetterSqlite3Adapter } from '@korajs/store/better-sqlite3'\n *\n * const adapter = new BetterSqlite3Adapter(':memory:')\n * ```\n */\nexport class BetterSqlite3Adapter implements StorageAdapter {\n\tprivate db: Database.Database | null = null\n\n\t/**\n\t * Serializes transactions. better-sqlite3 is synchronous, but our\n\t * `transaction()` is async and yields the event loop at each `await` inside\n\t * the callback. Without this mutex, a transaction started while another is\n\t * mid-flight (e.g. a relayed remote operation applied during a local write)\n\t * issues a nested `BEGIN`, which SQLite rejects, and the operation is lost.\n\t */\n\tprivate readonly txMutex = new Mutex()\n\n\t/**\n\t * @param path - Database file path, or ':memory:' for in-memory database\n\t */\n\tconstructor(private readonly path: string = ':memory:') {}\n\n\tasync open(schema: SchemaDefinition): Promise<void> {\n\t\t// Dynamic import so better-sqlite3 is only loaded when this adapter is used\n\t\tconst BetterSqlite3 = (await import('better-sqlite3')).default\n\t\tthis.db = new BetterSqlite3(this.path)\n\n\t\t// WAL mode for better concurrent read/write performance\n\t\tthis.db.pragma('journal_mode = WAL')\n\t\t// Enable foreign keys\n\t\tthis.db.pragma('foreign_keys = ON')\n\n\t\tconst statements = generateFullDDL(schema)\n\t\tfor (const sql of statements) {\n\t\t\tif (sql.startsWith('--kora:safe-alter')) {\n\t\t\t\t// Safe ALTER TABLE — ignore \"duplicate column name\" errors for existing columns\n\t\t\t\ttry {\n\t\t\t\t\tthis.db.exec(sql.replace('--kora:safe-alter\\n', ''))\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconst msg = (e as Error).message || ''\n\t\t\t\t\t// Tolerate duplicate columns (already exists) and NOT NULL without defaults\n\t\t\t\t\t// (column may be added or renamed by a migration step instead)\n\t\t\t\t\tif (\n\t\t\t\t\t\t!msg.includes('duplicate column name') &&\n\t\t\t\t\t\t!msg.includes('Cannot add a NOT NULL column with default value NULL')\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow e\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.db.exec(sql)\n\t\t\t}\n\t\t}\n\t}\n\n\tasync close(): Promise<void> {\n\t\tif (this.db) {\n\t\t\tthis.db.close()\n\t\t\tthis.db = null\n\t\t}\n\t}\n\n\tasync execute(sql: string, params?: unknown[]): Promise<void> {\n\t\tconst db = this.getDb()\n\t\ttry {\n\t\t\tdb.prepare(sql).run(...(params ?? []))\n\t\t} catch (error) {\n\t\t\tthrow new AdapterError(`Execute failed: ${(error as Error).message}`, {\n\t\t\t\tsql,\n\t\t\t\tparams,\n\t\t\t})\n\t\t}\n\t}\n\n\tasync query<T>(sql: string, params?: unknown[]): Promise<T[]> {\n\t\tconst db = this.getDb()\n\t\ttry {\n\t\t\treturn db.prepare(sql).all(...(params ?? [])) as T[]\n\t\t} catch (error) {\n\t\t\tthrow new AdapterError(`Query failed: ${(error as Error).message}`, {\n\t\t\t\tsql,\n\t\t\t\tparams,\n\t\t\t})\n\t\t}\n\t}\n\n\tasync transaction(fn: (tx: Transaction) => Promise<void>): Promise<void> {\n\t\tconst db = this.getDb()\n\n\t\t// Serialize with any other in-flight transaction so BEGIN is never nested.\n\t\tconst release = await this.txMutex.acquire()\n\t\ttry {\n\t\t\t// better-sqlite3's transaction() is synchronous, but our interface is async.\n\t\t\t// We use BEGIN/COMMIT/ROLLBACK manually for the async callback.\n\t\t\tdb.exec('BEGIN')\n\t\t\ttry {\n\t\t\t\tconst tx: Transaction = {\n\t\t\t\t\texecute: async (sql: string, params?: unknown[]): Promise<void> => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tdb.prepare(sql).run(...(params ?? []))\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tthrow new AdapterError(`Transaction execute failed: ${(error as Error).message}`, {\n\t\t\t\t\t\t\t\tsql,\n\t\t\t\t\t\t\t\tparams,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tquery: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\treturn db.prepare(sql).all(...(params ?? [])) as T[]\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tthrow new AdapterError(`Transaction query failed: ${(error as Error).message}`, {\n\t\t\t\t\t\t\t\tsql,\n\t\t\t\t\t\t\t\tparams,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tawait fn(tx)\n\t\t\t\tdb.exec('COMMIT')\n\t\t\t} catch (error) {\n\t\t\t\tdb.exec('ROLLBACK')\n\t\t\t\tthrow error\n\t\t\t}\n\t\t} finally {\n\t\t\trelease()\n\t\t}\n\t}\n\n\tasync migrate(from: number, to: number, migration: MigrationPlan): Promise<void> {\n\t\tconst db = this.getDb()\n\t\t// Serialize against transactions so migration's BEGIN is never nested.\n\t\tconst release = await this.txMutex.acquire()\n\t\ttry {\n\t\t\tdb.exec('BEGIN')\n\t\t\ttry {\n\t\t\t\tfor (const sql of migration.statements) {\n\t\t\t\t\tdb.exec(sql)\n\t\t\t\t}\n\t\t\t\tdb.exec('COMMIT')\n\t\t\t} catch (error) {\n\t\t\t\tdb.exec('ROLLBACK')\n\t\t\t\tthrow new AdapterError(\n\t\t\t\t\t`Migration from v${from} to v${to} failed: ${(error as Error).message}`,\n\t\t\t\t\t{\n\t\t\t\t\t\tfrom,\n\t\t\t\t\t\tto,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\t\t} finally {\n\t\t\trelease()\n\t\t}\n\t}\n\n\tprivate getDb(): Database.Database {\n\t\tif (!this.db) {\n\t\t\tthrow new StoreNotOpenError()\n\t\t}\n\t\treturn this.db\n\t}\n}\n","import { KoraError } from '@korajs/core'\n\n/**\n * Thrown when a query is invalid (bad field names, invalid operators, etc.).\n */\nexport class QueryError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(message, 'QUERY_ERROR', context)\n\t\tthis.name = 'QueryError'\n\t}\n}\n\n/**\n * Thrown when a record is not found by ID (findById, update, delete on missing record).\n */\nexport class RecordNotFoundError extends KoraError {\n\tconstructor(collection: string, recordId: string) {\n\t\tsuper(`Record \"${recordId}\" not found in collection \"${collection}\"`, 'RECORD_NOT_FOUND', {\n\t\t\tcollection,\n\t\t\trecordId,\n\t\t})\n\t\tthis.name = 'RecordNotFoundError'\n\t}\n}\n\n/**\n * Thrown inside `applyRemoteOperation` when the row's version state changed\n * between the caller's snapshot read and the guarded write (a concurrent local\n * mutation landed in the window). The transaction is rolled back with nothing\n * written; the caller re-reads fresh state, recomputes its merge, and retries.\n * This is the optimistic-concurrency guard that keeps merge-engine results\n * (richtext, add-wins arrays, constraint resolutions) from clobbering newer\n * local edits they never saw.\n */\nexport class OptimisticLockError extends KoraError {\n\tconstructor(collection: string, recordId: string) {\n\t\tsuper(\n\t\t\t`Row version changed while merging record \"${recordId}\" in \"${collection}\"; retry with fresh state`,\n\t\t\t'OPTIMISTIC_LOCK',\n\t\t\t{ collection, recordId },\n\t\t)\n\t\tthis.name = 'OptimisticLockError'\n\t}\n}\n\n/**\n * Thrown when a storage adapter operation fails.\n */\nexport class AdapterError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(message, 'ADAPTER_ERROR', context)\n\t\tthis.name = 'AdapterError'\n\t}\n}\n\n/**\n * Thrown when an operation is attempted on a store that has not been opened.\n */\nexport class StoreNotOpenError extends KoraError {\n\tconstructor() {\n\t\tsuper('Store is not open. Call store.open() before performing operations.', 'STORE_NOT_OPEN')\n\t\tthis.name = 'StoreNotOpenError'\n\t}\n}\n\n/**\n * Thrown when the Web Worker fails to initialize (WASM load failure, OPFS unavailable, etc.).\n */\nexport class WorkerInitError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(`Worker initialization failed: ${message}`, 'WORKER_INIT_ERROR', context)\n\t\tthis.name = 'WorkerInitError'\n\t}\n}\n\n/**\n * Thrown when the Web Worker does not respond within the configured timeout.\n */\nexport class WorkerTimeoutError extends KoraError {\n\tconstructor(operation: string, timeoutMs: number) {\n\t\tsuper(\n\t\t\t`Worker did not respond within ${timeoutMs}ms for operation \"${operation}\"`,\n\t\t\t'WORKER_TIMEOUT',\n\t\t\t{ operation, timeoutMs },\n\t\t)\n\t\tthis.name = 'WorkerTimeoutError'\n\t}\n}\n\n/**\n * Thrown when a follower tab cannot reach a live leader tab over the multi-tab\n * broadcast channel (the leader tab was closed, crashed, or is unresponsive).\n * Distinct from {@link WorkerTimeoutError}: this fails fast on a confirmed-absent\n * leader instead of waiting out the full RPC timeout.\n */\nexport class NoLeaderError extends KoraError {\n\tconstructor(operation: string) {\n\t\tsuper(\n\t\t\t`No live leader tab is answering multi-tab storage RPC for operation \"${operation}\"`,\n\t\t\t'NO_LEADER',\n\t\t\t{ operation },\n\t\t)\n\t\tthis.name = 'NoLeaderError'\n\t}\n}\n\n/**\n * Thrown when IndexedDB persistence operations fail (serialize/deserialize).\n */\nexport class PersistenceError extends KoraError {\n\tconstructor(message: string, context?: Record<string, unknown>) {\n\t\tsuper(`Persistence error: ${message}`, 'PERSISTENCE_ERROR', context)\n\t\tthis.name = 'PersistenceError'\n\t}\n}\n","/**\n * Async mutex for serializing access to a resource across `await` boundaries.\n *\n * This exists because an adapter's `transaction()` is async (its callback may\n * await), but the underlying database transaction (BEGIN…COMMIT) is not\n * reentrant: starting a second transaction while one is open is an error. When\n * a client applies a relayed remote operation while a local write is in flight,\n * the two `transaction()` calls interleave at their `await` points, and without\n * serialization the second `BEGIN` collides with the first, silently dropping\n * the operation. Serializing every transaction through this mutex guarantees at\n * most one is active at a time.\n */\nexport class Mutex {\n\tprivate locked = false\n\tprivate readonly waiters: Array<() => void> = []\n\n\t/**\n\t * Acquire the mutex. Resolves with a release function once the lock is held.\n\t * If the mutex is already held, the caller waits (FIFO) until it is released.\n\t */\n\tasync acquire(): Promise<() => void> {\n\t\tif (!this.locked) {\n\t\t\tthis.locked = true\n\t\t\treturn this.createRelease()\n\t\t}\n\n\t\treturn new Promise<() => void>((resolve) => {\n\t\t\tthis.waiters.push(() => {\n\t\t\t\tresolve(this.createRelease())\n\t\t\t})\n\t\t})\n\t}\n\n\tprivate createRelease(): () => void {\n\t\tlet released = false\n\t\treturn () => {\n\t\t\tif (released) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\treleased = true\n\t\t\tconst next = this.waiters.shift()\n\t\t\tif (next) {\n\t\t\t\tnext()\n\t\t\t} else {\n\t\t\t\tthis.locked = false\n\t\t\t}\n\t\t}\n\t}\n}\n"],"mappings":"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","names":["import_core"]}
|
|
@@ -21,7 +21,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
21
21
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
22
|
|
|
23
23
|
// src/errors.ts
|
|
24
|
-
var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, PersistenceError;
|
|
24
|
+
var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError, PersistenceError;
|
|
25
25
|
var init_errors = __esm({
|
|
26
26
|
"src/errors.ts"() {
|
|
27
27
|
"use strict";
|
|
@@ -48,6 +48,16 @@ var init_errors = __esm({
|
|
|
48
48
|
this.name = "WorkerTimeoutError";
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
|
+
NoLeaderError = class extends import_core.KoraError {
|
|
52
|
+
constructor(operation) {
|
|
53
|
+
super(
|
|
54
|
+
`No live leader tab is answering multi-tab storage RPC for operation "${operation}"`,
|
|
55
|
+
"NO_LEADER",
|
|
56
|
+
{ operation }
|
|
57
|
+
);
|
|
58
|
+
this.name = "NoLeaderError";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
51
61
|
PersistenceError = class extends import_core.KoraError {
|
|
52
62
|
constructor(message, context) {
|
|
53
63
|
super(`Persistence error: ${message}`, "PERSISTENCE_ERROR", context);
|
|
@@ -190,6 +200,7 @@ __export(indexeddb_exports, {
|
|
|
190
200
|
module.exports = __toCommonJS(indexeddb_exports);
|
|
191
201
|
|
|
192
202
|
// src/adapters/indexeddb-adapter.ts
|
|
203
|
+
var import_core3 = require("@korajs/core");
|
|
193
204
|
init_errors();
|
|
194
205
|
|
|
195
206
|
// src/adapters/indexeddb-persistence-scheduler.ts
|
|
@@ -352,45 +363,73 @@ var SharedWorkerClientBridge = class {
|
|
|
352
363
|
init_errors();
|
|
353
364
|
var RPC_REQUEST = "kora-worker-request";
|
|
354
365
|
var RPC_RESPONSE = "kora-worker-response";
|
|
366
|
+
var LEADER_PING = "kora-leader-ping";
|
|
367
|
+
var LEADER_PONG = "kora-leader-pong";
|
|
368
|
+
var DEFAULT_LIVENESS_PROBE_MS = 2e3;
|
|
355
369
|
function isSharedWorkerStorageSupported() {
|
|
356
370
|
return typeof globalThis !== "undefined" && typeof SharedWorker !== "undefined";
|
|
357
371
|
}
|
|
358
372
|
function isMultiTabStorageSupported() {
|
|
359
373
|
return typeof globalThis !== "undefined" && typeof BroadcastChannel !== "undefined" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
|
|
360
374
|
}
|
|
361
|
-
async function acquireTabStorageSession(dbName) {
|
|
375
|
+
async function acquireTabStorageSession(dbName, options) {
|
|
362
376
|
const channelName = `kora-storage-${dbName}`;
|
|
377
|
+
const lockName = `kora-leader-${dbName}`;
|
|
363
378
|
if (!isMultiTabStorageSupported()) {
|
|
364
379
|
return { role: "leader", channelName };
|
|
365
380
|
}
|
|
366
381
|
return new Promise((resolve) => {
|
|
367
382
|
let releaseHeld;
|
|
368
|
-
void navigator.locks.request(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (lock === null) {
|
|
373
|
-
resolve({ role: "follower", channelName });
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
resolve({
|
|
377
|
-
role: "leader",
|
|
378
|
-
channelName,
|
|
379
|
-
releaseLock: async () => {
|
|
380
|
-
releaseHeld?.();
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
return new Promise((release) => {
|
|
384
|
-
releaseHeld = release;
|
|
385
|
-
});
|
|
383
|
+
void navigator.locks.request(lockName, { mode: "exclusive", ifAvailable: true }, (lock) => {
|
|
384
|
+
if (lock === null) {
|
|
385
|
+
resolve(startFollowerSession(channelName, lockName, options));
|
|
386
|
+
return;
|
|
386
387
|
}
|
|
387
|
-
|
|
388
|
+
resolve({
|
|
389
|
+
role: "leader",
|
|
390
|
+
channelName,
|
|
391
|
+
releaseLock: async () => {
|
|
392
|
+
releaseHeld?.();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
return new Promise((release) => {
|
|
396
|
+
releaseHeld = release;
|
|
397
|
+
});
|
|
398
|
+
});
|
|
388
399
|
});
|
|
389
400
|
}
|
|
401
|
+
function startFollowerSession(channelName, lockName, options) {
|
|
402
|
+
let promoted = false;
|
|
403
|
+
let releasePromotedLock;
|
|
404
|
+
const abort = new AbortController();
|
|
405
|
+
void navigator.locks.request(lockName, { mode: "exclusive", signal: abort.signal }, () => {
|
|
406
|
+
promoted = true;
|
|
407
|
+
options?.onPromote?.();
|
|
408
|
+
return new Promise((release) => {
|
|
409
|
+
releasePromotedLock = release;
|
|
410
|
+
});
|
|
411
|
+
}).catch(() => {
|
|
412
|
+
});
|
|
413
|
+
return {
|
|
414
|
+
role: "follower",
|
|
415
|
+
channelName,
|
|
416
|
+
cancelPromotionWatch: () => {
|
|
417
|
+
if (promoted) {
|
|
418
|
+
releasePromotedLock?.();
|
|
419
|
+
} else {
|
|
420
|
+
abort.abort();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
390
425
|
function startLeaderRpcRelay(channelName, bridge) {
|
|
391
426
|
const channel = new BroadcastChannel(channelName);
|
|
392
427
|
const onMessage = (event) => {
|
|
393
428
|
const data = event.data;
|
|
429
|
+
if (data?.type === LEADER_PING) {
|
|
430
|
+
channel.postMessage({ type: LEADER_PONG });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
394
433
|
if (data?.type !== RPC_REQUEST) {
|
|
395
434
|
return;
|
|
396
435
|
}
|
|
@@ -426,9 +465,11 @@ var FollowerBroadcastBridge = class {
|
|
|
426
465
|
channel;
|
|
427
466
|
pending = /* @__PURE__ */ new Map();
|
|
428
467
|
timeoutMs;
|
|
468
|
+
livenessProbeMs;
|
|
429
469
|
terminated = false;
|
|
430
|
-
constructor(channelName, timeoutMs = 3e4) {
|
|
470
|
+
constructor(channelName, timeoutMs = 3e4, livenessProbeMs = DEFAULT_LIVENESS_PROBE_MS) {
|
|
431
471
|
this.timeoutMs = timeoutMs;
|
|
472
|
+
this.livenessProbeMs = Math.min(livenessProbeMs, timeoutMs);
|
|
432
473
|
this.channel = new BroadcastChannel(channelName);
|
|
433
474
|
this.channel.addEventListener("message", (event) => {
|
|
434
475
|
const data = event.data;
|
|
@@ -442,6 +483,48 @@ var FollowerBroadcastBridge = class {
|
|
|
442
483
|
}
|
|
443
484
|
});
|
|
444
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Readiness handshake: resolves `true` as soon as a live leader answers a ping,
|
|
488
|
+
* or `false` if none answers within the budget. The adapter calls this before
|
|
489
|
+
* its first RPC so a follower created before a leader relay is live retries the
|
|
490
|
+
* handshake instead of firing into the void and waiting out the full timeout.
|
|
491
|
+
*/
|
|
492
|
+
async waitForLeader(timeoutMs = 3e3, attempts = 3) {
|
|
493
|
+
const perAttempt = Math.max(50, Math.floor(timeoutMs / Math.max(1, attempts)));
|
|
494
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
495
|
+
if (this.terminated) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
if (await this.pingLeader(perAttempt)) {
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
/** Sends one liveness ping and resolves whether a leader answered in time. */
|
|
505
|
+
pingLeader(timeoutMs) {
|
|
506
|
+
if (this.terminated) {
|
|
507
|
+
return Promise.resolve(false);
|
|
508
|
+
}
|
|
509
|
+
return new Promise((resolve) => {
|
|
510
|
+
const onPong = (event) => {
|
|
511
|
+
if (event.data?.type === LEADER_PONG) {
|
|
512
|
+
cleanup();
|
|
513
|
+
resolve(true);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const cleanup = () => {
|
|
517
|
+
clearTimeout(timer);
|
|
518
|
+
this.channel.removeEventListener("message", onPong);
|
|
519
|
+
};
|
|
520
|
+
const timer = setTimeout(() => {
|
|
521
|
+
cleanup();
|
|
522
|
+
resolve(false);
|
|
523
|
+
}, timeoutMs);
|
|
524
|
+
this.channel.addEventListener("message", onPong);
|
|
525
|
+
this.channel.postMessage({ type: LEADER_PING });
|
|
526
|
+
});
|
|
527
|
+
}
|
|
445
528
|
async send(request) {
|
|
446
529
|
if (this.terminated) {
|
|
447
530
|
return {
|
|
@@ -454,19 +537,25 @@ var FollowerBroadcastBridge = class {
|
|
|
454
537
|
const requestId = crypto.randomUUID();
|
|
455
538
|
const msg = { type: RPC_REQUEST, requestId, request };
|
|
456
539
|
return new Promise((resolve, reject) => {
|
|
457
|
-
const
|
|
540
|
+
const settle = (fn) => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
clearTimeout(probe);
|
|
458
543
|
this.pending.delete(requestId);
|
|
459
|
-
|
|
544
|
+
fn();
|
|
545
|
+
};
|
|
546
|
+
const timer = setTimeout(() => {
|
|
547
|
+
settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs)));
|
|
460
548
|
}, this.timeoutMs);
|
|
549
|
+
const probe = setTimeout(() => {
|
|
550
|
+
void this.pingLeader(this.livenessProbeMs).then((alive) => {
|
|
551
|
+
if (!alive && this.pending.has(requestId)) {
|
|
552
|
+
settle(() => reject(new NoLeaderError(`follower-rpc:${request.type}`)));
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
}, this.livenessProbeMs);
|
|
461
556
|
this.pending.set(requestId, {
|
|
462
|
-
resolve: (response) =>
|
|
463
|
-
|
|
464
|
-
resolve(response);
|
|
465
|
-
},
|
|
466
|
-
reject: (error) => {
|
|
467
|
-
clearTimeout(timer);
|
|
468
|
-
reject(error);
|
|
469
|
-
}
|
|
557
|
+
resolve: (response) => settle(() => resolve(response)),
|
|
558
|
+
reject: (error) => settle(() => reject(error))
|
|
470
559
|
});
|
|
471
560
|
this.channel.postMessage(msg);
|
|
472
561
|
});
|
|
@@ -495,14 +584,19 @@ var SqliteWasmAdapter = class {
|
|
|
495
584
|
sharedWorkerUrl;
|
|
496
585
|
workerResponseTimeoutMs;
|
|
497
586
|
dbName;
|
|
587
|
+
emitter;
|
|
498
588
|
tabSession = null;
|
|
499
589
|
sharedWorker = null;
|
|
590
|
+
/** Retained so a follower promoted to leader can re-open its own worker. */
|
|
591
|
+
schema = null;
|
|
592
|
+
promoting = false;
|
|
500
593
|
constructor(options = {}) {
|
|
501
594
|
this.injectedBridge = options.bridge;
|
|
502
595
|
this.workerUrl = options.workerUrl;
|
|
503
596
|
this.sharedWorkerUrl = options.sharedWorkerUrl;
|
|
504
597
|
this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
|
|
505
598
|
this.dbName = options.dbName ?? "kora-db";
|
|
599
|
+
this.emitter = options.emitter;
|
|
506
600
|
}
|
|
507
601
|
async open(schema) {
|
|
508
602
|
if (this.opened) return;
|
|
@@ -514,17 +608,29 @@ var SqliteWasmAdapter = class {
|
|
|
514
608
|
this.sharedWorker = new SharedWorker(sharedUrl, { name: `kora-sw-${this.dbName}` });
|
|
515
609
|
this.bridge = new SharedWorkerClientBridge(this.sharedWorker, this.dbName, workerUrlString);
|
|
516
610
|
} else if (this.workerUrl) {
|
|
517
|
-
this.
|
|
611
|
+
this.schema = schema;
|
|
612
|
+
this.tabSession = await acquireTabStorageSession(this.dbName, {
|
|
613
|
+
onPromote: () => {
|
|
614
|
+
void this.promoteToLeader();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
518
617
|
const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
|
|
519
618
|
if (this.tabSession.role === "leader") {
|
|
520
619
|
const workerBridge = new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs);
|
|
521
620
|
this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
|
|
522
621
|
this.bridge = workerBridge;
|
|
523
622
|
} else {
|
|
524
|
-
|
|
623
|
+
const followerBridge = new FollowerBroadcastBridge(
|
|
525
624
|
this.tabSession.channelName,
|
|
526
625
|
this.workerResponseTimeoutMs
|
|
527
626
|
);
|
|
627
|
+
this.bridge = followerBridge;
|
|
628
|
+
this.emitter?.emit({
|
|
629
|
+
type: "store:db-name-collision",
|
|
630
|
+
dbName: this.dbName,
|
|
631
|
+
message: `Another runtime on this origin is already using database "${this.dbName}"; this runtime is sharing it as a follower. If these are separate apps, give each a distinct store name.`
|
|
632
|
+
});
|
|
633
|
+
await followerBridge.waitForLeader();
|
|
528
634
|
}
|
|
529
635
|
} else {
|
|
530
636
|
throw new AdapterError(
|
|
@@ -544,14 +650,37 @@ var SqliteWasmAdapter = class {
|
|
|
544
650
|
dbName: this.dbName
|
|
545
651
|
});
|
|
546
652
|
}
|
|
653
|
+
this.reportStorageMode(response.data);
|
|
547
654
|
this.opened = true;
|
|
548
655
|
}
|
|
656
|
+
/**
|
|
657
|
+
* Surface a `store:opfs-unavailable` diagnostic when the worker reported that
|
|
658
|
+
* persistence silently degraded to an in-memory database, so a data-loss
|
|
659
|
+
* condition is observable instead of failing silently. A persistent open (or a
|
|
660
|
+
* bridge that does not report a mode, e.g. the Node mock) emits nothing.
|
|
661
|
+
*/
|
|
662
|
+
reportStorageMode(data) {
|
|
663
|
+
if (!this.emitter || typeof data !== "object" || data === null) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const mode = data;
|
|
667
|
+
if (mode.persistent === false) {
|
|
668
|
+
const reason = mode.fallbackReason ?? "unsupported";
|
|
669
|
+
this.emitter.emit({
|
|
670
|
+
type: "store:opfs-unavailable",
|
|
671
|
+
dbName: this.dbName,
|
|
672
|
+
reason,
|
|
673
|
+
message: `OPFS persistence is unavailable (${reason}) for database "${this.dbName}"; the store is running in memory and data will not survive a reload.`
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
549
677
|
async close() {
|
|
550
678
|
if (!this.bridge) return;
|
|
551
679
|
try {
|
|
552
680
|
await this.sendRequest({ id: 0, type: "close" });
|
|
553
681
|
} finally {
|
|
554
682
|
this.tabSession?.stopRelay?.();
|
|
683
|
+
this.tabSession?.cancelPromotionWatch?.();
|
|
555
684
|
if (this.tabSession?.releaseLock) {
|
|
556
685
|
await this.tabSession.releaseLock();
|
|
557
686
|
}
|
|
@@ -562,6 +691,30 @@ var SqliteWasmAdapter = class {
|
|
|
562
691
|
this.opened = false;
|
|
563
692
|
}
|
|
564
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* Promotes a follower to leader after the previous leader released the storage
|
|
696
|
+
* lock (its tab closed or crashed). The lock is only granted once the old leader
|
|
697
|
+
* is gone, so opening our own worker here is safe from the OPFS single-writer
|
|
698
|
+
* rule. In-flight follower RPCs are rejected and left to the caller / reactive
|
|
699
|
+
* layer to retry against the new leader.
|
|
700
|
+
*/
|
|
701
|
+
async promoteToLeader() {
|
|
702
|
+
if (this.promoting || !this.opened || this.workerUrl === void 0 || this.schema === null) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
this.promoting = true;
|
|
706
|
+
const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
|
|
707
|
+
const workerBridge = new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs);
|
|
708
|
+
const previousBridge = this.bridge;
|
|
709
|
+
this.bridge = workerBridge;
|
|
710
|
+
if (this.tabSession) {
|
|
711
|
+
this.tabSession.role = "leader";
|
|
712
|
+
this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
|
|
713
|
+
}
|
|
714
|
+
previousBridge?.terminate();
|
|
715
|
+
const ddlStatements = (0, import_core2.generateFullDDL)(this.schema);
|
|
716
|
+
await this.sendRequest({ id: 0, type: "open", ddlStatements, dbName: this.dbName });
|
|
717
|
+
}
|
|
565
718
|
async execute(sql, params) {
|
|
566
719
|
this.guardOpen();
|
|
567
720
|
const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
|
|
@@ -904,7 +1057,7 @@ var IndexedDbAdapter = class {
|
|
|
904
1057
|
if (!dump) return;
|
|
905
1058
|
for (const table of dump.tables) {
|
|
906
1059
|
const name = ensureSafeIdentifier(table.name);
|
|
907
|
-
await this.inner.execute(`DELETE FROM ${name}`);
|
|
1060
|
+
await this.inner.execute(`DELETE FROM ${(0, import_core3.quoteIdent)(name)}`);
|
|
908
1061
|
if (table.rows.length === 0) continue;
|
|
909
1062
|
for (const row of table.rows) {
|
|
910
1063
|
const columns = table.columns.filter(
|
|
@@ -912,10 +1065,10 @@ var IndexedDbAdapter = class {
|
|
|
912
1065
|
);
|
|
913
1066
|
if (columns.length === 0) continue;
|
|
914
1067
|
const placeholders = columns.map(() => "?").join(", ");
|
|
915
|
-
const quotedColumns = columns.map((column) => ensureSafeIdentifier(column)).join(", ");
|
|
1068
|
+
const quotedColumns = columns.map((column) => (0, import_core3.quoteIdent)(ensureSafeIdentifier(column))).join(", ");
|
|
916
1069
|
const values = columns.map((column) => row[column]);
|
|
917
1070
|
await this.inner.execute(
|
|
918
|
-
`INSERT INTO ${name} (${quotedColumns}) VALUES (${placeholders})`,
|
|
1071
|
+
`INSERT INTO ${(0, import_core3.quoteIdent)(name)} (${quotedColumns}) VALUES (${placeholders})`,
|
|
919
1072
|
values
|
|
920
1073
|
);
|
|
921
1074
|
}
|
|
@@ -928,9 +1081,13 @@ var IndexedDbAdapter = class {
|
|
|
928
1081
|
const tables = [];
|
|
929
1082
|
for (const tableRow of tableRows) {
|
|
930
1083
|
const tableName = ensureSafeIdentifier(tableRow.name);
|
|
931
|
-
const columns = await this.inner.query(
|
|
1084
|
+
const columns = await this.inner.query(
|
|
1085
|
+
`PRAGMA table_info(${(0, import_core3.quoteIdent)(tableName)})`
|
|
1086
|
+
);
|
|
932
1087
|
const columnNames = columns.map((column) => column.name);
|
|
933
|
-
const rows = await this.inner.query(
|
|
1088
|
+
const rows = await this.inner.query(
|
|
1089
|
+
`SELECT * FROM ${(0, import_core3.quoteIdent)(tableName)}`
|
|
1090
|
+
);
|
|
934
1091
|
tables.push({
|
|
935
1092
|
name: tableName,
|
|
936
1093
|
columns: columnNames,
|