@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/tracker.js CHANGED
@@ -8,29 +8,61 @@
8
8
  * snapshot against current with the suite's own diff engine and plans
9
9
  * the MINIMAL set of parameterised statements: scalar/epoch/foreign-
10
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.
11
+ * paths, join-table synchronisation for many-to-many members by the
12
+ * key-set difference a `put` implies, or by an explicit `link`/`unlink`
13
+ * delta resolved against the join table at save time — and a counted
14
+ * whole-row fallback for anything untranslatable.
13
15
  *
14
16
  * Ordering never violates a foreign key mid-transaction: inserts run
15
17
  * parent-first, deletes child-first, updates in between, join rows
16
18
  * after both endpoints exist. A foreign-key cycle among the entities
17
19
  * 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.
20
+ * The whole save is one transaction, and the tracker is mutated only
21
+ * after its statements have run so a save that FAILS leaves the
22
+ * tracker exactly as it was and a retry is possible. A save that
23
+ * SUCCEEDS inside a larger transaction advances at once (inside it, the
24
+ * database does hold those rows, and every later plan and optimistic
25
+ * guard has to agree), and registers the withdrawal of that advance
26
+ * against the scope that owns the connection: an enclosing rollback
27
+ * takes it back, so the retry plans the same statements again rather
28
+ * than reporting a success it never had.
21
29
  */
22
30
 
23
31
  import { createJSONPatch } from '@jarenjs/json/patch';
24
32
  import { parseJSONPointer } from '@jarenjs/json/pointer';
25
33
 
26
34
  import { DbCompileError, DbRuntimeError } from './errors.js';
27
- import { chain } from './driver.js';
35
+ import { chain, attempt } from './driver.js';
28
36
  import { translatePatch } from './patch-sql.js';
29
37
 
30
38
  /** Rows per batched INSERT: bounded by the portable parameter budget. */
31
39
  export const BATCH_PARAM_BUDGET = 900;
32
40
  export const BATCH_ROW_BOUND = 100;
33
41
 
42
+ /**
43
+ * The target keys a many-to-many membership array names: a key, or a
44
+ * document carrying the target's key. One reading for the unit of work
45
+ * and for `create()`, so the two attach the same rows.
46
+ * @param {any} value - the member's value
47
+ * @param {string} targetKey - the target entity's key property
48
+ * @param {string} member
49
+ * @param {(reason: string) => Error} refuse
50
+ * @returns {(string | number)[]}
51
+ */
52
+ export function membershipKeys(value, targetKey, member, refuse) {
53
+ if (value === undefined || value === null) return [];
54
+ if (!Array.isArray(value)) throw refuse(`'${member}' must be an array to synchronise its join table`);
55
+ return value.map((element) => {
56
+ const key = typeof element === 'string' || typeof element === 'number'
57
+ ? element
58
+ : element !== null && typeof element === 'object'
59
+ ? element[targetKey] : undefined;
60
+ if (typeof key !== 'string' && typeof key !== 'number')
61
+ throw refuse(`an element of '${member}' carries no usable '${targetKey}' key`);
62
+ return key;
63
+ });
64
+ }
65
+
34
66
  const UNIT_SEPARATOR = '';
35
67
 
36
68
  /**
@@ -70,10 +102,16 @@ export function createTracker(context) {
70
102
  const records = new Map();
71
103
  /** @type {Map<string, any>} */
72
104
  const removals = new Map();
105
+ /** Pending membership deltas (§11.7), one per entity, own key and
106
+ * many-to-many member: the targets to link and the targets to unlink.
107
+ * @type {Map<string, { entity: string, member: string, ownKey: string | number,
108
+ * links: Set<string | number>, unlinks: Set<string | number> }>} */
109
+ const memberships = new Map();
73
110
  let pendingSequence = 0;
74
111
 
75
- const keyOf = (entityName, parts) =>
76
- `${entityName}${UNIT_SEPARATOR}${parts.join(UNIT_SEPARATOR)}`;
112
+ // Keys may contain the membership separator themselves. Encode the
113
+ // whole tuple so its component boundaries and entity remain distinct.
114
+ const keyOf = (entityName, parts) => JSON.stringify([entityName, ...parts]);
77
115
 
78
116
  const recordKeyFor = (entityName, doc) => {
79
117
  const plan = coreFor(entityName).plan;
@@ -89,6 +127,12 @@ export function createTracker(context) {
89
127
  collection: entityName,
90
128
  });
91
129
 
130
+ /** A membership write needs the entity's own key: an `auto` key is
131
+ * allocated by the save, so a pending insert has none to attach to. */
132
+ const needsOwnKey = (entityName, member, verb) => contractError(entityName,
133
+ `'${member}' membership needs the entity's own key at ${verb} time — `
134
+ + 'save the entity first, then attach');
135
+
92
136
  const register = (entityName, doc) => {
93
137
  deepFreeze(doc);
94
138
  const key = recordKeyFor(entityName, doc);
@@ -169,6 +213,86 @@ export function createTracker(context) {
169
213
  });
170
214
  };
171
215
 
216
+ /** The pending insert a caller is holding, found by the identity of
217
+ * the document `add()` handed back: a record whose key the save has
218
+ * yet to allocate has nothing else to be found by. */
219
+ const pendingInsertHolding = (entityName, doc) => {
220
+ for (const record of records.values()) {
221
+ if (record.pendingInsert === true && record.entity === entityName
222
+ && record.current === doc) return record;
223
+ }
224
+ return undefined;
225
+ };
226
+
227
+ /** What a join op and its membership delta are filed under: the own
228
+ * key once there is one, and the pending record's own identity while
229
+ * the save has yet to allocate it. */
230
+ const ownToken = (entityName, ownKey, ownRecord) => (ownRecord === undefined
231
+ ? keyOf(entityName, [ownKey])
232
+ : ownRecord.pendingKey);
233
+
234
+ /**
235
+ * The pending membership delta a `link`/`unlink` addresses (§11.7):
236
+ * the member must be a many-to-many relation of the entity; the own
237
+ * key is read from a key or a document; the target is a key or a
238
+ * document carrying the target's key — the reading a membership array
239
+ * gets, so the two attach the same rows.
240
+ *
241
+ * A document whose key the save allocates carries none to attach to,
242
+ * so the delta is filed against the pending INSERT it belongs to and
243
+ * the join row takes the key that insert returns.
244
+ */
245
+ const membershipOf = (entityName, own, member, target, verb) => {
246
+ const plan = coreFor(entityName).plan;
247
+ const relation = entities.get(entityName).properties.get(member)?.relation;
248
+ if (relation === undefined || relation.kind !== 'manyToMany') {
249
+ throw contractError(entityName, relation === undefined
250
+ ? `'${member}' is not a relation member of '${entityName}' — ${verb}() attaches a `
251
+ + 'many-to-many membership through its join table'
252
+ : `'${member}' is a ${relation.kind} relation — ${verb}() attaches many-to-many `
253
+ + "memberships only; write the related entity's foreign key instead");
254
+ }
255
+ let ownKey;
256
+ let ownRecord;
257
+ if (own !== null && typeof own === 'object' && !Array.isArray(own)) {
258
+ ownKey = own[plan.keys[0]];
259
+ if (typeof ownKey !== 'string' && typeof ownKey !== 'number') {
260
+ ownRecord = pendingInsertHolding(entityName, own);
261
+ if (ownRecord === undefined) throw needsOwnKey(entityName, member, `${verb}()`);
262
+ ownKey = undefined;
263
+ }
264
+ }
265
+ else {
266
+ ownKey = coreFor(entityName).normalizeKey(own)[0];
267
+ }
268
+ const targetKey = mapping.entities[relation.to].keys[0];
269
+ const [key] = membershipKeys([target], targetKey, member,
270
+ (reason) => contractError(entityName, reason));
271
+ const id = `${ownToken(entityName, ownKey, ownRecord)}${UNIT_SEPARATOR}${member}`;
272
+ let pending = memberships.get(id);
273
+ if (pending === undefined) {
274
+ pending = { entity: entityName, member, ownKey, ownRecord,
275
+ links: new Set(), unlinks: new Set() };
276
+ memberships.set(id, pending);
277
+ }
278
+ return { pending, key };
279
+ };
280
+
281
+ /** Attach one membership (local, synchronous); the last word on one
282
+ * target wins, so `unlink` after `link` means unlink. */
283
+ const link = (entityName, own, member, target) => {
284
+ const { pending, key } = membershipOf(entityName, own, member, target, 'link');
285
+ pending.unlinks.delete(key);
286
+ pending.links.add(key);
287
+ };
288
+
289
+ /** Detach one membership (local, synchronous). */
290
+ const unlink = (entityName, own, member, target) => {
291
+ const { pending, key } = membershipOf(entityName, own, member, target, 'unlink');
292
+ pending.links.delete(key);
293
+ pending.unlinks.add(key);
294
+ };
295
+
172
296
  const counts = () => {
173
297
  let pendingInserts = 0;
174
298
  for (const record of records.values()) {
@@ -178,6 +302,7 @@ export function createTracker(context) {
178
302
  tracked: records.size - pendingInserts,
179
303
  pendingInserts,
180
304
  pendingDeletes: removals.size,
305
+ pendingMemberships: memberships.size,
181
306
  };
182
307
  };
183
308
 
@@ -275,29 +400,31 @@ export function createTracker(context) {
275
400
  return { columnSets, docBuild, m2mMembers, fallback };
276
401
  };
277
402
 
403
+ /** The join-table endpoints a many-to-many member writes through. The
404
+ * endpoint columns come from the mapping, never from the join table's
405
+ * NAME: an entity name with an underscore, or a `through` name, does
406
+ * not split into its endpoints. */
407
+ const joinEndpoints = (entityName, member) => {
408
+ const relation = entities.get(entityName).properties.get(member).relation;
409
+ const join = mapping.joinTables[relation.joinTable];
410
+ const own = join.left.entity === entityName ? join.left : join.right;
411
+ const target = own === join.left ? join.right : join.left;
412
+ return {
413
+ entity: entityName,
414
+ member,
415
+ joinTable: relation.joinTable,
416
+ ownColumn: own.column,
417
+ targetColumn: target.column,
418
+ tableColumns: [join.left.column, join.right.column],
419
+ targetKey: mapping.entities[relation.to].keys[0],
420
+ };
421
+ };
422
+
278
423
  /** Compute a many-to-many member's join-row difference by key sets. */
279
424
  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
- };
425
+ const endpoints = joinEndpoints(entityName, member);
426
+ const extract = (value) => membershipKeys(value, endpoints.targetKey, member,
427
+ (reason) => contractError(entityName, reason));
301
428
  // a snapshot that never LOADED the member knows nothing about the
302
429
  // current membership — treating unknown as empty would re-insert
303
430
  // existing rows (a UNIQUE violation the seeded corpus found); the
@@ -307,15 +434,27 @@ export function createTracker(context) {
307
434
  ? null
308
435
  : [...new Set(extract(memberValue))];
309
436
  return {
310
- joinTable: relation.joinTable,
311
- ownColumn: `${entityName}_key`,
312
- targetColumn: `${relation.to}_key`,
437
+ ...endpoints,
313
438
  ownKey,
314
439
  beforeKeys,
315
440
  afterKeys: [...new Set(extract(after[member]))],
316
441
  };
317
442
  };
318
443
 
444
+ /** A pending `link`/`unlink` delta as a join op. Its baseline is the
445
+ * join table as read at save time, so linking a member that exists
446
+ * and unlinking one that does not are no-ops — the two-run property. */
447
+ const membershipDelta = (pending) => ({
448
+ ...joinEndpoints(pending.entity, pending.member),
449
+ ownKey: pending.ownKey,
450
+ ownRecord: pending.ownRecord,
451
+ // a row the save is about to INSERT has no memberships to read: the
452
+ // key does not exist yet, so its baseline is empty rather than unknown
453
+ beforeKeys: pending.ownRecord === undefined ? null : [],
454
+ links: [...pending.links],
455
+ unlinks: [...pending.unlinks],
456
+ });
457
+
319
458
  /** Relation members riding a pending INSERT: many-to-many becomes
320
459
  * join rows; anything else refuses — projections are not state. */
321
460
  const insertRelationOps = (record) => {
@@ -334,12 +473,16 @@ export function createTracker(context) {
334
473
  + 'projection, not stored state; add the related entities themselves');
335
474
  }
336
475
  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));
476
+ const known = typeof ownKey === 'string' || typeof ownKey === 'number';
477
+ if (!known && plan.autoKey === null)
478
+ throw needsOwnKey(record.entity, property.name, 'add()');
479
+ // the key this row will have is the one its INSERT returns, so the
480
+ // join row is planned against the record and takes the key at run time
481
+ ops.push({
482
+ ...joinDiff(record.entity, null, record.current,
483
+ known ? ownKey : undefined, property.name),
484
+ ownRecord: known ? undefined : record,
485
+ });
343
486
  }
344
487
  return ops;
345
488
  };
@@ -352,6 +495,10 @@ export function createTracker(context) {
352
495
  const updates = [];
353
496
  /** @type {any[]} */
354
497
  const joinOps = [];
498
+ /** Records this save advances through their join table alone: they
499
+ * carry no statement of their own, and the commit still settles
500
+ * them, so the undo delta has to know about them. @type {any[]} */
501
+ const joinOnly = [];
355
502
  let fallbacks = 0;
356
503
  const unversioned = new Set();
357
504
 
@@ -368,6 +515,10 @@ export function createTracker(context) {
368
515
  continue;
369
516
  }
370
517
  if (record.current === record.snapshot) continue;
518
+ // a record with a pending removal is deleted, not updated: planning
519
+ // both bumped the version on the UPDATE and left the DELETE's
520
+ // snapshot guard matching nothing (JD2040), so the row survived
521
+ if (removals.has(recordKeyFor(record.entity, record.snapshot) ?? '')) continue;
371
522
  // probe before stamping: an update stamp must never turn a
372
523
  // deep-equal replacement into a phantom write
373
524
  if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
@@ -380,7 +531,10 @@ export function createTracker(context) {
380
531
  if (parts.columnSets.size === 0 && parts.docBuild === null
381
532
  && !parts.fallback) {
382
533
  // nothing but join-table changes (or a no-op put)
383
- if (parts.m2mMembers.size > 0) record.joinOnly = true;
534
+ if (parts.m2mMembers.size > 0) {
535
+ record.joinOnly = true;
536
+ joinOnly.push(record);
537
+ }
384
538
  else record.stamped = undefined;
385
539
  continue;
386
540
  }
@@ -397,6 +551,23 @@ export function createTracker(context) {
397
551
  unversioned.add(removal.entity);
398
552
  }
399
553
 
554
+ // a link/unlink beside a put-based synchronisation of the SAME member
555
+ // folds into that op's key set: one intent per entity, own key and
556
+ // member, never two statements racing for one row
557
+ const synced = new Map(joinOps.map((op) =>
558
+ [`${ownToken(op.entity, op.ownKey, op.ownRecord)}${UNIT_SEPARATOR}${op.member}`, op]));
559
+ for (const [id, pending] of memberships) {
560
+ const diff = synced.get(id);
561
+ if (diff === undefined) {
562
+ joinOps.push(membershipDelta(pending));
563
+ continue;
564
+ }
565
+ const after = new Set(diff.afterKeys);
566
+ for (const key of pending.unlinks) after.delete(key);
567
+ for (const key of pending.links) after.add(key);
568
+ diff.afterKeys = [...after];
569
+ }
570
+
400
571
  // resolve unknown membership baselines, then finalize each op
401
572
  const resolveJoins = (i) => {
402
573
  if (i >= joinOps.length) return null;
@@ -413,6 +584,11 @@ export function createTracker(context) {
413
584
  const finalizeJoins = () => {
414
585
  for (const op of joinOps) {
415
586
  const before = new Set(op.beforeKeys);
587
+ if (op.links !== undefined) {
588
+ op.added = op.links.filter((key) => !before.has(key));
589
+ op.removed = op.unlinks.filter((key) => before.has(key));
590
+ continue;
591
+ }
416
592
  const after = new Set(op.afterKeys);
417
593
  op.added = op.afterKeys.filter((key) => !before.has(key));
418
594
  op.removed = op.beforeKeys.filter((key) => !after.has(key));
@@ -525,7 +701,10 @@ export function createTracker(context) {
525
701
  + `(${q(op.ownColumn)}, ${q(op.targetColumn)}) VALUES `
526
702
  + op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
527
703
  statements.push({
528
- kind: 'join-insert', entity: op.joinTable, sql,
704
+ kind: 'join-insert', entity: op.joinTable, sql, tableColumns: op.tableColumns,
705
+ // an own key the save has yet to allocate is filled in from the
706
+ // insert's RETURNING, which the ordering above guarantees has run
707
+ ownFrom: op.ownRecord,
529
708
  params: op.added.flatMap((key) => [op.ownKey, key]),
530
709
  joinRows: op.added.map((key) => ({
531
710
  own: op.ownKey, target: key,
@@ -535,7 +714,7 @@ export function createTracker(context) {
535
714
  }
536
715
  for (const key of op.removed) {
537
716
  statements.push({
538
- kind: 'join-delete', entity: op.joinTable,
717
+ kind: 'join-delete', entity: op.joinTable, tableColumns: op.tableColumns,
539
718
  sql: `DELETE FROM ${q(op.joinTable)} WHERE ${q(op.ownColumn)} = ${parameterAt(1)} `
540
719
  + `AND ${q(op.targetColumn)} = ${parameterAt(2)}`,
541
720
  params: [op.ownKey, key],
@@ -567,7 +746,7 @@ export function createTracker(context) {
567
746
  }
568
747
  }
569
748
 
570
- return { statements, fallbacks, unversioned: [...unversioned].sort() };
749
+ return { statements, fallbacks, joinOnly, unversioned: [...unversioned].sort() };
571
750
  };
572
751
  return chain(resolveJoins(0), assemble);
573
752
  };
@@ -608,6 +787,15 @@ export function createTracker(context) {
608
787
  const next = (i) => {
609
788
  if (i >= statements.length) return report;
610
789
  const statement = statements[i];
790
+ // a join row whose own key the save allocates: the INSERT that
791
+ // allocates it has already run (inserts precede join rows), so the
792
+ // key is on the record by now
793
+ if (statement.ownFrom !== undefined) {
794
+ const ownKey = statement.ownFrom.allocatedKey;
795
+ statement.params = statement.joinRows.flatMap(
796
+ (/** @type {any} */ row) => [ownKey, row.target]);
797
+ for (const row of statement.joinRows) row.own = ownKey;
798
+ }
611
799
  return chain(connection.prepare(statement.sql), (prepared) => {
612
800
  if (statement.kind === 'insert' && statement.returning === true) {
613
801
  let fetched;
@@ -623,6 +811,9 @@ export function createTracker(context) {
623
811
  // test, not assumed silently)
624
812
  const keys = rows.map((row) => row.key).sort((a, b) => a - b);
625
813
  statement.generatedKeys = keys;
814
+ // a join row planned against one of these records reads its
815
+ // key from here, before the commit phase re-keys anything
816
+ statement.records.forEach((record, at) => { record.allocatedKey = keys[at]; });
626
817
  report.inserted += statement.records.length;
627
818
  report.statements.push({ sql: statement.sql, rows: statement.records.length });
628
819
  return next(i + 1);
@@ -633,14 +824,8 @@ export function createTracker(context) {
633
824
  ? captureJoinDelete(statement.entity, statement.removal.parts)
634
825
  : null,
635
826
  () => {
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) => {
827
+ return chain(attempt(() => prepared.run(statement.params),
828
+ (error) => wrapDb(error, statement)), (outcome) => {
644
829
  const changed = Number(outcome?.changes ?? 0);
645
830
  report.statements.push({ sql: statement.sql, rows: changed });
646
831
  if (statement.kind === 'insert') report.inserted += statement.records.length;
@@ -663,21 +848,116 @@ export function createTracker(context) {
663
848
  return next(0);
664
849
  };
665
850
 
666
- /** Commit phase: only reached after the transaction succeeded. */
667
- const commit = (statements) => {
851
+ /**
852
+ * The tracker state a save's commit will advance, exactly as it stands
853
+ * before the save runs: the map slot behind every record the commit
854
+ * may re-key or drop, the fields it may overwrite on a record it
855
+ * keeps, and the pending removals and membership deltas, which it
856
+ * clears whole. Bounded by the save, never by the tracker's size.
857
+ *
858
+ * `planSave` names the join-only records because they carry no
859
+ * statement of their own and the commit still advances them.
860
+ * @param {any[]} statements
861
+ * @param {any[]} joinOnly
862
+ */
863
+ const undoFor = (statements, joinOnly) => {
864
+ /** @type {Map<string, any>} */
865
+ const slots = new Map();
866
+ /** @type {Map<any, any>} */
867
+ const fields = new Map();
868
+ const takeSlot = (key) => {
869
+ if (!slots.has(key)) slots.set(key, records.get(key));
870
+ };
871
+ const takeFields = (record) => {
872
+ if (record === undefined || fields.has(record)) return;
873
+ fields.set(record, {
874
+ snapshot: record.snapshot, current: record.current,
875
+ stamped: record.stamped, joinOnly: record.joinOnly,
876
+ pendingInsert: record.pendingInsert, saved: record.saved,
877
+ });
878
+ };
879
+ for (const statement of statements) {
880
+ if (statement.kind === 'insert') {
881
+ for (const record of statement.records) {
882
+ takeSlot(record.pendingKey);
883
+ takeFields(record);
884
+ }
885
+ }
886
+ else if (statement.kind === 'update') takeFields(statement.record);
887
+ else if (statement.kind === 'delete') {
888
+ const key = keyOf(statement.removal.entity, statement.removal.parts);
889
+ takeSlot(key);
890
+ takeFields(records.get(key));
891
+ }
892
+ }
893
+ for (const record of joinOnly) takeFields(record);
894
+ return {
895
+ slots, fields,
896
+ removals: [...removals],
897
+ // the delta sets are mutated in place by a later link()/unlink(),
898
+ // so the undo needs copies rather than the live ones
899
+ memberships: [...memberships].map(([id, pending]) => [id, {
900
+ ...pending, links: new Set(pending.links), unlinks: new Set(pending.unlinks),
901
+ }]),
902
+ };
903
+ };
904
+
905
+ /** Put back what {@link undoFor} took a copy of: the save's statements
906
+ * were rolled back, so every claim they made about the database is
907
+ * withdrawn and a retry plans them again. */
908
+ const restore = (undo) => {
909
+ for (const [key, entry] of undo.slots) {
910
+ if (entry === undefined) records.delete(key);
911
+ else records.set(key, entry);
912
+ }
913
+ for (const [record, was] of undo.fields) {
914
+ record.snapshot = was.snapshot;
915
+ record.current = was.current;
916
+ record.stamped = was.stamped;
917
+ record.joinOnly = was.joinOnly;
918
+ record.pendingInsert = was.pendingInsert;
919
+ record.saved = was.saved;
920
+ }
921
+ removals.clear();
922
+ for (const [key, removal] of undo.removals) removals.set(key, removal);
923
+ memberships.clear();
924
+ for (const [id, pending] of undo.memberships) memberships.set(id, pending);
925
+ };
926
+
927
+ /**
928
+ * Advance phase: runs as soon as every statement of the save has
929
+ * succeeded — inside an enclosing transaction too, where the database
930
+ * already holds these rows and every later read, plan and optimistic
931
+ * guard must agree. What is registered as a settlement effect is only
932
+ * the WITHDRAWAL of this advance ({@link restore} over `undo`), which
933
+ * the owning scope runs if it rolls back.
934
+ *
935
+ * `undo` is the state the save started from. It is read for one
936
+ * decision: an edit made to a tracked record AFTER this save was
937
+ * planned is still pending work, and only a record left exactly as
938
+ * the save found it becomes clean.
939
+ * @param {any[]} statements
940
+ * @param {any} undo
941
+ */
942
+ const commit = (statements, undo) => {
943
+ const untouched = (record) => !undo.fields.has(record)
944
+ || undo.fields.get(record).current === record.current;
668
945
  for (const statement of statements) {
669
946
  if (statement.kind === 'insert') {
670
947
  statement.records.forEach((record, i) => {
671
948
  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
- }
949
+ // the row this statement wrote is the one the save PLANNED; an
950
+ // edit made to the same record afterwards is not in the database
951
+ const planned = undo.fields.get(record)?.current ?? record.current;
952
+ const doc = statement.returning === true
953
+ ? deepFreeze({ ...planned, [plan.autoKey]: statement.generatedKeys[i] })
954
+ : planned;
676
955
  // re-key under the real identity
677
956
  records.delete(record.pendingKey);
678
957
  const key = recordKeyFor(statement.entity, doc);
679
958
  records.set(/** @type {string} */ (key), {
680
- entity: statement.entity, snapshot: doc, current: doc, pendingInsert: false,
959
+ entity: statement.entity, snapshot: doc,
960
+ current: untouched(record) ? doc : record.current, pendingInsert: false,
681
961
  });
682
962
  record.saved = doc;
683
963
  captureRecord?.(statement.entity,
@@ -691,8 +971,9 @@ export function createTracker(context) {
691
971
  const saved = statement.newVersion === null
692
972
  ? record.stamped
693
973
  : deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
974
+ const pending = untouched(record) ? null : record.current;
694
975
  record.snapshot = deepFreeze(saved);
695
- record.current = record.snapshot;
976
+ record.current = pending ?? record.snapshot;
696
977
  record.stamped = undefined;
697
978
  captureRecord?.(statement.entity,
698
979
  plan.keys.map((k) => record.snapshot[k]), before, record.snapshot);
@@ -709,12 +990,11 @@ export function createTracker(context) {
709
990
  else if (statement.kind === 'join-insert' || statement.kind === 'join-delete') {
710
991
  for (const row of statement.joinRows ?? []) {
711
992
  // 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('_');
993
+ // (the mapping's left, right) so both capture modes agree exactly
714
994
  const value = { [row.ownColumn]: row.own, [row.targetColumn]: row.target };
715
995
  const ordered = {};
716
- for (const part of pair) ordered[part + '_key'] = value[part + '_key'];
717
- const keyParts = pair.map((part) => ordered[part + '_key']);
996
+ for (const column of statement.tableColumns) ordered[column] = value[column];
997
+ const keyParts = statement.tableColumns.map((column) => ordered[column]);
718
998
  if (statement.kind === 'join-insert') {
719
999
  captureRecord?.(statement.entity, keyParts, null, ordered);
720
1000
  }
@@ -727,18 +1007,20 @@ export function createTracker(context) {
727
1007
  // join-only records: their member state is now persisted
728
1008
  for (const record of records.values()) {
729
1009
  if (record.joinOnly === true) {
730
- record.snapshot = record.stamped ?? record.current;
731
- record.current = record.snapshot;
1010
+ const pending = untouched(record) ? null : record.current;
1011
+ record.snapshot = record.stamped ?? undo.fields.get(record)?.current ?? record.current;
1012
+ record.current = pending ?? record.snapshot;
732
1013
  record.joinOnly = undefined;
733
1014
  record.stamped = undefined;
734
1015
  }
735
1016
  }
736
1017
  removals.clear();
1018
+ memberships.clear();
737
1019
  };
738
1020
 
739
1021
  const saveChanges = () => {
740
1022
  const startedAt = performance.now();
741
- return chain(planSave(), ({ statements, fallbacks, unversioned }) => {
1023
+ return chain(planSave(), ({ statements, fallbacks, joinOnly, unversioned }) => {
742
1024
  const report = {
743
1025
  inserted: 0, updated: 0, deleted: 0,
744
1026
  joinInserted: 0, joinDeleted: 0,
@@ -754,10 +1036,22 @@ export function createTracker(context) {
754
1036
  report.elapsedMs = performance.now() - startedAt;
755
1037
  return report;
756
1038
  }
1039
+ // what the tracker looked like before the save, taken while it still
1040
+ // does: the statements below run in a savepoint whose release is not
1041
+ // a commit, so the right to KEEP what they justify waits for one
1042
+ const undo = undoFor(statements, joinOnly);
757
1043
  return chain(
758
1044
  connection.transaction(() => runStatements(statements, report)),
759
1045
  (finished) => {
760
- commit(statements);
1046
+ // The advance itself lands now, because inside the transaction the
1047
+ // database DOES hold these rows: every later read, plan and
1048
+ // optimistic guard in this unit of work has to agree with that, and
1049
+ // a tracker still calling them pending would write them twice.
1050
+ // What waits for the commit is the right to keep the advance — the
1051
+ // enclosing scope withdraws it if it rolls back, which is what
1052
+ // makes a caller's retry plan the same statements again.
1053
+ commit(statements, undo);
1054
+ connection.onSettle({ rollback: () => restore(undo) });
761
1055
  finished.elapsedMs = performance.now() - startedAt;
762
1056
  return finished;
763
1057
  });
@@ -767,10 +1061,15 @@ export function createTracker(context) {
767
1061
  /** Drop tracking for a key without scheduling anything. */
768
1062
  const discard = (entityName, keyOrDoc) => {
769
1063
  const parts = coreFor(entityName).normalizeKey(keyOrDoc);
770
- records.delete(keyOf(entityName, parts));
1064
+ const key = keyOf(entityName, parts);
1065
+ records.delete(key);
1066
+ // a pending membership change belongs to the key it attaches to
1067
+ for (const id of memberships.keys()) {
1068
+ if (id.startsWith(`${key}${UNIT_SEPARATOR}`)) memberships.delete(id);
1069
+ }
771
1070
  };
772
1071
 
773
1072
  return {
774
- register, registerGraph, add, put, remove, discard, counts, saveChanges,
1073
+ register, registerGraph, add, put, remove, discard, link, unlink, counts, saveChanges,
775
1074
  };
776
1075
  }