@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/capture.js ADDED
@@ -0,0 +1,584 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Change capture (D13): committed writes become an observable,
4
+ * ordered stream of RFC 6902 patches — derived from SQLite's own
5
+ * session changesets where the binding has them, from a write-path
6
+ * journal where it does not (`bun:sqlite` has no `createSession`), or
7
+ * off entirely. One diff format then runs end to end: store → patch →
8
+ * live query → patch → O(k) render.
9
+ *
10
+ * The pointer contract (LIVE-FORMAT §2): `/<table>/<key>/<path…>`,
11
+ * every token escaped per RFC 6901. A single key renders as its
12
+ * scalar text (integers in decimal); a composite key renders as the
13
+ * JSON text of its parts array. Join-table rows are tiny documents
14
+ * under the join table's name — membership changes are part of the
15
+ * stream, not a blind spot.
16
+ *
17
+ * Session facts this file is built on (probed, 3.51.2):
18
+ * - a changeset carries ONE NET OP PER ROW (insert+update coalesce;
19
+ * insert+delete vanish; a no-op update is absent), and within-table
20
+ * order is NOT statement order — every row op targets a distinct
21
+ * pointer, so application order across rows cannot matter;
22
+ * - `ROLLBACK TO` a savepoint removes the undone rows from the
23
+ * session (pinned by test — the classic caveat does NOT hold here);
24
+ * - a rolled-back transaction yields an empty changeset;
25
+ * - virtual generated columns are invisible;
26
+ * - an UPDATE's old record carries the primary key and the CHANGED
27
+ * columns only — which is exactly enough for property-level ops,
28
+ * and why the doc column's old/new blobs make a minimal nested
29
+ * diff possible (`SELECT json(?)` turns JSONB back into text).
30
+ */
31
+
32
+ import { createJSONPatch } from '@jarenjs/json/patch';
33
+ import { encodeJSONPointerSegment, decodeJSONPointerSegment } from '@jarenjs/json/pointer';
34
+
35
+ import { DbRuntimeError } from './errors.js';
36
+ import { chain } from './driver.js';
37
+
38
+ /** The persisted change log (LIVE-FORMAT §5). */
39
+ export const CHANGES_TABLE = '_jaren_changes';
40
+ export const DEFAULT_RETENTION = 1000;
41
+
42
+ //#region the binary changeset parser
43
+
44
+ const OP_INSERT = 18;
45
+ const OP_UPDATE = 23;
46
+ const OP_DELETE = 9;
47
+
48
+ /**
49
+ * Parse one SQLite varint (1–9 bytes, big-endian 7-bit groups, the
50
+ * ninth byte carrying 8 bits).
51
+ * @param {Uint8Array} bytes
52
+ * @param {number} at
53
+ * @returns {{ value: number, next: number }}
54
+ */
55
+ function readVarint(bytes, at) {
56
+ let value = 0;
57
+ for (let i = 0; i < 8; i++) {
58
+ const byte = bytes[at + i];
59
+ if ((byte & 0x80) === 0) return { value: value * 128 + byte, next: at + i + 1 };
60
+ value = value * 128 + (byte & 0x7f);
61
+ }
62
+ return { value: value * 256 + bytes[at + 8], next: at + 9 };
63
+ }
64
+
65
+ const utf8 = new TextDecoder();
66
+
67
+ /**
68
+ * Read one value record. `undefined` means "not present in this
69
+ * record" (an unchanged column); `null` is SQL NULL.
70
+ * @param {Uint8Array} bytes
71
+ * @param {number} at
72
+ * @returns {{ value: any, next: number }}
73
+ */
74
+ function readValue(bytes, at) {
75
+ const type = bytes[at];
76
+ at += 1;
77
+ if (type === 0) return { value: undefined, next: at };
78
+ if (type === 5) return { value: null, next: at };
79
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
80
+ if (type === 1) {
81
+ const big = view.getBigInt64(at);
82
+ // JS documents cannot produce integers beyond 2^53 through this
83
+ // store; a raw-SQL value beyond it converts lossily (documented)
84
+ return { value: Number(big), next: at + 8 };
85
+ }
86
+ if (type === 2) return { value: view.getFloat64(at), next: at + 8 };
87
+ const { value: length, next } = readVarint(bytes, at);
88
+ const slice = bytes.subarray(next, next + length);
89
+ if (type === 3) return { value: utf8.decode(slice), next: next + length };
90
+ if (type === 4) return { value: slice.slice(), next: next + length };
91
+ throw new DbRuntimeError('JD2050',
92
+ `unknown changeset value type ${type} at offset ${at - 1}`);
93
+ }
94
+
95
+ /**
96
+ * Decode a binary changeset into row operations.
97
+ * @param {Uint8Array} bytes
98
+ * @returns {{ table: string, pk: boolean[], op: 'insert'|'update'|'delete',
99
+ * indirect: boolean, oldValues: any[] | null, newValues: any[] | null }[]}
100
+ */
101
+ export function parseChangeset(bytes) {
102
+ /** @type {any[]} */
103
+ const operations = [];
104
+ let at = 0;
105
+ let table = '';
106
+ let columnCount = 0;
107
+ /** @type {boolean[]} */
108
+ let pk = [];
109
+ const readRecord = () => {
110
+ const values = [];
111
+ for (let i = 0; i < columnCount; i++) {
112
+ const read = readValue(bytes, at);
113
+ values.push(read.value);
114
+ at = read.next;
115
+ }
116
+ return values;
117
+ };
118
+ while (at < bytes.length) {
119
+ const marker = bytes[at];
120
+ if (marker === 0x54 /* 'T' */ || marker === 0x50 /* 'P' */) {
121
+ at += 1;
122
+ const n = readVarint(bytes, at);
123
+ columnCount = n.value;
124
+ at = n.next;
125
+ pk = [];
126
+ for (let i = 0; i < columnCount; i++) pk.push(bytes[at + i] !== 0);
127
+ at += columnCount;
128
+ let end = at;
129
+ while (bytes[end] !== 0) end += 1;
130
+ table = utf8.decode(bytes.subarray(at, end));
131
+ at = end + 1;
132
+ continue;
133
+ }
134
+ const op = bytes[at];
135
+ const indirect = bytes[at + 1] !== 0;
136
+ at += 2;
137
+ if (op === OP_INSERT) {
138
+ operations.push({ table, pk, op: 'insert', indirect,
139
+ oldValues: null, newValues: readRecord() });
140
+ }
141
+ else if (op === OP_DELETE) {
142
+ operations.push({ table, pk, op: 'delete', indirect,
143
+ oldValues: readRecord(), newValues: null });
144
+ }
145
+ else if (op === OP_UPDATE) {
146
+ const oldValues = readRecord();
147
+ operations.push({ table, pk, op: 'update', indirect,
148
+ oldValues, newValues: readRecord() });
149
+ }
150
+ else {
151
+ throw new DbRuntimeError('JD2050',
152
+ `unknown changeset op ${op} at offset ${at - 2}`);
153
+ }
154
+ }
155
+ return operations;
156
+ }
157
+
158
+ //#endregion
159
+
160
+ //#region the pointer contract
161
+
162
+ /**
163
+ * The key token (LIVE-FORMAT §2): a single key is its scalar text;
164
+ * a composite key is the JSON text of its parts array.
165
+ * @param {any[]} parts
166
+ * @returns {string}
167
+ */
168
+ export function keyToken(parts) {
169
+ return parts.length === 1 ? String(parts[0]) : JSON.stringify(parts);
170
+ }
171
+
172
+ const pointerOf = (table, token, path = '') =>
173
+ `/${encodeJSONPointerSegment(table)}/${encodeJSONPointerSegment(token)}${path}`;
174
+
175
+ //#endregion
176
+
177
+ //#region translation
178
+
179
+ /**
180
+ * @typedef {{ kind: 'collection' | 'entity' | 'join',
181
+ * columns: { name: string, role: 'key'|'doc'|'scalar'|'fk'|'epoch',
182
+ * storage?: string }[],
183
+ * keyIndexes: number[], docIndex: number }} TableShape
184
+ */
185
+
186
+ /** Decode one stored column value to its DOCUMENT-level value. */
187
+ function documentValue(column, value) {
188
+ if (value === null || value === undefined) return value;
189
+ if (column.storage === 'boolean') return value === 1;
190
+ return value;
191
+ }
192
+
193
+ /**
194
+ * Translate parsed row operations into RFC 6902 ops, resolving JSONB
195
+ * blobs through the connection (`SELECT json(?)`).
196
+ * @param {any} connection
197
+ * @param {Map<string, TableShape>} shapes
198
+ * @param {any[]} operations
199
+ * @returns {any} value-or-promise of RFC 6902 ops
200
+ */
201
+ export function translateOperations(connection, shapes, operations) {
202
+ const jsonOf = (blob) => (blob === undefined || blob === null
203
+ ? blob
204
+ : chain(connection.prepare('SELECT json(?) AS t'), (statement) =>
205
+ chain(statement.get([blob]), (row) => JSON.parse(row.t))));
206
+
207
+ /** @type {any[]} */
208
+ const ops = [];
209
+ /** Translate ONE operation, mutating `ops`; value-or-promise. The
210
+ * caller drives ITERATIVELY — a self-recursive walk overflowed the
211
+ * stack on a 10k-row transaction (found by the live-query
212
+ * measurement, fixed here). */
213
+ const translateOne = (operation) => {
214
+ const shape = shapes.get(operation.table);
215
+ if (shape === undefined) return null; // an internal table
216
+ const record = operation.newValues ?? operation.oldValues ?? [];
217
+ const token = keyToken(shape.keyIndexes.map((index) =>
218
+ (operation.op === 'update'
219
+ ? operation.oldValues?.[index]
220
+ : record[index])));
221
+
222
+ if (shape.kind === 'join') {
223
+ // membership rows are tiny documents under the join table's name
224
+ if (operation.op === 'insert') {
225
+ const value = {};
226
+ for (let c = 0; c < shape.columns.length; c++)
227
+ value[shape.columns[c].name] = record[c];
228
+ ops.push({ op: 'add', path: pointerOf(operation.table, token), value });
229
+ }
230
+ else if (operation.op === 'delete') {
231
+ ops.push({ op: 'remove', path: pointerOf(operation.table, token) });
232
+ }
233
+ return null;
234
+ }
235
+
236
+ const buildDocument = (values) => chain(
237
+ jsonOf(values[shape.docIndex]), (doc) => {
238
+ const out = doc ?? {};
239
+ for (let c = 0; c < shape.columns.length; c++) {
240
+ const column = shape.columns[c];
241
+ if (column.role === 'doc' || column.role === 'epoch') continue;
242
+ // a collection's key column is physical bookkeeping — the
243
+ // document carries its own key member per the model
244
+ if (shape.kind === 'collection' && column.role === 'key') continue;
245
+ const value = values[c];
246
+ if (value === null || value === undefined) continue;
247
+ out[column.name] = documentValue(column, value);
248
+ }
249
+ return out;
250
+ });
251
+
252
+ if (operation.op === 'insert') {
253
+ return chain(buildDocument(record), (doc) => {
254
+ ops.push({ op: 'add', path: pointerOf(operation.table, token), value: doc });
255
+ return null;
256
+ });
257
+ }
258
+ if (operation.op === 'delete') {
259
+ ops.push({ op: 'remove', path: pointerOf(operation.table, token) });
260
+ return null;
261
+ }
262
+ // update: property-level ops from the CHANGED columns; the doc
263
+ // column diffs old against new for a minimal nested patch
264
+ const columnOps = () => {
265
+ for (let c = 0; c < shape.columns.length; c++) {
266
+ const column = shape.columns[c];
267
+ const newValue = operation.newValues[c];
268
+ if (newValue === undefined || column.role === 'doc') continue;
269
+ if (column.role === 'epoch') continue; // derived; the doc string decides
270
+ if (shape.kind === 'collection' && column.role === 'key') continue;
271
+ const oldValue = operation.oldValues[c];
272
+ const path = pointerOf(operation.table, token, `/${encodeJSONPointerSegment(column.name)}`);
273
+ if (newValue === null) {
274
+ if (oldValue !== null) ops.push({ op: 'remove', path });
275
+ }
276
+ else if (oldValue === null) {
277
+ ops.push({ op: 'add', path, value: documentValue(column, newValue) });
278
+ }
279
+ else {
280
+ ops.push({ op: 'replace', path, value: documentValue(column, newValue) });
281
+ }
282
+ }
283
+ };
284
+ const newDocBlob = operation.newValues[shape.docIndex];
285
+ if (newDocBlob === undefined) {
286
+ columnOps();
287
+ return null;
288
+ }
289
+ return chain(jsonOf(operation.oldValues[shape.docIndex]), (oldDoc) =>
290
+ chain(jsonOf(newDocBlob), (newDoc) => {
291
+ columnOps();
292
+ const prefix = pointerOf(operation.table, token);
293
+ for (const op of createJSONPatch(oldDoc ?? {}, newDoc ?? {})) {
294
+ ops.push({ ...op, path: `${prefix}${op.path}`,
295
+ ...(op.from !== undefined ? { from: `${prefix}${op.from}` } : {}) });
296
+ }
297
+ return null;
298
+ }));
299
+ };
300
+ let index = 0;
301
+ const drive = () => {
302
+ while (index < operations.length) {
303
+ const outcome = translateOne(operations[index]);
304
+ index += 1;
305
+ if (outcome instanceof Promise) return outcome.then(drive);
306
+ }
307
+ return ops;
308
+ };
309
+ return drive();
310
+ }
311
+
312
+ //#endregion
313
+
314
+ //#region the capture engine
315
+
316
+ /**
317
+ * @param {{ connection: any, shapes: Map<string, TableShape>,
318
+ * mode: 'session' | 'journal',
319
+ * log: boolean, retention: number }} options
320
+ * @returns {any}
321
+ */
322
+ export function createCaptureEngine(options) {
323
+ const { connection, shapes, mode } = options;
324
+ const dialect = connection.dialect;
325
+ const q = dialect.quoteIdentifier;
326
+
327
+ /** @type {Set<Function>} */
328
+ const observers = new Set();
329
+ let seq = 0;
330
+ let depth = 0;
331
+ /** @type {any} */
332
+ let session = null;
333
+ /** @type {any[]} */
334
+ let journal = [];
335
+ /** @type {any[]} */
336
+ const pendingDeliveries = [];
337
+
338
+ const logStatements = options.log ? {
339
+ create: dialect.ddl.createPlainTable({
340
+ table: CHANGES_TABLE,
341
+ columns: [
342
+ { name: 'seq', type: dialect.typeFor('integer', 'key'), primaryKey: true },
343
+ { name: 'at', type: dialect.typeFor('integer', 'key') },
344
+ { name: 'source', type: dialect.typeFor('string', 'key') },
345
+ { name: 'patch', type: dialect.typeFor('string', 'key') },
346
+ ],
347
+ }),
348
+ insert: `INSERT INTO ${q(CHANGES_TABLE)} `
349
+ + `(${['seq', 'at', 'source', 'patch'].map(q).join(', ')}) `
350
+ + `VALUES (${[1, 2, 3, 4].map((i) => dialect.parameterRef(i, 'v')).join(', ')})`,
351
+ prune: `DELETE FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} <= ${dialect.parameterRef(1, 'v')}`,
352
+ highest: `SELECT MAX(${q('seq')}) AS ${q('n')} FROM ${q(CHANGES_TABLE)}`,
353
+ read: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
354
+ + `FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} > ${dialect.parameterRef(1, 'v')} `
355
+ + `ORDER BY ${q('seq')}`,
356
+ } : null;
357
+
358
+ const ready = logStatements === null
359
+ ? null
360
+ : chain(connection.exec(logStatements.create), () =>
361
+ chain(connection.prepare(logStatements.highest), (statement) =>
362
+ chain(statement.get([]), (row) => {
363
+ seq = Number(row?.n ?? 0) || 0;
364
+ return null;
365
+ })));
366
+
367
+ /** A patch value must be JSON: a `{ ...doc, m: undefined }` write
368
+ * stores the member ABSENT, and the journal must record the JSON
369
+ * reality, not the JavaScript artifact. `undefined`/`null` sentinels
370
+ * pass through untouched. */
371
+ const jsonReality = (doc) => (doc === undefined || doc === null
372
+ ? doc
373
+ : JSON.parse(JSON.stringify(doc)));
374
+
375
+ /** Journal-mode emission from the write paths. */
376
+ const record = (table, keyParts, before, after) => {
377
+ if (mode !== 'journal' || depth === 0) return;
378
+ journal.push({ table, keyParts,
379
+ before: jsonReality(before), after: jsonReality(after) });
380
+ };
381
+
382
+ const journalOps = () => {
383
+ // ONE NET OP PER ROW (§2), the same discipline sessions get for
384
+ // free: fold every entry for one (table, key) into first-before /
385
+ // last-after, so insert+update coalesces, insert+delete vanishes,
386
+ // and an update back to the original emits nothing. Found by the
387
+ // wasm parity suite — the original differential script never wrote
388
+ // the same row twice in one transaction.
389
+ /** @type {Map<string, any>} */
390
+ const netted = new Map();
391
+ /** @type {string[]} */
392
+ const order = [];
393
+ for (const entry of journal) {
394
+ const token = keyToken(entry.keyParts);
395
+ const key = `${entry.table}\u0000${token}`;
396
+ const existing = netted.get(key);
397
+ if (existing === undefined) {
398
+ netted.set(key, {
399
+ table: entry.table, token,
400
+ before: entry.before, after: entry.after,
401
+ });
402
+ order.push(key);
403
+ }
404
+ else {
405
+ existing.after = entry.after;
406
+ }
407
+ }
408
+ /** @type {any[]} */
409
+ const ops = [];
410
+ for (const key of order) {
411
+ const entry = netted.get(key);
412
+ const prefix = pointerOf(entry.table, entry.token);
413
+ if (entry.before === null && entry.after === null) continue;
414
+ if (entry.before === null) {
415
+ ops.push({ op: 'add', path: prefix, value: entry.after });
416
+ }
417
+ else if (entry.after === null) {
418
+ ops.push({ op: 'remove', path: prefix });
419
+ }
420
+ else {
421
+ for (const op of createJSONPatch(entry.before, entry.after)) {
422
+ ops.push({ ...op, path: `${prefix}${op.path}`,
423
+ ...(op.from !== undefined ? { from: `${prefix}${op.from}` } : {}) });
424
+ }
425
+ }
426
+ }
427
+ return ops;
428
+ };
429
+
430
+ /** Collect this commit's patch (inside the transaction). */
431
+ const collect = () => {
432
+ if (mode === 'session') {
433
+ const changeset = session.changeset();
434
+ session.close();
435
+ session = null;
436
+ if (changeset.length === 0) return [];
437
+ return translateOperations(connection, shapes, parseChangeset(changeset));
438
+ }
439
+ const ops = journalOps();
440
+ journal = [];
441
+ return ops;
442
+ };
443
+
444
+ const persist = (patch, at) => {
445
+ if (logStatements === null || patch.length === 0) return null;
446
+ seq += 1;
447
+ const mySeq = seq;
448
+ return chain(connection.prepare(logStatements.insert), (insert) =>
449
+ chain(insert.run([mySeq, at, mode, JSON.stringify(patch)]), () =>
450
+ chain(connection.prepare(logStatements.prune), (prune) =>
451
+ chain(prune.run([mySeq - options.retention]), () => null))));
452
+ };
453
+
454
+ const deliver = () => {
455
+ while (pendingDeliveries.length > 0) {
456
+ const delivery = pendingDeliveries.shift();
457
+ for (const observer of [...observers]) {
458
+ // error isolation: a throwing observer must never affect the
459
+ // write (the app.observe discipline)
460
+ try {
461
+ observer(delivery);
462
+ }
463
+ catch {
464
+ // deliberately swallowed; the write already committed
465
+ }
466
+ }
467
+ }
468
+ };
469
+
470
+ /**
471
+ * Run `fn` inside the capture scope: the OUTERMOST scope opens a
472
+ * session (or journal buffer) plus a transaction, translates and
473
+ * persists inside it, and delivers to observers after commit.
474
+ */
475
+ const wrap = (fn) => {
476
+ if (depth > 0) return fn();
477
+ depth = 1;
478
+ if (mode === 'session') session = connection.session();
479
+ else journal = [];
480
+ const cleanupFailure = () => {
481
+ depth = 0;
482
+ if (session !== null) {
483
+ session.close();
484
+ session = null;
485
+ }
486
+ journal = [];
487
+ };
488
+ let outcome;
489
+ try {
490
+ outcome = connection.transaction(() =>
491
+ chain(fn(), (result) =>
492
+ chain(collect(), (patch) => {
493
+ if (patch.length === 0) return { result, delivery: null };
494
+ const at = Date.now();
495
+ return chain(persist(patch, at), () => ({
496
+ result,
497
+ delivery: {
498
+ seq: logStatements === null ? (seq += 1) : seq,
499
+ at,
500
+ source: mode,
501
+ collections: [...new Set(patch.map(
502
+ (op) => decodeJSONPointerSegment(op.path.split('/')[1]))),
503
+ ],
504
+ patch,
505
+ },
506
+ }));
507
+ })));
508
+ }
509
+ catch (error) {
510
+ cleanupFailure();
511
+ throw error;
512
+ }
513
+ const finish = (bundle) => {
514
+ depth = 0;
515
+ if (bundle.delivery !== null) pendingDeliveries.push(bundle.delivery);
516
+ deliver();
517
+ return bundle.result;
518
+ };
519
+ if (outcome instanceof Promise) {
520
+ return outcome.then(finish, (error) => {
521
+ cleanupFailure();
522
+ throw error;
523
+ });
524
+ }
525
+ return finish(outcome);
526
+ };
527
+
528
+ /**
529
+ * A NESTED transaction scope: in journal mode, records buffered by
530
+ * an inner transaction that rolls back (and whose failure the
531
+ * caller CATCHES) must vanish exactly as a session drops rows
532
+ * undone by ROLLBACK TO — checkpoint the buffer and truncate on
533
+ * failure.
534
+ */
535
+ const nest = (fn) => {
536
+ if (depth === 0) return wrap(fn);
537
+ if (mode !== 'journal') return fn();
538
+ const mark = journal.length;
539
+ let outcome;
540
+ try {
541
+ outcome = fn();
542
+ }
543
+ catch (error) {
544
+ journal.length = mark;
545
+ throw error;
546
+ }
547
+ if (outcome instanceof Promise) {
548
+ return outcome.then((value) => value, (error) => {
549
+ journal.length = mark;
550
+ throw error;
551
+ });
552
+ }
553
+ return outcome;
554
+ };
555
+
556
+ return {
557
+ mode,
558
+ ready,
559
+ wrap,
560
+ nest,
561
+ record,
562
+ observe(fn) {
563
+ if (typeof fn !== 'function')
564
+ throw new TypeError('observe needs a function');
565
+ observers.add(fn);
566
+ return () => observers.delete(fn);
567
+ },
568
+ changesSince(after) {
569
+ if (logStatements === null) {
570
+ throw new DbRuntimeError('JD2051',
571
+ 'the change log is not enabled — open the store with capture.log');
572
+ }
573
+ return chain(connection.prepare(logStatements.read), (statement) =>
574
+ chain(statement.all([after]), (rows) => rows.map((row) => ({
575
+ seq: Number(row.seq),
576
+ at: Number(row.at),
577
+ source: String(row.source),
578
+ patch: JSON.parse(row.patch),
579
+ }))));
580
+ },
581
+ };
582
+ }
583
+
584
+ //#endregion