@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/tracker.js ADDED
@@ -0,0 +1,776 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The unit of work (§11): copy-on-write change tracking and
4
+ * minimal writes. Materialised entities are plain, DEEP-FROZEN JSON —
5
+ * no proxies anywhere — and the tracker retains exactly ONE reference
6
+ * per tracked entity: the frozen document itself is the snapshot.
7
+ * Mutation is replacement (`put(next)`); `saveChanges()` diffs
8
+ * snapshot against current with the suite's own diff engine and plans
9
+ * the MINIMAL set of parameterised statements: scalar/epoch/foreign-
10
+ * key column writes, `jsonb_set`/`jsonb_remove` chains for document
11
+ * paths, join-table synchronisation for many-to-many members, and a
12
+ * counted whole-row fallback for anything untranslatable.
13
+ *
14
+ * Ordering never violates a foreign key mid-transaction: inserts run
15
+ * parent-first, deletes child-first, updates in between, join rows
16
+ * after both endpoints exist. A foreign-key cycle among the entities
17
+ * being inserted or deleted is `JD0040`, reported, never a deadlock.
18
+ * The whole save is one transaction; the tracker is mutated ONLY
19
+ * after commit, so a failed save leaves it exactly as it was and a
20
+ * retry is possible.
21
+ */
22
+
23
+ import { createJSONPatch } from '@jarenjs/json/patch';
24
+ import { parseJSONPointer } from '@jarenjs/json/pointer';
25
+
26
+ import { DbCompileError, DbRuntimeError } from './errors.js';
27
+ import { chain } from './driver.js';
28
+ import { translatePatch } from './patch-sql.js';
29
+
30
+ /** Rows per batched INSERT: bounded by the portable parameter budget. */
31
+ export const BATCH_PARAM_BUDGET = 900;
32
+ export const BATCH_ROW_BOUND = 100;
33
+
34
+ const UNIT_SEPARATOR = '';
35
+
36
+ /**
37
+ * Deep-freeze a JSON value in place and return it. Idempotent; shared
38
+ * substructure (a graph load's children) freezes once.
39
+ * @template T
40
+ * @param {T} value
41
+ * @returns {T}
42
+ */
43
+ export function deepFreeze(value) {
44
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value))
45
+ return value;
46
+ Object.freeze(value);
47
+ if (Array.isArray(value)) {
48
+ for (const item of value) deepFreeze(item);
49
+ return value;
50
+ }
51
+ for (const key of Object.keys(value)) deepFreeze(/** @type {any} */ (value)[key]);
52
+ return value;
53
+ }
54
+
55
+ /**
56
+ * The store-level unit of work.
57
+ * @param {{ connection: any, entities: Map<string, any>, mapping: any,
58
+ * coreFor: (name: string) => any }} context
59
+ * @returns {any}
60
+ */
61
+ export function createTracker(context) {
62
+ const { connection, entities, mapping, coreFor } = context;
63
+ const captureRecord = context.captureRecord ?? null;
64
+ const captureJoinDelete = context.captureJoinDelete ?? null;
65
+ const dialect = connection.dialect;
66
+ const q = dialect.quoteIdentifier;
67
+ const parameterAt = (i) => dialect.parameterRef(i, 'v');
68
+
69
+ /** @type {Map<string, any>} */
70
+ const records = new Map();
71
+ /** @type {Map<string, any>} */
72
+ const removals = new Map();
73
+ let pendingSequence = 0;
74
+
75
+ const keyOf = (entityName, parts) =>
76
+ `${entityName}${UNIT_SEPARATOR}${parts.join(UNIT_SEPARATOR)}`;
77
+
78
+ const recordKeyFor = (entityName, doc) => {
79
+ const plan = coreFor(entityName).plan;
80
+ const parts = plan.keys.map((k) => doc[k]);
81
+ if (parts.some((part) => typeof part !== 'string' && typeof part !== 'number'))
82
+ return null;
83
+ return keyOf(entityName, parts);
84
+ };
85
+
86
+ const contractError = (entityName, reason) => new DbRuntimeError('JD2003',
87
+ reason, {
88
+ docPath: entities.get(entityName)?.docPath ?? '/entities',
89
+ collection: entityName,
90
+ });
91
+
92
+ const register = (entityName, doc) => {
93
+ deepFreeze(doc);
94
+ const key = recordKeyFor(entityName, doc);
95
+ if (key === null) return doc; // no usable key — plain data
96
+ const existing = records.get(key);
97
+ // a re-read refreshes a CLEAN record; a dirty record stays
98
+ // authoritative for the save in flight (§11.1)
99
+ if (existing === undefined
100
+ || (existing.current === existing.snapshot && existing.pendingInsert !== true)) {
101
+ records.set(key, {
102
+ entity: entityName, snapshot: doc, current: doc, pendingInsert: false,
103
+ });
104
+ }
105
+ return doc;
106
+ };
107
+
108
+ /** Register a graph load: the root and every included child. */
109
+ const registerGraph = (tree, docs) => {
110
+ const walk = (node, doc) => {
111
+ register(node.entity.name, doc);
112
+ for (const include of node.includes) {
113
+ if (include.count === true) continue;
114
+ const value = doc[include.name];
115
+ if (include.many) {
116
+ for (const child of value ?? []) walk(include.child, child);
117
+ }
118
+ else if (value !== null && value !== undefined) {
119
+ walk(include.child, value);
120
+ }
121
+ }
122
+ };
123
+ for (const doc of docs) walk(tree, doc);
124
+ return docs;
125
+ };
126
+
127
+ const add = (entityName, doc) => {
128
+ const core = coreFor(entityName);
129
+ const completed = deepFreeze(core.complete(doc, { updating: false }));
130
+ const key = recordKeyFor(entityName, completed)
131
+ ?? `${entityName}${UNIT_SEPARATOR}#pending${pendingSequence++}`;
132
+ records.set(key, {
133
+ entity: entityName, snapshot: null, current: completed,
134
+ pendingInsert: true, pendingKey: key,
135
+ });
136
+ return completed;
137
+ };
138
+
139
+ const put = (entityName, next) => {
140
+ const core = coreFor(entityName);
141
+ if (next === null || typeof next !== 'object' || Array.isArray(next))
142
+ throw contractError(entityName, 'put() takes an entity document');
143
+ const key = recordKeyFor(entityName, next);
144
+ const record = key === null ? undefined : records.get(key);
145
+ if (record === undefined) {
146
+ throw new DbRuntimeError('JD2006',
147
+ `'${entityName}' is not tracked under that key — read it first, `
148
+ + 'add() it, or use the explicit update()',
149
+ { docPath: entities.get(entityName)?.docPath, collection: entityName });
150
+ }
151
+ core.validateOnly(next);
152
+ record.current = deepFreeze(next);
153
+ return record.current;
154
+ };
155
+
156
+ const remove = (entityName, keyOrDoc) => {
157
+ const core = coreFor(entityName);
158
+ const parts = core.normalizeKey(keyOrDoc);
159
+ const key = keyOf(entityName, parts);
160
+ const record = records.get(key);
161
+ if (record !== undefined && record.pendingInsert === true) {
162
+ records.delete(key); // added then removed: a no-op
163
+ return;
164
+ }
165
+ removals.set(key, {
166
+ entity: entityName,
167
+ parts,
168
+ snapshot: record?.snapshot ?? null,
169
+ });
170
+ };
171
+
172
+ const counts = () => {
173
+ let pendingInserts = 0;
174
+ for (const record of records.values()) {
175
+ if (record.pendingInsert === true) pendingInserts++;
176
+ }
177
+ return {
178
+ tracked: records.size - pendingInserts,
179
+ pendingInserts,
180
+ pendingDeletes: removals.size,
181
+ };
182
+ };
183
+
184
+ // ————— planning —————
185
+
186
+ /**
187
+ * Order entity names so referenced entities come first. Only edges
188
+ * between the given names constrain; a cycle (self-loops included)
189
+ * is `JD0040`.
190
+ */
191
+ const orderEntities = (names, verb) => {
192
+ const present = new Set(names);
193
+ const waiting = new Map(names.map((name) => [name,
194
+ new Set(mapping.entities[name].foreignKeys
195
+ .map((fk) => fk.references)
196
+ .filter((reference) => present.has(reference))),
197
+ ]));
198
+ /** @type {string[]} */
199
+ const out = [];
200
+ const placed = new Set();
201
+ while (out.length < names.length) {
202
+ const ready = [...waiting.entries()]
203
+ .filter(([name, deps]) => !placed.has(name)
204
+ && [...deps].every((dep) => placed.has(dep) && dep !== name))
205
+ .map(([name]) => name)
206
+ .sort();
207
+ if (ready.length === 0) {
208
+ const cycle = names.filter((name) => !placed.has(name)).sort();
209
+ throw new DbCompileError('JD0040',
210
+ `cannot order the ${verb} — these entities form a foreign-key `
211
+ + `cycle: ${cycle.join(' → ')} (break the save in two)`,
212
+ entities.get(cycle[0])?.docPath);
213
+ }
214
+ for (const name of ready) {
215
+ placed.add(name);
216
+ out.push(name);
217
+ }
218
+ }
219
+ return out;
220
+ };
221
+
222
+ /** Partition one update's diff into the statement ingredients. */
223
+ const partitionDiff = (entityName, record) => {
224
+ const core = coreFor(entityName);
225
+ const plan = core.plan;
226
+ const entity = entities.get(entityName);
227
+ const ops = createJSONPatch(record.snapshot, record.stamped);
228
+ const columnSets = new Map();
229
+ const docOps = [];
230
+ const m2mMembers = new Set();
231
+ let fallback = false;
232
+ for (const op of ops) {
233
+ if (op.from !== undefined) {
234
+ fallback = true;
235
+ continue;
236
+ }
237
+ const names = parseJSONPointer(op.path);
238
+ if (names.length === 0) {
239
+ fallback = true;
240
+ continue;
241
+ }
242
+ const first = names[0];
243
+ const property = entity.properties.get(first);
244
+ if (property?.relation !== undefined) {
245
+ if (property.relation.kind === 'manyToMany') {
246
+ m2mMembers.add(first);
247
+ continue;
248
+ }
249
+ throw contractError(entityName,
250
+ `'${first}' is a relation member — a materialised projection, `
251
+ + 'not stored state; change the related entities themselves');
252
+ }
253
+ if (first === plan.version) continue; // engine-owned (§11.5)
254
+ if (plan.columnSet.has(first)) {
255
+ const isEpoch = plan.scalarColumns.some(
256
+ (column) => column.name === first && column.epoch);
257
+ if (names.length !== 1) {
258
+ fallback = true;
259
+ continue;
260
+ }
261
+ const value = op.op === 'remove' ? undefined : op.value;
262
+ columnSets.set(first, plan.encodeColumn(first, value));
263
+ // the epoch string ALSO lives in the document
264
+ if (isEpoch) docOps.push(op);
265
+ continue;
266
+ }
267
+ docOps.push(op);
268
+ }
269
+ let docBuild = null;
270
+ if (!fallback && docOps.length > 0) {
271
+ const translated = translatePatch(docOps, record.snapshot, dialect);
272
+ if (translated === null) fallback = true;
273
+ else docBuild = translated;
274
+ }
275
+ return { columnSets, docBuild, m2mMembers, fallback };
276
+ };
277
+
278
+ /** Compute a many-to-many member's join-row difference by key sets. */
279
+ const joinDiff = (entityName, before, after, ownKey, member) => {
280
+ const entity = entities.get(entityName);
281
+ const relation = entity.properties.get(member).relation;
282
+ const targetKey = mapping.entities[relation.to].keys[0];
283
+ const extract = (value) => {
284
+ if (value === undefined || value === null) return [];
285
+ if (!Array.isArray(value)) {
286
+ throw contractError(entityName,
287
+ `'${member}' must be an array to synchronise its join table`);
288
+ }
289
+ return value.map((element) => {
290
+ const key = typeof element === 'string' || typeof element === 'number'
291
+ ? element
292
+ : element !== null && typeof element === 'object'
293
+ ? element[targetKey] : undefined;
294
+ if (typeof key !== 'string' && typeof key !== 'number') {
295
+ throw contractError(entityName,
296
+ `an element of '${member}' carries no usable '${targetKey}' key`);
297
+ }
298
+ return key;
299
+ });
300
+ };
301
+ // a snapshot that never LOADED the member knows nothing about the
302
+ // current membership — treating unknown as empty would re-insert
303
+ // existing rows (a UNIQUE violation the seeded corpus found); the
304
+ // save resolves unknowns by reading the join table first
305
+ const memberValue = before === null ? [] : before[member];
306
+ const beforeKeys = before !== null && memberValue === undefined
307
+ ? null
308
+ : [...new Set(extract(memberValue))];
309
+ return {
310
+ joinTable: relation.joinTable,
311
+ ownColumn: `${entityName}_key`,
312
+ targetColumn: `${relation.to}_key`,
313
+ ownKey,
314
+ beforeKeys,
315
+ afterKeys: [...new Set(extract(after[member]))],
316
+ };
317
+ };
318
+
319
+ /** Relation members riding a pending INSERT: many-to-many becomes
320
+ * join rows; anything else refuses — projections are not state. */
321
+ const insertRelationOps = (record) => {
322
+ const entity = entities.get(record.entity);
323
+ const plan = coreFor(record.entity).plan;
324
+ /** @type {any[]} */
325
+ const ops = [];
326
+ for (const property of entity.properties.values()) {
327
+ if (property.relation === undefined) continue;
328
+ const value = record.current[property.name];
329
+ if (value === undefined || (Array.isArray(value) && value.length === 0))
330
+ continue;
331
+ if (property.relation.kind !== 'manyToMany') {
332
+ throw contractError(record.entity,
333
+ `'${property.name}' is a relation member — a materialised `
334
+ + 'projection, not stored state; add the related entities themselves');
335
+ }
336
+ const ownKey = record.current[plan.keys[0]];
337
+ if (typeof ownKey !== 'string' && typeof ownKey !== 'number') {
338
+ throw contractError(record.entity,
339
+ `'${property.name}' membership needs the entity's own key at `
340
+ + 'add() time — save the entity first, then attach');
341
+ }
342
+ ops.push(joinDiff(record.entity, null, record.current, ownKey, property.name));
343
+ }
344
+ return ops;
345
+ };
346
+
347
+ /** Build the ordered statement list for the current tracked state. */
348
+ const planSave = () => {
349
+ /** @type {Map<string, any[]>} */
350
+ const inserts = new Map();
351
+ /** @type {any[]} */
352
+ const updates = [];
353
+ /** @type {any[]} */
354
+ const joinOps = [];
355
+ let fallbacks = 0;
356
+ const unversioned = new Set();
357
+
358
+ for (const record of records.values()) {
359
+ const core = coreFor(record.entity);
360
+ if (record.pendingInsert === true) {
361
+ let list = inserts.get(record.entity);
362
+ if (list === undefined) {
363
+ list = [];
364
+ inserts.set(record.entity, list);
365
+ }
366
+ list.push(record);
367
+ joinOps.push(...insertRelationOps(record));
368
+ continue;
369
+ }
370
+ if (record.current === record.snapshot) continue;
371
+ // probe before stamping: an update stamp must never turn a
372
+ // deep-equal replacement into a phantom write
373
+ if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
374
+ record.stamped = core.stampUpdated(record.current);
375
+ const parts = partitionDiff(record.entity, record);
376
+ for (const member of parts.m2mMembers) {
377
+ joinOps.push(joinDiff(record.entity, record.snapshot, record.stamped,
378
+ record.stamped[core.plan.keys[0]], member));
379
+ }
380
+ if (parts.columnSets.size === 0 && parts.docBuild === null
381
+ && !parts.fallback) {
382
+ // nothing but join-table changes (or a no-op put)
383
+ if (parts.m2mMembers.size > 0) record.joinOnly = true;
384
+ else record.stamped = undefined;
385
+ continue;
386
+ }
387
+ if (parts.fallback) fallbacks++;
388
+ if (core.plan.version === null) unversioned.add(record.entity);
389
+ updates.push({ record, parts });
390
+ }
391
+
392
+ /** @type {any[]} */
393
+ const deletes = [];
394
+ for (const removal of removals.values()) {
395
+ deletes.push(removal);
396
+ if (coreFor(removal.entity).plan.version === null)
397
+ unversioned.add(removal.entity);
398
+ }
399
+
400
+ // resolve unknown membership baselines, then finalize each op
401
+ const resolveJoins = (i) => {
402
+ if (i >= joinOps.length) return null;
403
+ const op = joinOps[i];
404
+ if (op.beforeKeys !== null) return resolveJoins(i + 1);
405
+ const sql = `SELECT ${q(op.targetColumn)} AS ${q('t')} FROM ${q(op.joinTable)} `
406
+ + `WHERE ${q(op.ownColumn)} = ${parameterAt(1)}`;
407
+ return chain(connection.prepare(sql), (statement) =>
408
+ chain(statement.all([op.ownKey]), (rows) => {
409
+ op.beforeKeys = rows.map((row) => row.t);
410
+ return resolveJoins(i + 1);
411
+ }));
412
+ };
413
+ const finalizeJoins = () => {
414
+ for (const op of joinOps) {
415
+ const before = new Set(op.beforeKeys);
416
+ const after = new Set(op.afterKeys);
417
+ op.added = op.afterKeys.filter((key) => !before.has(key));
418
+ op.removed = op.beforeKeys.filter((key) => !after.has(key));
419
+ }
420
+ };
421
+
422
+ const assemble = () => {
423
+ finalizeJoins();
424
+ const insertOrder = orderEntities([...inserts.keys()], 'inserts');
425
+ const deleteOrder = orderEntities(
426
+ [...new Set(deletes.map((removal) => removal.entity))], 'deletes').reverse();
427
+
428
+ /** @type {any[]} */
429
+ const statements = [];
430
+
431
+ // 1. inserts, parent-first, batched per column-name signature
432
+ for (const entityName of insertOrder) {
433
+ const plan = coreFor(entityName).plan;
434
+ /** @type {Map<string, any[]>} */
435
+ const shapes = new Map();
436
+ for (const record of inserts.get(entityName)) {
437
+ const split = plan.split(record.current);
438
+ const signature = split.values.map((value) => value.name).join(',');
439
+ let group = shapes.get(signature);
440
+ if (group === undefined) {
441
+ shapes.set(signature, group = []);
442
+ }
443
+ group.push({ record, split });
444
+ }
445
+ for (const group of shapes.values()) {
446
+ const names = group[0].split.values.map((value) => value.name);
447
+ const paramsPerRow = names.length + 1;
448
+ const rowsPerBatch = Math.max(1, Math.min(BATCH_ROW_BOUND,
449
+ Math.floor(BATCH_PARAM_BUDGET / paramsPerRow)));
450
+ for (let at = 0; at < group.length; at += rowsPerBatch) {
451
+ const batch = group.slice(at, at + rowsPerBatch);
452
+ const returning = plan.autoKey !== null && !names.includes(plan.autoKey);
453
+ const rowSql = (base) => `(${[...names.map((_, i) => parameterAt(base + i + 1)),
454
+ dialect.jsonEncode(parameterAt(base + names.length + 1))].join(', ')})`;
455
+ const sql = `INSERT INTO ${q(plan.table)} `
456
+ + `(${[...names.map(q), q('doc')].join(', ')}) VALUES `
457
+ + batch.map((_, i) => rowSql(i * paramsPerRow)).join(', ')
458
+ + (returning ? ` RETURNING ${q(plan.autoKey)} AS ${q('key')}` : '');
459
+ const params = batch.flatMap(({ split }) => [
460
+ ...split.values.map((value) => value.value),
461
+ JSON.stringify(split.rest),
462
+ ]);
463
+ statements.push({
464
+ kind: 'insert', entity: entityName, sql, params, returning,
465
+ records: batch.map(({ record }) => record),
466
+ });
467
+ }
468
+ }
469
+ }
470
+
471
+ // 2. updates (inserts already exist; deletes still ahead)
472
+ for (const { record, parts } of updates) {
473
+ const plan = coreFor(record.entity).plan;
474
+ const params = [];
475
+ const assignments = [];
476
+ if (parts.fallback) {
477
+ const split = plan.split(record.stamped);
478
+ for (const value of split.values) {
479
+ if (value.name === plan.version) continue;
480
+ assignments.push(`${q(value.name)} = ${parameterAt(params.length + 1)}`);
481
+ params.push(value.value);
482
+ }
483
+ assignments.push(`${q('doc')} = ${dialect.jsonEncode(parameterAt(params.length + 1))}`);
484
+ params.push(JSON.stringify(split.rest));
485
+ }
486
+ else {
487
+ for (const [name, value] of parts.columnSets) {
488
+ assignments.push(`${q(name)} = ${parameterAt(params.length + 1)}`);
489
+ params.push(value);
490
+ }
491
+ if (parts.docBuild !== null) {
492
+ const built = parts.docBuild.build(q('doc'), params.length);
493
+ assignments.push(`${q('doc')} = ${built.expression}`);
494
+ params.push(...built.params);
495
+ }
496
+ }
497
+ const snapshotVersion = plan.version === null
498
+ ? null : Number(record.snapshot[plan.version]) || 0;
499
+ if (plan.version !== null) {
500
+ assignments.push(`${q(plan.version)} = ${parameterAt(params.length + 1)}`);
501
+ params.push(snapshotVersion + 1);
502
+ }
503
+ const wheres = plan.keys.map((key) => {
504
+ params.push(record.snapshot[key]);
505
+ return `${q(key)} = ${parameterAt(params.length)}`;
506
+ });
507
+ if (plan.version !== null) {
508
+ params.push(snapshotVersion);
509
+ wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
510
+ }
511
+ statements.push({
512
+ kind: 'update', entity: record.entity, record,
513
+ sql: `UPDATE ${q(plan.table)} SET ${assignments.join(', ')} `
514
+ + `WHERE ${wheres.join(' AND ')}`,
515
+ params,
516
+ guarded: plan.version !== null,
517
+ newVersion: plan.version === null ? null : snapshotVersion + 1,
518
+ });
519
+ }
520
+
521
+ // 3. join-table rows: after both endpoints exist, before deletes
522
+ for (const op of joinOps) {
523
+ if (op.added.length > 0) {
524
+ const sql = `INSERT INTO ${q(op.joinTable)} `
525
+ + `(${q(op.ownColumn)}, ${q(op.targetColumn)}) VALUES `
526
+ + op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
527
+ statements.push({
528
+ kind: 'join-insert', entity: op.joinTable, sql,
529
+ params: op.added.flatMap((key) => [op.ownKey, key]),
530
+ joinRows: op.added.map((key) => ({
531
+ own: op.ownKey, target: key,
532
+ ownColumn: op.ownColumn, targetColumn: op.targetColumn,
533
+ })),
534
+ });
535
+ }
536
+ for (const key of op.removed) {
537
+ statements.push({
538
+ kind: 'join-delete', entity: op.joinTable,
539
+ sql: `DELETE FROM ${q(op.joinTable)} WHERE ${q(op.ownColumn)} = ${parameterAt(1)} `
540
+ + `AND ${q(op.targetColumn)} = ${parameterAt(2)}`,
541
+ params: [op.ownKey, key],
542
+ joinRows: [{ own: op.ownKey, target: key,
543
+ ownColumn: op.ownColumn, targetColumn: op.targetColumn }],
544
+ });
545
+ }
546
+ }
547
+
548
+ // 4. deletes, child-first
549
+ for (const entityName of deleteOrder) {
550
+ const plan = coreFor(entityName).plan;
551
+ for (const removal of deletes) {
552
+ if (removal.entity !== entityName) continue;
553
+ const params = [...removal.parts];
554
+ const wheres = plan.keys.map((key, i) => `${q(key)} = ${parameterAt(i + 1)}`);
555
+ const snapshotVersion = plan.version !== null && removal.snapshot !== null
556
+ ? Number(removal.snapshot[plan.version]) || 0 : null;
557
+ if (snapshotVersion !== null) {
558
+ params.push(snapshotVersion);
559
+ wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
560
+ }
561
+ statements.push({
562
+ kind: 'delete', entity: entityName, removal,
563
+ sql: `DELETE FROM ${q(plan.table)} WHERE ${wheres.join(' AND ')}`,
564
+ params,
565
+ guarded: snapshotVersion !== null,
566
+ });
567
+ }
568
+ }
569
+
570
+ return { statements, fallbacks, unversioned: [...unversioned].sort() };
571
+ };
572
+ return chain(resolveJoins(0), assemble);
573
+ };
574
+
575
+ // ————— execution —————
576
+
577
+ const conflict = (statement) => {
578
+ const keyParts = statement.kind === 'delete'
579
+ ? statement.removal.parts
580
+ : coreFor(statement.entity).plan.keys
581
+ .map((key) => statement.record.snapshot[key]);
582
+ const key = keyParts.length === 1 ? keyParts[0] : keyParts;
583
+ return new DbRuntimeError('JD2040',
584
+ statement.guarded
585
+ ? `'${statement.entity}' ${JSON.stringify(key)} changed under the save `
586
+ + '(version mismatch) — re-read and retry'
587
+ : `'${statement.entity}' ${JSON.stringify(key)} no longer exists`,
588
+ {
589
+ docPath: entities.get(statement.entity)?.docPath,
590
+ collection: statement.entity,
591
+ key,
592
+ });
593
+ };
594
+
595
+ const wrapDb = (error, statement) => {
596
+ if (typeof (/** @type {any} */ (error))?.code === 'string'
597
+ && String((/** @type {any} */ (error)).code).startsWith('JD')) return error;
598
+ return new DbRuntimeError('JD2005',
599
+ `the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
600
+ {
601
+ docPath: entities.get(statement.entity)?.docPath,
602
+ collection: statement.entity,
603
+ cause: error,
604
+ });
605
+ };
606
+
607
+ const runStatements = (statements, report) => {
608
+ const next = (i) => {
609
+ if (i >= statements.length) return report;
610
+ const statement = statements[i];
611
+ return chain(connection.prepare(statement.sql), (prepared) => {
612
+ if (statement.kind === 'insert' && statement.returning === true) {
613
+ let fetched;
614
+ try {
615
+ fetched = prepared.all(statement.params);
616
+ }
617
+ catch (error) {
618
+ throw wrapDb(error, statement);
619
+ }
620
+ return chain(fetched, (rows) => {
621
+ // auto keys allocate monotonically in insertion order —
622
+ // sort ascending to pair rows with records (asserted by
623
+ // test, not assumed silently)
624
+ const keys = rows.map((row) => row.key).sort((a, b) => a - b);
625
+ statement.generatedKeys = keys;
626
+ report.inserted += statement.records.length;
627
+ report.statements.push({ sql: statement.sql, rows: statement.records.length });
628
+ return next(i + 1);
629
+ });
630
+ }
631
+ return chain(
632
+ statement.kind === 'delete' && captureJoinDelete !== null
633
+ ? captureJoinDelete(statement.entity, statement.removal.parts)
634
+ : null,
635
+ () => {
636
+ let ran;
637
+ try {
638
+ ran = prepared.run(statement.params);
639
+ }
640
+ catch (error) {
641
+ throw wrapDb(error, statement);
642
+ }
643
+ return chain(ran, (outcome) => {
644
+ const changed = Number(outcome?.changes ?? 0);
645
+ report.statements.push({ sql: statement.sql, rows: changed });
646
+ if (statement.kind === 'insert') report.inserted += statement.records.length;
647
+ else if (statement.kind === 'update') {
648
+ if (changed === 0) throw conflict(statement);
649
+ report.updated += 1;
650
+ }
651
+ else if (statement.kind === 'delete') {
652
+ if (changed === 0 && statement.guarded) throw conflict(statement);
653
+ statement.deletedRows = changed;
654
+ report.deleted += changed;
655
+ }
656
+ else if (statement.kind === 'join-insert') report.joinInserted += changed;
657
+ else if (statement.kind === 'join-delete') report.joinDeleted += changed;
658
+ return next(i + 1);
659
+ });
660
+ });
661
+ });
662
+ };
663
+ return next(0);
664
+ };
665
+
666
+ /** Commit phase: only reached after the transaction succeeded. */
667
+ const commit = (statements) => {
668
+ for (const statement of statements) {
669
+ if (statement.kind === 'insert') {
670
+ statement.records.forEach((record, i) => {
671
+ const plan = coreFor(statement.entity).plan;
672
+ let doc = record.current;
673
+ if (statement.returning === true) {
674
+ doc = deepFreeze({ ...doc, [plan.autoKey]: statement.generatedKeys[i] });
675
+ }
676
+ // re-key under the real identity
677
+ records.delete(record.pendingKey);
678
+ const key = recordKeyFor(statement.entity, doc);
679
+ records.set(/** @type {string} */ (key), {
680
+ entity: statement.entity, snapshot: doc, current: doc, pendingInsert: false,
681
+ });
682
+ record.saved = doc;
683
+ captureRecord?.(statement.entity,
684
+ plan.keys.map((k) => doc[k]), null, doc);
685
+ });
686
+ }
687
+ else if (statement.kind === 'update') {
688
+ const record = statement.record;
689
+ const plan = coreFor(statement.entity).plan;
690
+ const before = record.snapshot;
691
+ const saved = statement.newVersion === null
692
+ ? record.stamped
693
+ : deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
694
+ record.snapshot = deepFreeze(saved);
695
+ record.current = record.snapshot;
696
+ record.stamped = undefined;
697
+ captureRecord?.(statement.entity,
698
+ plan.keys.map((k) => record.snapshot[k]), before, record.snapshot);
699
+ }
700
+ else if (statement.kind === 'delete') {
701
+ const removal = statement.removal;
702
+ records.delete(keyOf(removal.entity, removal.parts));
703
+ removals.delete(keyOf(removal.entity, removal.parts));
704
+ if ((statement.deletedRows ?? 0) > 0) {
705
+ captureRecord?.(removal.entity, removal.parts,
706
+ removal.snapshot ?? undefined, null);
707
+ }
708
+ }
709
+ else if (statement.kind === 'join-insert' || statement.kind === 'join-delete') {
710
+ for (const row of statement.joinRows ?? []) {
711
+ // the join-row "document" lists its columns in table order
712
+ // (the sorted pair) so both capture modes agree exactly
713
+ const pair = statement.entity.split('_');
714
+ const value = { [row.ownColumn]: row.own, [row.targetColumn]: row.target };
715
+ const ordered = {};
716
+ for (const part of pair) ordered[part + '_key'] = value[part + '_key'];
717
+ const keyParts = pair.map((part) => ordered[part + '_key']);
718
+ if (statement.kind === 'join-insert') {
719
+ captureRecord?.(statement.entity, keyParts, null, ordered);
720
+ }
721
+ else {
722
+ captureRecord?.(statement.entity, keyParts, undefined, null);
723
+ }
724
+ }
725
+ }
726
+ }
727
+ // join-only records: their member state is now persisted
728
+ for (const record of records.values()) {
729
+ if (record.joinOnly === true) {
730
+ record.snapshot = record.stamped ?? record.current;
731
+ record.current = record.snapshot;
732
+ record.joinOnly = undefined;
733
+ record.stamped = undefined;
734
+ }
735
+ }
736
+ removals.clear();
737
+ };
738
+
739
+ const saveChanges = () => {
740
+ const startedAt = performance.now();
741
+ return chain(planSave(), ({ statements, fallbacks, unversioned }) => {
742
+ const report = {
743
+ inserted: 0, updated: 0, deleted: 0,
744
+ joinInserted: 0, joinDeleted: 0,
745
+ fallbacks,
746
+ statements: /** @type {{ sql: string, rows: number }[]} */ ([]),
747
+ concurrency: {
748
+ checked: statements.filter((statement) => statement.guarded === true).length,
749
+ unversioned,
750
+ },
751
+ elapsedMs: 0,
752
+ };
753
+ if (statements.length === 0) {
754
+ report.elapsedMs = performance.now() - startedAt;
755
+ return report;
756
+ }
757
+ return chain(
758
+ connection.transaction(() => runStatements(statements, report)),
759
+ (finished) => {
760
+ commit(statements);
761
+ finished.elapsedMs = performance.now() - startedAt;
762
+ return finished;
763
+ });
764
+ });
765
+ };
766
+
767
+ /** Drop tracking for a key without scheduling anything. */
768
+ const discard = (entityName, keyOrDoc) => {
769
+ const parts = coreFor(entityName).normalizeKey(keyOrDoc);
770
+ records.delete(keyOf(entityName, parts));
771
+ };
772
+
773
+ return {
774
+ register, registerGraph, add, put, remove, discard, counts, saveChanges,
775
+ };
776
+ }