@jarenjs/db 0.34.0

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.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/store.js ADDED
@@ -0,0 +1,1422 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The store: `openStore(model, options)` normalizes the model
4
+ * document, opens a database through the injected driver, applies (or
5
+ * verifies) the physical shape, and gives transactional,
6
+ * schema-validated reads and writes.
7
+ *
8
+ * The public surface is ASYNCHRONOUS — every method returns a promise
9
+ * — because the browser's OPFS story forces it regardless of any other
10
+ * backend. Where the driver is synchronous the same operations exist
11
+ * without the promise under `store.sync`, present only there — never a
12
+ * throwing stub — so portable code pays one declared microsecond and
13
+ * code that wants it back opts in knowingly. Internally everything
14
+ * composes through the sync-capable {@link chain}, so the async
15
+ * surface allocates exactly one promise per call, not one per step.
16
+ *
17
+ * Writes validate through an injected `compileSchema` hook with the
18
+ * `compileTypeTest` signature; absent, writes are unvalidated and
19
+ * `store.capabilities.validated === false` — a declared downgrade,
20
+ * never a silent one. `@jarenjs/validate` is never imported here.
21
+ */
22
+
23
+ import { applyJSONPatch } from '@jarenjs/json/patch';
24
+ import { parseJSONPointer } from '@jarenjs/json/pointer';
25
+
26
+ import { DbCompileError, DbRuntimeError } from './errors.js';
27
+ import { chain, toPromise, isThenable } from './driver.js';
28
+ import { planCollection, planEntity, planJoinTable, verifyShape } from './ddl.js';
29
+ import { translatePatch } from './patch-sql.js';
30
+ import { createQueryEngine, createQueryState, createEntityQueryEngine, createLoadEngine } from './query.js';
31
+ import { normalizeProfile } from './profile.js';
32
+ import { normalizeEntities, explainMapping } from './model.js';
33
+ import { entityCore } from './entity.js';
34
+ import { createTracker } from './tracker.js';
35
+ import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
36
+ import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
37
+ import { createJobEngine } from './jobs.js';
38
+ import { collectEntityRoots } from './plan.js';
39
+
40
+ /** The model format version this store implements. */
41
+ export const MODEL_VERSION = '0.1';
42
+
43
+ const COLLECTION_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
44
+ const IDENTITIES = new Set(['uuid', 'integer']);
45
+
46
+ /**
47
+ * @param {string} code
48
+ * @param {string} reason
49
+ * @param {string} docPath
50
+ * @returns {DbCompileError}
51
+ */
52
+ function modelError(code, reason, docPath) {
53
+ return new DbCompileError(code, reason, docPath);
54
+ }
55
+
56
+ /**
57
+ * Normalize and check a model document. Every failure is `JD0005` with
58
+ * a `docPath` into the model.
59
+ * @param {any} model
60
+ * @returns {Map<string, any>} collection name -> normalized collection
61
+ */
62
+ export function normalizeModel(model) {
63
+ if (model === null || typeof model !== 'object' || Array.isArray(model))
64
+ throw modelError('JD0005', 'the model document must be an object', '');
65
+ if (model.$model !== MODEL_VERSION) {
66
+ throw modelError('JD0005',
67
+ `the model must declare "$model": "${MODEL_VERSION}"`, '/$model');
68
+ }
69
+ const collections = model.collections;
70
+ if (collections === undefined && model.entities !== undefined) {
71
+ return new Map(); // an entities-only model (§9)
72
+ }
73
+ if (collections === null || typeof collections !== 'object'
74
+ || Array.isArray(collections) || Object.keys(collections).length === 0) {
75
+ throw modelError('JD0005',
76
+ 'the model must declare at least one collection or entity', '/collections');
77
+ }
78
+ /** @type {Map<string, any>} */
79
+ const normalized = new Map();
80
+ for (const name of Object.keys(collections)) {
81
+ const docPath = `/collections/${name}`;
82
+ if (!COLLECTION_NAME.test(name)) {
83
+ throw modelError('JD0005',
84
+ `collection names are identifiers ([A-Za-z_][A-Za-z0-9_]*), got '${name}'`,
85
+ '/collections');
86
+ }
87
+ const spec = collections[name];
88
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
89
+ throw modelError('JD0005', 'a collection must be an object', docPath);
90
+ if (spec.schema === null || typeof spec.schema !== 'object'
91
+ || Array.isArray(spec.schema))
92
+ throw modelError('JD0005', 'a collection needs an object schema', `${docPath}/schema`);
93
+
94
+ let keySegments = null;
95
+ let identity = 'caller';
96
+ if (spec.key === null || spec.key === undefined) {
97
+ identity = spec.identity;
98
+ if (!IDENTITIES.has(identity)) {
99
+ throw modelError('JD0005',
100
+ "a collection without a key pointer must declare identity 'uuid' or 'integer' — allocation is declared, never guessed",
101
+ `${docPath}/identity`);
102
+ }
103
+ }
104
+ else {
105
+ if (spec.identity !== undefined && spec.identity !== 'caller') {
106
+ throw modelError('JD0005',
107
+ 'a collection with a key pointer is caller-keyed; identity does not apply',
108
+ `${docPath}/identity`);
109
+ }
110
+ if (typeof spec.key !== 'string') {
111
+ throw modelError('JD0005',
112
+ 'the key must be an RFC 6901 pointer string or null', `${docPath}/key`);
113
+ }
114
+ let names;
115
+ try {
116
+ names = parseJSONPointer(spec.key);
117
+ }
118
+ catch {
119
+ throw modelError('JD0005',
120
+ `the key '${spec.key}' is not a valid RFC 6901 pointer`, `${docPath}/key`);
121
+ }
122
+ if (names.length === 0) {
123
+ throw modelError('JD0005',
124
+ 'the key pointer must select a member, not the whole document',
125
+ `${docPath}/key`);
126
+ }
127
+ keySegments = names.map((n) => ({ name: n }));
128
+ }
129
+
130
+ const indexes = [];
131
+ const indexNames = new Set();
132
+ const declaredIndexes = spec.indexes ?? [];
133
+ if (!Array.isArray(declaredIndexes)) {
134
+ throw modelError('JD0005', 'indexes must be an array',
135
+ `${docPath}/indexes`);
136
+ }
137
+ for (let i = 0; i < declaredIndexes.length; i++) {
138
+ const index = declaredIndexes[i];
139
+ const indexDocPath = `${docPath}/indexes/${i}`;
140
+ if (index === null || typeof index !== 'object' || Array.isArray(index))
141
+ throw modelError('JD0005', 'an index must be an object', indexDocPath);
142
+ if (typeof index.name !== 'string' || !COLLECTION_NAME.test(index.name)) {
143
+ throw modelError('JD0005',
144
+ 'an index needs an identifier name', `${indexDocPath}/name`);
145
+ }
146
+ if (indexNames.has(index.name)) {
147
+ throw modelError('JD0005',
148
+ `duplicate index name '${index.name}'`, `${indexDocPath}/name`);
149
+ }
150
+ indexNames.add(index.name);
151
+ const paths = Array.isArray(index.path) ? index.path : [index.path];
152
+ if (paths.length === 0
153
+ || paths.some((p) => typeof p !== 'string' || p === '')) {
154
+ throw modelError('JD0005',
155
+ 'an index path must be a JSONPath string (a composite index takes a non-empty array of them)',
156
+ `${indexDocPath}/path`);
157
+ }
158
+ indexes.push({
159
+ name: index.name,
160
+ paths,
161
+ unique: index.unique === true,
162
+ docPath: indexDocPath,
163
+ });
164
+ }
165
+
166
+ normalized.set(name, {
167
+ name,
168
+ docPath,
169
+ schema: spec.schema,
170
+ key: spec.key ?? null,
171
+ keySegments,
172
+ identity,
173
+ indexes,
174
+ });
175
+ }
176
+ return normalized;
177
+ }
178
+
179
+ /**
180
+ * Read a caller-supplied key out of a document along the declared
181
+ * pointer.
182
+ * @param {any} doc
183
+ * @param {{ name: string }[]} keySegments
184
+ * @param {string} pointer
185
+ * @param {string} collection
186
+ * @param {string} docPath
187
+ * @returns {string | number}
188
+ */
189
+ function extractKey(doc, keySegments, pointer, collection, docPath) {
190
+ let node = doc;
191
+ for (const segment of keySegments) {
192
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
193
+ node = undefined;
194
+ break;
195
+ }
196
+ node = node[segment.name];
197
+ }
198
+ if (typeof node !== 'string' && typeof node !== 'number') {
199
+ throw new DbRuntimeError('JD2002',
200
+ `the document carries no scalar key at the declared pointer '${pointer}'`,
201
+ { docPath, collection });
202
+ }
203
+ return node;
204
+ }
205
+
206
+ /**
207
+ * @param {any} key
208
+ * @param {string} collection
209
+ * @param {string} docPath
210
+ * @returns {string | number}
211
+ */
212
+ function requireKey(key, collection, docPath) {
213
+ if (typeof key !== 'string' && typeof key !== 'number') {
214
+ throw new DbRuntimeError('JD2002',
215
+ 'a key must be a string or a number', { docPath, collection });
216
+ }
217
+ return key;
218
+ }
219
+
220
+ /**
221
+ * Is this database error a duplicate on the collection's key column?
222
+ * SQLite reports a rowid-alias conflict as SQLITE_CONSTRAINT_PRIMARYKEY
223
+ * (1555) and a declared-PRIMARY-KEY conflict as
224
+ * SQLITE_CONSTRAINT_UNIQUE naming `table.column` in the message.
225
+ * @param {any} error
226
+ * @param {string} table
227
+ * @param {string} keyColumn
228
+ * @returns {boolean}
229
+ */
230
+ function isDuplicateKey(error, table, keyColumn) {
231
+ if (error?.errcode === 1555) return true;
232
+ return typeof error?.message === 'string'
233
+ && error.message.includes('UNIQUE constraint failed')
234
+ && error.message.includes(`${table}.${keyColumn}`);
235
+ }
236
+
237
+ /**
238
+ * Wrap a database failure for one collection operation.
239
+ * @param {any} error
240
+ * @param {any} plan
241
+ * @param {string} collection
242
+ * @param {string} docPath
243
+ * @param {string | number} [key]
244
+ * @returns {DbRuntimeError}
245
+ */
246
+ function wrapWriteError(error, plan, collection, docPath, key) {
247
+ if (isDuplicateKey(error, plan.table, plan.keyColumn)) {
248
+ return new DbRuntimeError('JD2001',
249
+ `a document already exists under key '${String(key)}'`,
250
+ { docPath, collection, key, cause: error });
251
+ }
252
+ return new DbRuntimeError('JD2005',
253
+ `the database rejected the operation: ${error?.message ?? String(error)}`,
254
+ key === undefined
255
+ ? { docPath, collection, cause: error }
256
+ : { docPath, collection, key, cause: error });
257
+ }
258
+
259
+ /**
260
+ * Create or verify every collection's physical shape, inside one
261
+ * transaction.
262
+ * @param {any} connection
263
+ * @param {Map<string, any>} collections
264
+ * @param {Map<string, any>} plans
265
+ * @returns {any} value-or-promise
266
+ */
267
+ function ensureShape(connection, collections, plans, readOnly) {
268
+ const dialect = connection.dialect;
269
+ const names = [...collections.keys()];
270
+ return connection.transaction(() => {
271
+ const step = (i) => {
272
+ if (i >= names.length) return null;
273
+ const name = names[i];
274
+ const collection = collections.get(name);
275
+ const plan = plans.get(name);
276
+ return chain(connection.prepare(dialect.introspect.tableExists()), (statement) =>
277
+ chain(statement.get([name]), (row) => {
278
+ if (row === undefined) {
279
+ if (readOnly) {
280
+ throw new DbCompileError('JD0002',
281
+ `collection '${name}': the table does not exist and a read-only store creates nothing`,
282
+ collection.docPath);
283
+ }
284
+ const run = (j) => (j >= plan.createSql.length
285
+ ? null
286
+ : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
287
+ return chain(run(0), () => step(i + 1));
288
+ }
289
+ return chain(verifyShape(connection, plan, name, collection.docPath),
290
+ () => step(i + 1));
291
+ }));
292
+ };
293
+ return step(0);
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Create or verify entity and join tables (the same
299
+ * create-or-verify discipline as collections), including the
300
+ * foreign-key list: source column, target table and the declared
301
+ * on-delete behaviour must match.
302
+ * @param {any} connection
303
+ * @param {Map<string, any>} entityPlans
304
+ * @param {Map<string, any>} entities
305
+ * @param {boolean} readOnly
306
+ * @returns {any} value-or-promise
307
+ */
308
+ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
309
+ if (entityPlans.size === 0) return null;
310
+ const dialect = connection.dialect;
311
+ const names = [...entityPlans.keys()];
312
+ return connection.transaction(() => {
313
+ const step = (i) => {
314
+ if (i >= names.length) return null;
315
+ const name = names[i];
316
+ const plan = entityPlans.get(name);
317
+ const docPath = entities.get(name)?.docPath ?? `/entities/${name}`;
318
+ return chain(connection.prepare(dialect.introspect.tableExists()), (statement) =>
319
+ chain(statement.get([name]), (row) => {
320
+ if (row === undefined) {
321
+ if (readOnly) {
322
+ throw new DbCompileError('JD0002',
323
+ `entity '${name}': the table does not exist and a read-only store creates nothing`,
324
+ docPath);
325
+ }
326
+ const run = (j) => (j >= plan.createSql.length
327
+ ? null
328
+ : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
329
+ return chain(run(0), () => step(i + 1));
330
+ }
331
+ return chain(verifyShape(connection, plan, name, docPath), () =>
332
+ chain(connection.prepare(dialect.introspect.foreignKeyList(name)), (fkStatement) =>
333
+ chain(fkStatement.all([]), (fkRows) => {
334
+ // the whole TUPLE, not the count: a key that changed
335
+ // `ON DELETE SET NULL` to `ON DELETE CASCADE`, or that now
336
+ // points at a different table or column, keeps the count
337
+ // identical and deletes different rows
338
+ const expectedFks = (plan.expectedForeignKeys ?? []);
339
+ /** @param {any} fk */
340
+ const describe = (fk) => `${fk.column} -> ${fk.references}`
341
+ + `${fk.targetColumn === null ? '' : `(${fk.targetColumn})`}`
342
+ + ` ON DELETE ${String(fk.onDelete ?? 'NO ACTION').toUpperCase()}`
343
+ + ` ON UPDATE ${String(fk.onUpdate ?? 'NO ACTION').toUpperCase()}`;
344
+ const actual = fkRows.map((row) => describe({
345
+ column: String(row.source_column),
346
+ references: String(row.target),
347
+ targetColumn: row.target_column === null ? null : String(row.target_column),
348
+ onDelete: row.on_delete,
349
+ onUpdate: row.on_update,
350
+ })).sort();
351
+ const wanted = expectedFks.map(describe).sort();
352
+ if (actual.join(' | ') !== wanted.join(' | ')) {
353
+ throw new DbCompileError('JD0002',
354
+ `entity '${name}': the foreign keys are [${actual.join(', ')}], `
355
+ + `the model declares [${wanted.join(', ')}]`,
356
+ docPath);
357
+ }
358
+ return step(i + 1);
359
+ })));
360
+ }));
361
+ };
362
+ return step(0);
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Build the per-collection operation core. Every function returns a
368
+ * value or a promise depending on the driver; the async surface lifts
369
+ * once, the sync surface passes through.
370
+ * @param {any} connection
371
+ * @param {any} collection - normalized collection
372
+ * @param {any} plan
373
+ * @param {((doc: any) => any) | null} validate
374
+ * @param {any} queryState - the store-wide statement cache and UDF set
375
+ * @param {{ profile: any }} storeProfileRef - the store-level profile
376
+ * @returns {any}
377
+ */
378
+ function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef) {
379
+ const dialect = connection.dialect;
380
+ const shape = {
381
+ table: plan.table,
382
+ keyColumn: plan.keyColumn,
383
+ docColumn: plan.docColumn,
384
+ };
385
+ /** @type {Map<string, any>} */
386
+ const statements = new Map();
387
+ const prepared = (name, sql) => {
388
+ let statement = statements.get(name);
389
+ if (statement === undefined) {
390
+ statement = connection.prepare(sql);
391
+ statements.set(name, statement);
392
+ }
393
+ return statement;
394
+ };
395
+ const stats = { patchTranslated: 0, patchFallback: 0 };
396
+ const engine = createQueryEngine({
397
+ connection, state: queryState, collection, physicalPlan: plan,
398
+ profile: storeProfileRef.profile,
399
+ });
400
+
401
+ const checkValid = (doc) => {
402
+ if (validate === null) return;
403
+ const result = validate(doc);
404
+ const valid = result === true || result?.valid === true;
405
+ if (!valid) {
406
+ throw new DbRuntimeError('JD2003',
407
+ `collection '${collection.name}' rejected the document`,
408
+ Array.isArray(result?.errors)
409
+ ? { docPath: collection.docPath, collection: collection.name, errors: result.errors }
410
+ : { docPath: collection.docPath, collection: collection.name });
411
+ }
412
+ };
413
+
414
+ const resolveWriteKey = (doc, explicitKey) => {
415
+ if (collection.keySegments !== null) {
416
+ return extractKey(doc, collection.keySegments, collection.key,
417
+ collection.name, collection.docPath);
418
+ }
419
+ if (explicitKey !== undefined)
420
+ return requireKey(explicitKey, collection.name, collection.docPath);
421
+ if (collection.identity === 'uuid') return crypto.randomUUID();
422
+ return null; // integer: the database allocates
423
+ };
424
+
425
+ const runWrite = (statementName, sql, params, key, reads) => {
426
+ return chain(prepared(statementName, sql), (statement) => {
427
+ let out;
428
+ try {
429
+ out = reads ? statement.get(params) : statement.run(params);
430
+ }
431
+ catch (error) {
432
+ throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
433
+ }
434
+ return out;
435
+ });
436
+ };
437
+
438
+ const core = {
439
+ stats: () => ({ ...stats }),
440
+ model: collection,
441
+ queryShape: engine.shape,
442
+ // the D2 provider: value-or-promise, deliberately NOT lifted — a
443
+ // synchronous driver answers a linq chain synchronously
444
+ execute: (document, options) => engine.execute(document, options),
445
+ query: (document, options) => engine.query(document, options),
446
+ explain: (document, options) => engine.explain(document, options),
447
+ get(key) {
448
+ requireKey(key, collection.name, collection.docPath);
449
+ return chain(prepared('get', dialect.dml.get(shape)), (statement) =>
450
+ chain(statement.get([key]),
451
+ (row) => (row === undefined ? undefined : JSON.parse(row.doc))));
452
+ },
453
+ insert(doc) {
454
+ checkValid(doc);
455
+ const key = resolveWriteKey(doc, undefined);
456
+ if (key === null) {
457
+ return chain(
458
+ runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
459
+ [JSON.stringify(doc)], undefined, true),
460
+ (row) => row.key);
461
+ }
462
+ return chain(
463
+ runWrite('insert', dialect.dml.insert(shape),
464
+ [key, JSON.stringify(doc)], key, false),
465
+ () => key);
466
+ },
467
+ put(doc, explicitKey) {
468
+ checkValid(doc);
469
+ const key = resolveWriteKey(doc, explicitKey);
470
+ if (key === null) {
471
+ return chain(
472
+ runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
473
+ [JSON.stringify(doc)], undefined, true),
474
+ (row) => row.key);
475
+ }
476
+ return chain(
477
+ runWrite('upsert', dialect.dml.upsert(shape),
478
+ [key, JSON.stringify(doc)], key, false),
479
+ () => key);
480
+ },
481
+ patch(key, ops) {
482
+ requireKey(key, collection.name, collection.docPath);
483
+ return chain(core.get(key), (current) => {
484
+ if (current === undefined) {
485
+ throw new DbRuntimeError('JD2006',
486
+ `no document to patch under key '${String(key)}'`,
487
+ { docPath: collection.docPath, collection: collection.name, key });
488
+ }
489
+ // the copy-on-write engine validates the RESULT before any SQL
490
+ const next = applyJSONPatch(current, ops);
491
+ checkValid(next);
492
+ const translated = translatePatch(ops, current, dialect);
493
+ if (translated === null) {
494
+ stats.patchFallback++;
495
+ return chain(
496
+ runWrite('patchFallback',
497
+ dialect.dml.updateDoc(shape, dialect.jsonEncode(dialect.parameterRef(1, 'doc')), 2),
498
+ [JSON.stringify(next), key], key, false),
499
+ () => next);
500
+ }
501
+ stats.patchTranslated++;
502
+ const { expression, params } = translated.build(
503
+ dialect.quoteIdentifier(plan.docColumn), 1);
504
+ const sql = dialect.dml.updateDoc(shape, expression, params.length + 1);
505
+ return chain(prepared(`patch:${sql}`, sql), (statement) => {
506
+ try {
507
+ statement.run([...params, key]);
508
+ }
509
+ catch (error) {
510
+ throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
511
+ }
512
+ return next;
513
+ });
514
+ });
515
+ },
516
+ delete(key) {
517
+ requireKey(key, collection.name, collection.docPath);
518
+ return chain(
519
+ runWrite('delete', dialect.dml.del(shape), [key], key, false),
520
+ (result) => Number(result?.changes ?? 0) > 0);
521
+ },
522
+ };
523
+ return core;
524
+ }
525
+
526
+ /**
527
+ * Lift a core operation into the asynchronous contract: the returned
528
+ * function ALWAYS rejects, never throws synchronously — a caller-side
529
+ * `catch` must be enough.
530
+ * @param {Function} fn
531
+ * @returns {(...args: any[]) => Promise<any>}
532
+ */
533
+ function lift(fn) {
534
+ return (...args) => {
535
+ try {
536
+ return toPromise(fn(...args));
537
+ }
538
+ catch (error) {
539
+ return Promise.reject(error);
540
+ }
541
+ };
542
+ }
543
+
544
+ /**
545
+ * The asynchronous collection surface over a core.
546
+ * @param {any} core
547
+ * @param {Function | null} live - the store-level live registration
548
+ * for this collection (null when the store has no capture)
549
+ * @returns {any}
550
+ */
551
+ function asyncCollection(core, live) {
552
+ return Object.freeze({
553
+ stats: () => core.stats(),
554
+ get: lift(core.get),
555
+ insert: lift(core.insert),
556
+ put: lift(core.put),
557
+ patch: lift(core.patch),
558
+ delete: lift(core.delete),
559
+ // the provider contract (D2): execute stays value-or-promise so a
560
+ // linq chain over a synchronous driver stays synchronous
561
+ execute: (document, options) => core.execute(document, options),
562
+ query: (document, options) => core.query(document, options),
563
+ explain: lift(core.explain),
564
+ live: lift((document, liveOptions) => {
565
+ if (live === null) {
566
+ throw new DbCompileError('JD0050',
567
+ 'live queries require change capture — open the store with { capture: true }');
568
+ }
569
+ return live(core, document, liveOptions);
570
+ }),
571
+ });
572
+ }
573
+
574
+ /**
575
+ * Resolve the store's operator seam (Ring 2) to a single
576
+ * `{ functions, extensions }` or `null`. Accepts `options.operators` (a
577
+ * registry from `@jarenjs/json/jslt`'s `createJsltRegistry()`) and/or
578
+ * raw `options.functions` / `options.extensions`. A registered operator
579
+ * becomes engine vocabulary the query planner recognises and the
580
+ * residual evaluates — it runs correctly in JavaScript over the fetched
581
+ * rows, and is never pushed to SQL in this ring (that is Ring 3). Bad
582
+ * input is API misuse (a synchronous `TypeError`), consistent with the
583
+ * driver check. When no registry is threaded the return is `null`, and
584
+ * the whole query engine is byte-identical to before.
585
+ * @param {any} options
586
+ * @returns {{ functions: any, extensions: any } | null}
587
+ */
588
+ function resolveOperators(options) {
589
+ const registry = options.operators;
590
+ const hasRegistry = registry !== undefined && registry !== null;
591
+ const hasRaw = options.functions !== undefined || options.extensions !== undefined;
592
+ if (!hasRegistry && !hasRaw) return null;
593
+ let functions = {};
594
+ let extensions = {};
595
+ // the SQL-pushable scalar subset (Ring 3): the registered operators a
596
+ // pack marked `pushable: 'scalar'`, eligible to become deterministic
597
+ // UDFs where the driver supports them. Raw (registry-free) extensions
598
+ // are never pushed — only a registry declares pushability.
599
+ const pushableScalar = new Set();
600
+ if (hasRegistry) {
601
+ if (typeof registry.toOptions !== 'function') {
602
+ throw new TypeError('openStore: operators must be a registry '
603
+ + '(createJsltRegistry()) exposing toOptions()');
604
+ }
605
+ const resolved = registry.toOptions();
606
+ // reuse the registry's stable frozen maps by reference when there
607
+ // are no raw overrides, so the engine's identity-keyed caches hit
608
+ functions = resolved.functions ?? {};
609
+ extensions = resolved.extensions ?? {};
610
+ if (typeof registry.forSql === 'function') {
611
+ for (const [name, meta] of Object.entries(registry.forSql())) {
612
+ if (meta.pushable === 'scalar') pushableScalar.add(name);
613
+ }
614
+ }
615
+ }
616
+ if (options.functions !== undefined) functions = { ...functions, ...options.functions };
617
+ if (options.extensions !== undefined) extensions = { ...extensions, ...options.extensions };
618
+ return Object.freeze({ functions, extensions, pushableScalar });
619
+ }
620
+
621
+ /**
622
+ * Open (or create) a store described by a model document.
623
+ * @param {any} model - A `jaren-model` document (the 0.1 subset)
624
+ * @param {{ driver: any, path?: string, compileSchema?: Function,
625
+ * busyTimeout?: number, queueTimeout?: number, journalMode?: string,
626
+ * statementCacheBound?: number, profile?: any, operators?: any,
627
+ * functions?: any, extensions?: any,
628
+ * readOnly?: boolean }} options
629
+ * @returns {Promise<any>}
630
+ */
631
+ export function openStore(model, options) {
632
+ if (options === null || typeof options !== 'object'
633
+ || options.driver === null || typeof options.driver !== 'object'
634
+ || typeof options.driver.open !== 'function')
635
+ throw new TypeError('openStore needs { driver } from @jarenjs/db/node, /bun or /wasm');
636
+ if (options.compileSchema !== undefined && typeof options.compileSchema !== 'function')
637
+ throw new TypeError('openStore: compileSchema must be a function when present');
638
+ const operators = resolveOperators(options);
639
+
640
+ // API misuse (above) throws; a defective MODEL rejects, per the
641
+ // asynchronous contract
642
+ let collections;
643
+ let entities;
644
+ let mapping;
645
+ try {
646
+ collections = normalizeModel(model);
647
+ entities = normalizeEntities(model);
648
+ mapping = entities.size > 0 ? explainMapping(model) : null;
649
+ if (collections.size === 0 && entities.size === 0) {
650
+ throw modelError('JD0005',
651
+ 'the model must declare at least one collection or entity', '');
652
+ }
653
+ }
654
+ catch (error) {
655
+ return Promise.reject(error);
656
+ }
657
+ const path = options.path ?? ':memory:';
658
+ const busyTimeout = options.busyTimeout ?? 5000;
659
+ const journalMode = options.journalMode ?? 'wal';
660
+ const memory = path === ':memory:' || path === '';
661
+ const readOnly = options.readOnly === true;
662
+ const storeProfile = options.profile === undefined
663
+ ? null
664
+ : normalizeProfile(options.profile);
665
+
666
+ return toPromise(chain(
667
+ options.driver.open(path,
668
+ { timeout: busyTimeout, readOnly, queueTimeout: options.queueTimeout }),
669
+ (opened) => {
670
+ /**
671
+ * The transaction SCOPE that currently owns the driver connection,
672
+ * or `null`. A top-level `store.transaction()` sets it for the
673
+ * callback's whole lifetime so the collection/entity cores below
674
+ * reach the open transaction instead of queueing behind it.
675
+ * @type {any}
676
+ */
677
+ let scope = null;
678
+
679
+ /**
680
+ * What the cores talk to: the owning transaction's scope while one
681
+ * is open, the driver connection otherwise. One indirection here
682
+ * instead of a parallel set of handles per transaction — and it is
683
+ * why a write inside a transaction callback runs immediately as the
684
+ * owner rather than waiting for a commit it is part of.
685
+ */
686
+ const connection = Object.freeze({
687
+ get synchronous() { return opened.synchronous; },
688
+ get capabilities() { return opened.capabilities; },
689
+ get dialect() { return opened.dialect; },
690
+ /** @param {string} sql */
691
+ exec: (sql) => (scope ?? opened).exec(sql),
692
+ /** @param {string} sql */
693
+ prepare: (sql) => (scope ?? opened).prepare(sql),
694
+ /** Internal transaction users (jobs, checkpoints, migrations)
695
+ * nest when a transaction is open and take the gate when not. */
696
+ transaction: (fn) => withScope((scope ?? opened).transaction, fn),
697
+ registerFunction: opened.registerFunction === null ? null
698
+ : (/** @type {string} */ name, /** @type {any} */ o, /** @type {Function} */ fn) =>
699
+ opened.registerFunction(name, o, fn),
700
+ session: opened.session === null ? null
701
+ : (/** @type {any} */ table) => opened.session(table),
702
+ close: () => opened.close(),
703
+ });
704
+
705
+ /**
706
+ * Run `fn` as a transaction opened by `open`, with `scope` bound to
707
+ * it for the callback's whole lifetime and restored afterwards.
708
+ * @param {(inner: (s: any) => any) => any} open - the driver's
709
+ * `transaction`, gated (top level) or nesting (inner)
710
+ * @param {(store: any) => any} fn
711
+ */
712
+ function withScope(open, fn) {
713
+ return open((inner) => {
714
+ const outer = scope;
715
+ scope = inner;
716
+ const restore = () => { scope = outer; };
717
+ let out;
718
+ try {
719
+ out = fn(scopedStore());
720
+ }
721
+ catch (error) {
722
+ restore();
723
+ throw error;
724
+ }
725
+ if (!isThenable(out)) {
726
+ restore();
727
+ return out;
728
+ }
729
+ return out.then(
730
+ (value) => { restore(); return value; },
731
+ (error) => { restore(); throw error; });
732
+ });
733
+ }
734
+
735
+ /** Set once the store object exists; the transaction callback's
736
+ * argument, whose `transaction` NESTS instead of queueing. */
737
+ let scopedStore = () => undefined;
738
+
739
+ /**
740
+ * A TOP-LEVEL store transaction. It takes the connection's gate
741
+ * first, so two of them never share a savepoint stack no matter how
742
+ * their callbacks interleave, and the capture scope opens INSIDE it
743
+ * — a rollback then undoes the translated patch together with the
744
+ * rows it describes.
745
+ * @param {(store: any) => any} fn
746
+ */
747
+ let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
748
+
749
+ const dialect = connection.dialect;
750
+ /** @type {Map<string, any>} */
751
+ const plans = new Map();
752
+ for (const [name, collection] of collections)
753
+ plans.set(name, planCollection(name, collection, dialect));
754
+ /** @type {Map<string, any>} */
755
+ const entityPlans = new Map();
756
+ if (mapping !== null) {
757
+ for (const name of Object.keys(mapping.entities))
758
+ entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
759
+ for (const joinName of Object.keys(mapping.joinTables)) {
760
+ entityPlans.set(joinName,
761
+ planJoinTable(joinName, mapping.joinTables[joinName], mapping, dialect));
762
+ }
763
+ }
764
+
765
+ const pragmas = chain(
766
+ memory
767
+ ? null
768
+ : chain(connection.exec(dialect.pragma.busyTimeout(busyTimeout)),
769
+ // a journal-mode change writes; a read-only store keeps
770
+ // whatever mode the file already has
771
+ () => (readOnly ? null : connection.exec(dialect.pragma.journalMode(journalMode)))),
772
+ // referential integrity is real only when the pragma is ON —
773
+ // it defaults off, so set it AND verify it per connection
774
+ () => chain(connection.exec(dialect.pragma.foreignKeys(true)), () =>
775
+ chain(connection.prepare(dialect.introspect.foreignKeysOn()), (statement) =>
776
+ chain(statement.get([]), (row) => {
777
+ if (Number(row?.enabled) !== 1) {
778
+ throw new DbCompileError('JD0003',
779
+ 'this connection cannot enforce foreign keys (PRAGMA foreign_keys stayed off)');
780
+ }
781
+ return null;
782
+ }))));
783
+
784
+ // ————— the rejection boundary around an ACQUIRED connection —————
785
+ // Initialization continues for a long way past `driver.open`:
786
+ // pragmas, shape verification, capture, jobs, readiness. Every one
787
+ // of those can refuse, and a refusal that walks away from the open
788
+ // handle leaks it — on Windows the database file simply stays
789
+ // locked, which is how three of these showed up as `EPERM` while
790
+ // a temporary directory was being removed. So: close exactly once
791
+ // on any failure after acquisition, and keep the initialization
792
+ // error primary — a close that also fails is retained beside it
793
+ // rather than replacing the reason the open was refused.
794
+ let closed = false;
795
+ /**
796
+ * Release the handle and re-reject with the original failure.
797
+ * @param {any} error
798
+ * @returns {Promise<never>}
799
+ */
800
+ const failClosed = (error) => {
801
+ if (closed) return Promise.reject(error);
802
+ closed = true;
803
+ /** @param {any} closeError */
804
+ const both = (closeError) => Promise.reject(new AggregateError([error, closeError],
805
+ 'the store failed to open, and closing the acquired connection failed too'));
806
+ let closing;
807
+ try {
808
+ closing = opened.close();
809
+ }
810
+ catch (closeError) {
811
+ return both(closeError);
812
+ }
813
+ return isThenable(closing)
814
+ ? closing.then(() => Promise.reject(error), both)
815
+ : Promise.reject(error);
816
+ };
817
+ const opening = () => chain(pragmas, () =>
818
+ chain(ensureShape(connection, collections, plans, readOnly), () =>
819
+ chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
820
+ /** @type {Map<string, any>} */
821
+ const cores = new Map();
822
+ const coreFor = (name) => {
823
+ let core = cores.get(name);
824
+ if (core === undefined) {
825
+ const collection = collections.get(name);
826
+ if (collection === undefined) {
827
+ throw new DbRuntimeError('JD2004',
828
+ `the model declares no collection '${name}'`,
829
+ { docPath: '/collections', collection: name });
830
+ }
831
+ const validate = options.compileSchema !== undefined
832
+ ? options.compileSchema(collection.schema)
833
+ : null;
834
+ if (validate !== null && typeof validate !== 'function')
835
+ throw new TypeError('openStore: compileSchema must return a validation function');
836
+ core = captureCollection(name, collectionCore(connection, collection,
837
+ plans.get(name), validate, queryState,
838
+ { profile: storeProfile }));
839
+ cores.set(name, core);
840
+ }
841
+ return core;
842
+ };
843
+
844
+ // ————— change capture (LIVE-FORMAT §§1–6) —————
845
+ const captureRequested = options.capture === true
846
+ ? {}
847
+ : (options.capture === undefined || options.capture === false
848
+ ? null : options.capture);
849
+ let captureMode = 'none';
850
+ if (captureRequested !== null) {
851
+ const wanted = captureRequested.mode ?? 'auto';
852
+ const hasSessions = connection.capabilities.sessions === true
853
+ && typeof connection.session === 'function';
854
+ if (wanted === 'session' && !hasSessions) {
855
+ throw new TypeError(
856
+ "capture mode 'session' is unavailable on this driver "
857
+ + '(bun:sqlite and some wasm builds ship no session extension) — '
858
+ + "use mode 'journal' or 'auto'");
859
+ }
860
+ captureMode = wanted === 'auto'
861
+ ? (hasSessions ? 'session' : 'journal')
862
+ : wanted;
863
+ }
864
+ const captureShapes = new Map();
865
+ if (captureMode !== 'none') {
866
+ for (const [collectionName, plan] of plans) {
867
+ captureShapes.set(plan.table ?? collectionName, {
868
+ kind: 'collection',
869
+ columns: [
870
+ { name: plan.keyColumn, role: 'key' },
871
+ { name: plan.docColumn, role: 'doc' },
872
+ ],
873
+ keyIndexes: [0],
874
+ docIndex: 1,
875
+ });
876
+ }
877
+ for (const [entityName, entity] of (mapping === null ? [] : entities)) {
878
+ const em = mapping.entities[entityName];
879
+ const fkNames = new Set(em.foreignKeys.map((fk) => fk.column));
880
+ const columns = [];
881
+ for (const column of em.columns) {
882
+ if (fkNames.has(column.name)) continue;
883
+ columns.push({
884
+ name: column.name,
885
+ role: column.key ? 'key'
886
+ : column.source === 'epoch(document)' ? 'epoch' : 'scalar',
887
+ storage: column.storage,
888
+ });
889
+ }
890
+ for (const fk of em.foreignKeys)
891
+ columns.push({ name: fk.column, role: 'fk' });
892
+ columns.push({ name: 'doc', role: 'doc' });
893
+ captureShapes.set(entityName, {
894
+ kind: 'entity',
895
+ columns,
896
+ keyIndexes: em.keys.map((key) =>
897
+ columns.findIndex((column) => column.name === key)),
898
+ docIndex: columns.length - 1,
899
+ relationNames: [...entity.properties.values()]
900
+ .filter((property) => property.relation !== undefined)
901
+ .map((property) => property.name),
902
+ });
903
+ }
904
+ for (const joinName of Object.keys(mapping?.joinTables ?? {})) {
905
+ const pair = joinName.split('_');
906
+ const columns = pair.map((part) => ({ name: `${part}_key`, role: 'key' }));
907
+ captureShapes.set(joinName, {
908
+ kind: 'join', columns,
909
+ keyIndexes: columns.map((_, i) => i), docIndex: -1,
910
+ });
911
+ }
912
+ }
913
+ const capture = captureMode === 'none' ? null : createCaptureEngine({
914
+ connection,
915
+ shapes: captureShapes,
916
+ mode: captureMode,
917
+ log: captureRequested.log === true
918
+ || (captureRequested.log !== undefined && captureRequested.log !== false),
919
+ retention: captureRequested.log?.retention ?? DEFAULT_RETENTION,
920
+ });
921
+ const guard = capture === null ? (fn) => fn() : capture.wrap;
922
+ if (capture !== null) {
923
+ topLevelTransaction = (fn) => withScope(opened.transaction,
924
+ (tx) => capture.nest(() => fn(tx)));
925
+ }
926
+ // the live registry rides the capture stream; its dispatcher
927
+ // registers FIRST so maintenance sees every record before any
928
+ // user observer can commit a further write (LIVE-FORMAT §8)
929
+ const liveRegistry = capture === null ? null : createLiveRegistry({
930
+ maxQueries: options.live?.maxQueries ?? LIVE_DEFAULTS.maxQueries,
931
+ maxMaintained: options.live?.maxMaintained ?? LIVE_DEFAULTS.maxMaintained,
932
+ });
933
+ if (capture !== null) {
934
+ capture.observe((record) => /** @type {any} */ (liveRegistry).deliver(record));
935
+ }
936
+ // the durable job queue (JOBS-FORMAT), opt-in per store
937
+ const jobsRequested = options.jobs === true
938
+ || (options.jobs !== undefined && options.jobs !== false);
939
+ const jobsEngine = !jobsRequested ? null : createJobEngine({
940
+ connection,
941
+ now: typeof options.jobs === 'object' ? options.jobs.now : undefined,
942
+ random: typeof options.jobs === 'object' ? options.jobs.random : undefined,
943
+ defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
944
+ });
945
+ /** Register a collection live query (LIVE-FORMAT §7). */
946
+ const registerCollectionLive = (core, document, liveOptions) => {
947
+ const externals = liveOptions?.externals ?? {};
948
+ const keyed = core.model.keySegments !== null;
949
+ const classification = liveOptions?.mode === 'rerun'
950
+ ? { strategy: 'rerun', reason: 'rerun was requested' }
951
+ : classifyLiveQuery(document, core.queryShape, keyed);
952
+ return /** @type {any} */ (liveRegistry).register({
953
+ name: core.model.name,
954
+ tables: new Set([core.model.name]),
955
+ document,
956
+ externals,
957
+ demanded: liveOptions?.mode,
958
+ classification,
959
+ execute: (doc, executeOptions) => core.execute(doc, executeOptions),
960
+ readRow: (token) => core.get(token),
961
+ keyOf: (doc) => String(extractKey(doc, core.model.keySegments,
962
+ core.model.key, core.model.name, core.model.docPath)),
963
+ });
964
+ };
965
+ /** Strip relation members before journal diffs — sessions
966
+ * never see them (they are not stored), so the two modes
967
+ * stay identical. */
968
+ const stripRelations = (entityName, doc) => {
969
+ if (doc === null || doc === undefined) return doc;
970
+ const names = captureShapes.get(entityName)?.relationNames;
971
+ if (names === undefined || names.length === 0) return doc;
972
+ const out = { ...doc };
973
+ for (const name of names) delete out[name];
974
+ return out;
975
+ };
976
+ /** Journal mode cannot see ON DELETE CASCADE side effects;
977
+ * the ONE store-shaped case — join-table membership dying
978
+ * with its entity — is read and recorded before the delete.
979
+ * Deeper cascades (child rows) stay documented-invisible. */
980
+ const captureJoinDelete = capture === null || capture.mode !== 'journal'
981
+ ? null
982
+ : (entityName, keyParts) => {
983
+ const joins = Object.keys(mapping?.joinTables ?? {})
984
+ .filter((joinName) => joinName.split('_').includes(entityName));
985
+ const nextJoin = (i) => {
986
+ if (i >= joins.length) return null;
987
+ const joinName = joins[i];
988
+ const pair = joinName.split('_');
989
+ const sql = `SELECT ${pair.map((part) => dialect.quoteIdentifier(`${part}_key`)).join(', ')} `
990
+ + `FROM ${dialect.quoteIdentifier(joinName)} `
991
+ + `WHERE ${dialect.quoteIdentifier(`${entityName}_key`)} = ${dialect.parameterRef(1, 'v')}`;
992
+ return chain(connection.prepare(sql), (statement) =>
993
+ chain(statement.all([keyParts[0]]), (rows) => {
994
+ for (const row of rows) {
995
+ capture.record(joinName,
996
+ pair.map((part) => row[`${part}_key`]), undefined, null);
997
+ }
998
+ return nextJoin(i + 1);
999
+ }));
1000
+ };
1001
+ return nextJoin(0);
1002
+ };
1003
+ /** Journal-mode write wrappers for a collection core. */
1004
+ const captureCollection = (collectionName, core) => {
1005
+ if (capture === null) return core;
1006
+ const journal = capture.mode === 'journal';
1007
+ return {
1008
+ ...core,
1009
+ insert: (doc) => guard(() => chain(core.insert(doc), (key) => {
1010
+ if (journal) capture.record(collectionName, [key], null, doc);
1011
+ return key;
1012
+ })),
1013
+ put: (doc, key) => guard(() => (journal
1014
+ ? chain(key === undefined ? undefined : core.get(key), (before) =>
1015
+ chain(core.put(doc, key), (storedKey) => {
1016
+ capture.record(collectionName, [storedKey], before ?? null, doc);
1017
+ return storedKey;
1018
+ }))
1019
+ : core.put(doc, key))),
1020
+ patch: (key, ops) => guard(() => (journal
1021
+ ? chain(core.get(key), (before) =>
1022
+ chain(core.patch(key, ops), (after) => {
1023
+ capture.record(collectionName, [key], before ?? null, after);
1024
+ return after;
1025
+ }))
1026
+ : core.patch(key, ops))),
1027
+ delete: (key) => guard(() => (journal
1028
+ ? chain(core.get(key), (before) =>
1029
+ chain(core.delete(key), (deleted) => {
1030
+ if (deleted && before !== undefined)
1031
+ capture.record(collectionName, [key], before, null);
1032
+ return deleted;
1033
+ }))
1034
+ : core.delete(key))),
1035
+ };
1036
+ };
1037
+ /** Journal-mode write wrappers for an entity core. */
1038
+ const captureEntity = (entityName, core) => {
1039
+ if (capture === null) return core;
1040
+ const journal = capture.mode === 'journal';
1041
+ const keysOf = (doc) => core.plan.keys.map((key) => doc[key]);
1042
+ return {
1043
+ ...core,
1044
+ create: (doc) => guard(() => chain(core.create(doc), (made) => {
1045
+ if (journal) {
1046
+ capture.record(entityName, keysOf(made), null,
1047
+ stripRelations(entityName, made));
1048
+ }
1049
+ return made;
1050
+ })),
1051
+ update: (key, changes) => guard(() => (journal
1052
+ ? chain(core.get(key), (before) =>
1053
+ chain(core.update(key, changes), (next) => {
1054
+ capture.record(entityName, keysOf(next),
1055
+ stripRelations(entityName, before ?? null),
1056
+ stripRelations(entityName, next));
1057
+ return next;
1058
+ }))
1059
+ : core.update(key, changes))),
1060
+ delete: (key) => guard(() => (journal
1061
+ ? chain(captureJoinDelete(entityName, core.normalizeKey(key)), () =>
1062
+ chain(core.get(key), (before) =>
1063
+ chain(core.delete(key), (deleted) => {
1064
+ if (deleted && before !== undefined) {
1065
+ capture.record(entityName, core.normalizeKey(key),
1066
+ stripRelations(entityName, before), null);
1067
+ }
1068
+ return deleted;
1069
+ })))
1070
+ : core.delete(key))),
1071
+ };
1072
+ };
1073
+
1074
+ const capabilities = Object.freeze({
1075
+ ...connection.capabilities,
1076
+ validated: options.compileSchema !== undefined,
1077
+ busyTimeoutMs: memory ? null : busyTimeout,
1078
+ journalMode: memory || readOnly ? null : journalMode,
1079
+ readOnly,
1080
+ profiled: storeProfile !== null,
1081
+ // the registered operator vocabulary (Ring 2): the names a
1082
+ // query may use; they run in the residual by default
1083
+ operators: operators === null
1084
+ ? Object.freeze([])
1085
+ : Object.freeze(Object.keys(operators.extensions)),
1086
+ // the subset this driver actually pushes into SQL as
1087
+ // deterministic UDFs (Ring 3): `pushable:'scalar'` operators
1088
+ // when the driver has user functions; `[]` on bun:sqlite
1089
+ // (no UDF API — always the residual) and without a registry
1090
+ pushableOperators: operators === null || connection.capabilities.userFunctions !== true
1091
+ ? Object.freeze([])
1092
+ : Object.freeze([...operators.pushableScalar]),
1093
+ capture: captureMode,
1094
+ captureLog: captureMode !== 'none'
1095
+ && (captureRequested.log === true
1096
+ || (captureRequested.log !== undefined && captureRequested.log !== false)),
1097
+ live: captureMode !== 'none',
1098
+ jobs: options.jobs === true
1099
+ || (options.jobs !== undefined && options.jobs !== false),
1100
+ });
1101
+
1102
+ const queryState = createQueryState(options.statementCacheBound, operators);
1103
+ const entityEngine = entities.size > 0
1104
+ ? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
1105
+ : null;
1106
+ /** @type {Map<string, any>} */
1107
+ const loadEngines = new Map();
1108
+ const loadEngineFor = (name) => {
1109
+ let engine = loadEngines.get(name);
1110
+ if (engine === undefined) {
1111
+ if (!entities.has(name)) {
1112
+ throw new DbRuntimeError('JD2004',
1113
+ `the model declares no entity '${name}'`,
1114
+ { docPath: '/entities', collection: name });
1115
+ }
1116
+ engine = createLoadEngine(
1117
+ { connection, entities, mapping, state: queryState }, name);
1118
+ loadEngines.set(name, engine);
1119
+ }
1120
+ return engine;
1121
+ };
1122
+ /** @type {Map<string, any>} */
1123
+ const entityCores = new Map();
1124
+ const entityCoreFor = (name) => {
1125
+ let core = entityCores.get(name);
1126
+ if (core === undefined) {
1127
+ const entity = entities.get(name);
1128
+ if (entity === undefined) {
1129
+ throw new DbRuntimeError('JD2004',
1130
+ `the model declares no entity '${name}'`,
1131
+ { docPath: '/entities', collection: name });
1132
+ }
1133
+ const validate = options.compileSchema !== undefined
1134
+ ? options.compileSchema(entity.schema)
1135
+ : null;
1136
+ core = captureEntity(name, entityCore(connection, entity,
1137
+ mapping.entities[name], validate));
1138
+ entityCores.set(name, core);
1139
+ }
1140
+ return core;
1141
+ };
1142
+
1143
+ const tracker = entities.size > 0
1144
+ ? createTracker({
1145
+ connection, entities, mapping, coreFor: entityCoreFor,
1146
+ captureRecord: capture === null || capture.mode !== 'journal'
1147
+ ? undefined
1148
+ : (table, keyParts, before, after) => capture.record(table, keyParts,
1149
+ before === undefined ? undefined : stripRelations(table, before),
1150
+ stripRelations(table, after)),
1151
+ captureJoinDelete: captureJoinDelete ?? undefined,
1152
+ })
1153
+ : null;
1154
+ /** @type {Map<string, any>} */
1155
+ const trackedOps = new Map();
1156
+ // the unit-of-work surface (§11): reads register frozen
1157
+ // snapshots; add/put/remove are LOCAL bookkeeping (no
1158
+ // database round trip, deliberately synchronous on both
1159
+ // surfaces); asNoTracking() reads retain nothing
1160
+ const trackedOpsFor = (name) => {
1161
+ let ops = trackedOps.get(name);
1162
+ if (ops !== undefined) return ops;
1163
+ const core = entityCoreFor(name);
1164
+ const loads = loadEngineFor(name);
1165
+ ops = {
1166
+ create: (doc) => chain(core.create(doc),
1167
+ (made) => tracker.register(name, made)),
1168
+ get: (key) => chain(core.get(key), (doc) =>
1169
+ (doc === undefined ? undefined : tracker.register(name, doc))),
1170
+ update: (key, changes) => chain(core.update(key, changes),
1171
+ (next) => tracker.register(name, next)),
1172
+ delete: (key) => chain(core.delete(key), (done) => {
1173
+ tracker.discard(name, key);
1174
+ return done;
1175
+ }),
1176
+ load: (spec) => chain(loads.load(spec),
1177
+ (docs) => tracker.registerGraph(loads.treeFor(spec), docs)),
1178
+ explainLoad: (spec) => loads.explainLoad(spec),
1179
+ add: (doc) => tracker.add(name, doc),
1180
+ put: (next) => tracker.put(name, next),
1181
+ remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
1182
+ discard: (keyOrDoc) => tracker.discard(name, keyOrDoc),
1183
+ noTracking: {
1184
+ get: (key) => core.get(key),
1185
+ load: (spec) => loads.load(spec),
1186
+ },
1187
+ };
1188
+ trackedOps.set(name, ops);
1189
+ return ops;
1190
+ };
1191
+
1192
+ /** @type {Map<string, any>} */
1193
+ const asyncHandles = new Map();
1194
+ /** @type {Map<string, any>} */
1195
+ const asyncEntityHandles = new Map();
1196
+ const store = {
1197
+ capabilities,
1198
+ stats: () => ({
1199
+ statementCache: { ...queryState.counters },
1200
+ udfRegistrations: queryState.registered.size,
1201
+ tracker: tracker === null ? null : tracker.counts(),
1202
+ liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
1203
+ }),
1204
+ dialect,
1205
+ collection(name) {
1206
+ let handle = asyncHandles.get(name);
1207
+ if (handle === undefined) {
1208
+ handle = asyncCollection(coreFor(name),
1209
+ liveRegistry === null ? null : registerCollectionLive);
1210
+ asyncHandles.set(name, handle);
1211
+ }
1212
+ return handle;
1213
+ },
1214
+ entity(name) {
1215
+ let handle = asyncEntityHandles.get(name);
1216
+ if (handle === undefined) {
1217
+ const ops = trackedOpsFor(name);
1218
+ const untracked = Object.freeze({
1219
+ get: lift((key) => ops.noTracking.get(key)),
1220
+ load: lift((spec) => ops.noTracking.load(spec)),
1221
+ });
1222
+ handle = Object.freeze({
1223
+ create: lift((doc) => ops.create(doc)),
1224
+ get: lift((key) => ops.get(key)),
1225
+ update: lift((key, changes) => ops.update(key, changes)),
1226
+ delete: lift((key) => ops.delete(key)),
1227
+ load: lift((spec) => ops.load(spec)),
1228
+ explainLoad: ops.explainLoad,
1229
+ add: ops.add,
1230
+ put: ops.put,
1231
+ remove: ops.remove,
1232
+ discard: ops.discard,
1233
+ asNoTracking: () => untracked,
1234
+ });
1235
+ asyncEntityHandles.set(name, handle);
1236
+ }
1237
+ return handle;
1238
+ },
1239
+ saveChanges: entities.size === 0 ? undefined
1240
+ : lift(() => guard(() => tracker.saveChanges())),
1241
+ // entity DOCUMENTS query the multi-entity root at the store
1242
+ execute: entityEngine === null ? undefined
1243
+ : (document, queryOptions) => entityEngine.execute(document, queryOptions),
1244
+ explain: entityEngine === null ? undefined
1245
+ : lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
1246
+ // entity live queries re-run on invalidation — declared,
1247
+ // not attempted (LIVE-FORMAT §7)
1248
+ live: entityEngine === null ? undefined
1249
+ : lift((document, liveOptions) => {
1250
+ if (liveRegistry === null) {
1251
+ throw new DbCompileError('JD0050',
1252
+ 'live queries require change capture — open the store with { capture: true }');
1253
+ }
1254
+ const roots = collectEntityRoots(document, entities);
1255
+ if (roots.size === 0) {
1256
+ throw new TypeError(
1257
+ 'store.live takes an entity-root document — for a collection, '
1258
+ + 'use store.collection(name).live');
1259
+ }
1260
+ return liveRegistry.register({
1261
+ name: [...roots].join('+'),
1262
+ tables: roots,
1263
+ document,
1264
+ externals: liveOptions?.externals ?? {},
1265
+ demanded: liveOptions?.mode,
1266
+ classification: {
1267
+ strategy: 'rerun',
1268
+ reason: 'entity queries re-run in this version',
1269
+ },
1270
+ execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
1271
+ readRow: null,
1272
+ keyOf: null,
1273
+ });
1274
+ }),
1275
+ // A TOP-LEVEL transaction: it takes the connection's gate, so
1276
+ // it never shares a savepoint stack with another one. To nest,
1277
+ // use the store the callback RECEIVES — the outer store cannot
1278
+ // tell an inner transaction from an unrelated caller, and an
1279
+ // unrelated caller must wait for the commit.
1280
+ transaction: lift((fn) => topLevelTransaction(fn)),
1281
+ observe: (fn) => {
1282
+ if (capture === null) {
1283
+ throw new TypeError(
1284
+ 'observe needs capture — open the store with { capture: true }');
1285
+ }
1286
+ return capture.observe(fn);
1287
+ },
1288
+ changesSince: capture === null ? undefined
1289
+ : lift((after) => capture.changesSince(after)),
1290
+ dataVersion: lift(() => chain(
1291
+ connection.prepare(dialect.introspect.dataVersion()),
1292
+ (statement) => chain(statement.get([]), (row) => Number(row.v)))),
1293
+ jobs: jobsEngine === null ? undefined : Object.freeze({
1294
+ enqueue: lift(jobsEngine.enqueue),
1295
+ get: lift(jobsEngine.get),
1296
+ counts: lift(jobsEngine.counts),
1297
+ claim: lift(jobsEngine.claim),
1298
+ complete: lift(jobsEngine.complete),
1299
+ fail: lift(jobsEngine.fail),
1300
+ checkpointsFor: jobsEngine.checkpointsFor,
1301
+ createWorker: jobsEngine.createWorker,
1302
+ }),
1303
+ /**
1304
+ * Close the store. Job workers are asked to stop and given a
1305
+ * bounded grace period; the connection is then closed WHETHER
1306
+ * OR NOT a handler wound up. That bound is the point: an
1307
+ * unbounded wait let one handler that never settles hold the
1308
+ * database file open for the life of the process, which is how
1309
+ * an abandoned worker in the suite left a locked file behind.
1310
+ * @param {{ graceMs?: number }} [closeOptions]
1311
+ */
1312
+ close: lift((closeOptions) => {
1313
+ if (liveRegistry !== null) liveRegistry.closeAll();
1314
+ return chain(
1315
+ jobsEngine === null ? null : jobsEngine.stopAll(closeOptions),
1316
+ (stopped) => chain(connection.close(), () => {
1317
+ const stuck = (stopped ?? []).filter(
1318
+ (/** @type {any} */ outcome) => outcome.drained === false);
1319
+ if (stuck.length === 0) return undefined;
1320
+ // reported, not swallowed: the handle is released, but
1321
+ // handlers are still running against a closed connection
1322
+ throw new DbRuntimeError('JD2062',
1323
+ `the store closed with ${stuck.reduce(
1324
+ (/** @type {number} */ n, /** @type {any} */ o) => n + o.inFlight, 0)} `
1325
+ + `job handler(s) still in flight across ${stuck.length} worker(s); `
1326
+ + 'they were signalled to abort and did not settle within the grace period');
1327
+ }));
1328
+ }),
1329
+ };
1330
+
1331
+ // The transaction callback's argument. It is the store, with one
1332
+ // difference that matters: its `transaction` NESTS through the
1333
+ // owning savepoint instead of queueing behind it. Everything
1334
+ // else already reaches the open transaction, because the cores
1335
+ // read the active scope.
1336
+ /** @type {any} */
1337
+ let txStore = null;
1338
+ /** Nest through the savepoint that owns the connection now. The
1339
+ * capture scope goes INSIDE the savepoint, so a rollback undoes
1340
+ * the translated patch with the rows it describes. */
1341
+ const nested = (/** @type {any} */ fn) => withScope(scope.transaction,
1342
+ (tx) => (capture === null ? fn(tx) : capture.nest(() => fn(tx))));
1343
+ /** The overriding member on a view of the FROZEN store: plain
1344
+ * assignment cannot shadow a non-writable inherited property. */
1345
+ const override = (/** @type {any} */ value) =>
1346
+ ({ value, writable: false, enumerable: true, configurable: false });
1347
+ scopedStore = () => {
1348
+ if (txStore === null) {
1349
+ const members = { transaction: override(nested) };
1350
+ if (store.sync !== undefined) {
1351
+ members.sync = override(Object.create(store.sync,
1352
+ { transaction: override(nested) }));
1353
+ }
1354
+ txStore = Object.freeze(Object.create(store, members));
1355
+ }
1356
+ return txStore;
1357
+ };
1358
+
1359
+ if (connection.synchronous) {
1360
+ /** @type {Map<string, any>} */
1361
+ const syncHandles = new Map();
1362
+ store.sync = Object.freeze({
1363
+ collection(name) {
1364
+ let handle = syncHandles.get(name);
1365
+ if (handle === undefined) {
1366
+ const core = coreFor(name);
1367
+ handle = Object.freeze({
1368
+ stats: () => core.stats(),
1369
+ get: (key) => core.get(key),
1370
+ insert: (doc) => core.insert(doc),
1371
+ put: (doc, key) => core.put(doc, key),
1372
+ patch: (key, ops) => core.patch(key, ops),
1373
+ delete: (key) => core.delete(key),
1374
+ execute: (document, options) => core.execute(document, options),
1375
+ explain: (document, options) => core.explain(document, options),
1376
+ });
1377
+ syncHandles.set(name, handle);
1378
+ }
1379
+ return handle;
1380
+ },
1381
+ transaction: (fn) => topLevelTransaction(fn),
1382
+ entity(name) {
1383
+ const ops = trackedOpsFor(name);
1384
+ const untracked = Object.freeze({
1385
+ get: (key) => ops.noTracking.get(key),
1386
+ load: (spec) => ops.noTracking.load(spec),
1387
+ });
1388
+ return Object.freeze({
1389
+ create: (doc) => ops.create(doc),
1390
+ get: (key) => ops.get(key),
1391
+ update: (key, changes) => ops.update(key, changes),
1392
+ delete: (key) => ops.delete(key),
1393
+ load: (spec) => ops.load(spec),
1394
+ explainLoad: ops.explainLoad,
1395
+ add: ops.add,
1396
+ put: ops.put,
1397
+ remove: ops.remove,
1398
+ discard: ops.discard,
1399
+ asNoTracking: () => untracked,
1400
+ });
1401
+ },
1402
+ saveChanges: entities.size === 0 ? undefined
1403
+ : () => guard(() => tracker.saveChanges()),
1404
+ execute: entityEngine === null ? undefined
1405
+ : (document, queryOptions) => entityEngine.execute(document, queryOptions),
1406
+ });
1407
+ }
1408
+ return chain(capture === null ? null : capture.ready,
1409
+ () => chain(jobsEngine === null ? null : jobsEngine.ready,
1410
+ () => Object.freeze(store)));
1411
+ })));
1412
+
1413
+ let opened_;
1414
+ try {
1415
+ opened_ = opening();
1416
+ }
1417
+ catch (error) {
1418
+ return failClosed(error);
1419
+ }
1420
+ return isThenable(opened_) ? opened_.then((value) => value, failClosed) : opened_;
1421
+ }));
1422
+ }