@jarenjs/db 0.46.5 → 0.56.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 (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
package/src/tracker.js CHANGED
@@ -8,8 +8,10 @@
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
@@ -24,13 +26,37 @@ import { createJSONPatch } from '@jarenjs/json/patch';
24
26
  import { parseJSONPointer } from '@jarenjs/json/pointer';
25
27
 
26
28
  import { DbCompileError, DbRuntimeError } from './errors.js';
27
- import { chain } from './driver.js';
29
+ import { chain, attempt } from './driver.js';
28
30
  import { translatePatch } from './patch-sql.js';
29
31
 
30
32
  /** Rows per batched INSERT: bounded by the portable parameter budget. */
31
33
  export const BATCH_PARAM_BUDGET = 900;
32
34
  export const BATCH_ROW_BOUND = 100;
33
35
 
36
+ /**
37
+ * The target keys a many-to-many membership array names: a key, or a
38
+ * document carrying the target's key. One reading for the unit of work
39
+ * and for `create()`, so the two attach the same rows.
40
+ * @param {any} value - the member's value
41
+ * @param {string} targetKey - the target entity's key property
42
+ * @param {string} member
43
+ * @param {(reason: string) => Error} refuse
44
+ * @returns {(string | number)[]}
45
+ */
46
+ export function membershipKeys(value, targetKey, member, refuse) {
47
+ if (value === undefined || value === null) return [];
48
+ if (!Array.isArray(value)) throw refuse(`'${member}' must be an array to synchronise its join table`);
49
+ return value.map((element) => {
50
+ const key = typeof element === 'string' || typeof element === 'number'
51
+ ? element
52
+ : element !== null && typeof element === 'object'
53
+ ? element[targetKey] : undefined;
54
+ if (typeof key !== 'string' && typeof key !== 'number')
55
+ throw refuse(`an element of '${member}' carries no usable '${targetKey}' key`);
56
+ return key;
57
+ });
58
+ }
59
+
34
60
  const UNIT_SEPARATOR = '';
35
61
 
36
62
  /**
@@ -70,6 +96,11 @@ export function createTracker(context) {
70
96
  const records = new Map();
71
97
  /** @type {Map<string, any>} */
72
98
  const removals = new Map();
99
+ /** Pending membership deltas (§11.7), one per entity, own key and
100
+ * many-to-many member: the targets to link and the targets to unlink.
101
+ * @type {Map<string, { entity: string, member: string, ownKey: string | number,
102
+ * links: Set<string | number>, unlinks: Set<string | number> }>} */
103
+ const memberships = new Map();
73
104
  let pendingSequence = 0;
74
105
 
75
106
  const keyOf = (entityName, parts) =>
@@ -89,6 +120,12 @@ export function createTracker(context) {
89
120
  collection: entityName,
90
121
  });
91
122
 
123
+ /** A membership write needs the entity's own key: an `auto` key is
124
+ * allocated by the save, so a pending insert has none to attach to. */
125
+ const needsOwnKey = (entityName, member, verb) => contractError(entityName,
126
+ `'${member}' membership needs the entity's own key at ${verb} time — `
127
+ + 'save the entity first, then attach');
128
+
92
129
  const register = (entityName, doc) => {
93
130
  deepFreeze(doc);
94
131
  const key = recordKeyFor(entityName, doc);
@@ -169,6 +206,60 @@ export function createTracker(context) {
169
206
  });
170
207
  };
171
208
 
209
+ /**
210
+ * The pending membership delta a `link`/`unlink` addresses (§11.7):
211
+ * the member must be a many-to-many relation of the entity; the own
212
+ * key is read from a key or a document (a pending insert whose key
213
+ * the save allocates has none to attach to); the target is a key or a
214
+ * document carrying the target's key — the reading a membership array
215
+ * gets, so the two attach the same rows.
216
+ */
217
+ const membershipOf = (entityName, own, member, target, verb) => {
218
+ const plan = coreFor(entityName).plan;
219
+ const relation = entities.get(entityName).properties.get(member)?.relation;
220
+ if (relation === undefined || relation.kind !== 'manyToMany') {
221
+ throw contractError(entityName, relation === undefined
222
+ ? `'${member}' is not a relation member of '${entityName}' — ${verb}() attaches a `
223
+ + 'many-to-many membership through its join table'
224
+ : `'${member}' is a ${relation.kind} relation — ${verb}() attaches many-to-many `
225
+ + "memberships only; write the related entity's foreign key instead");
226
+ }
227
+ let ownKey;
228
+ if (own !== null && typeof own === 'object' && !Array.isArray(own)) {
229
+ ownKey = own[plan.keys[0]];
230
+ if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
231
+ throw needsOwnKey(entityName, member, `${verb}()`);
232
+ }
233
+ else {
234
+ ownKey = coreFor(entityName).normalizeKey(own)[0];
235
+ }
236
+ const targetKey = mapping.entities[relation.to].keys[0];
237
+ const [key] = membershipKeys([target], targetKey, member,
238
+ (reason) => contractError(entityName, reason));
239
+ const id = `${keyOf(entityName, [ownKey])}${UNIT_SEPARATOR}${member}`;
240
+ let pending = memberships.get(id);
241
+ if (pending === undefined) {
242
+ pending = { entity: entityName, member, ownKey, links: new Set(), unlinks: new Set() };
243
+ memberships.set(id, pending);
244
+ }
245
+ return { pending, key };
246
+ };
247
+
248
+ /** Attach one membership (local, synchronous); the last word on one
249
+ * target wins, so `unlink` after `link` means unlink. */
250
+ const link = (entityName, own, member, target) => {
251
+ const { pending, key } = membershipOf(entityName, own, member, target, 'link');
252
+ pending.unlinks.delete(key);
253
+ pending.links.add(key);
254
+ };
255
+
256
+ /** Detach one membership (local, synchronous). */
257
+ const unlink = (entityName, own, member, target) => {
258
+ const { pending, key } = membershipOf(entityName, own, member, target, 'unlink');
259
+ pending.links.delete(key);
260
+ pending.unlinks.add(key);
261
+ };
262
+
172
263
  const counts = () => {
173
264
  let pendingInserts = 0;
174
265
  for (const record of records.values()) {
@@ -178,6 +269,7 @@ export function createTracker(context) {
178
269
  tracked: records.size - pendingInserts,
179
270
  pendingInserts,
180
271
  pendingDeletes: removals.size,
272
+ pendingMemberships: memberships.size,
181
273
  };
182
274
  };
183
275
 
@@ -275,29 +367,31 @@ export function createTracker(context) {
275
367
  return { columnSets, docBuild, m2mMembers, fallback };
276
368
  };
277
369
 
370
+ /** The join-table endpoints a many-to-many member writes through. The
371
+ * endpoint columns come from the mapping, never from the join table's
372
+ * NAME: an entity name with an underscore, or a `through` name, does
373
+ * not split into its endpoints. */
374
+ const joinEndpoints = (entityName, member) => {
375
+ const relation = entities.get(entityName).properties.get(member).relation;
376
+ const join = mapping.joinTables[relation.joinTable];
377
+ const own = join.left.entity === entityName ? join.left : join.right;
378
+ const target = own === join.left ? join.right : join.left;
379
+ return {
380
+ entity: entityName,
381
+ member,
382
+ joinTable: relation.joinTable,
383
+ ownColumn: own.column,
384
+ targetColumn: target.column,
385
+ tableColumns: [join.left.column, join.right.column],
386
+ targetKey: mapping.entities[relation.to].keys[0],
387
+ };
388
+ };
389
+
278
390
  /** Compute a many-to-many member's join-row difference by key sets. */
279
391
  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
- };
392
+ const endpoints = joinEndpoints(entityName, member);
393
+ const extract = (value) => membershipKeys(value, endpoints.targetKey, member,
394
+ (reason) => contractError(entityName, reason));
301
395
  // a snapshot that never LOADED the member knows nothing about the
302
396
  // current membership — treating unknown as empty would re-insert
303
397
  // existing rows (a UNIQUE violation the seeded corpus found); the
@@ -307,15 +401,24 @@ export function createTracker(context) {
307
401
  ? null
308
402
  : [...new Set(extract(memberValue))];
309
403
  return {
310
- joinTable: relation.joinTable,
311
- ownColumn: `${entityName}_key`,
312
- targetColumn: `${relation.to}_key`,
404
+ ...endpoints,
313
405
  ownKey,
314
406
  beforeKeys,
315
407
  afterKeys: [...new Set(extract(after[member]))],
316
408
  };
317
409
  };
318
410
 
411
+ /** A pending `link`/`unlink` delta as a join op. Its baseline is the
412
+ * join table as read at save time, so linking a member that exists
413
+ * and unlinking one that does not are no-ops — the two-run property. */
414
+ const membershipDelta = (pending) => ({
415
+ ...joinEndpoints(pending.entity, pending.member),
416
+ ownKey: pending.ownKey,
417
+ beforeKeys: null,
418
+ links: [...pending.links],
419
+ unlinks: [...pending.unlinks],
420
+ });
421
+
319
422
  /** Relation members riding a pending INSERT: many-to-many becomes
320
423
  * join rows; anything else refuses — projections are not state. */
321
424
  const insertRelationOps = (record) => {
@@ -334,11 +437,8 @@ export function createTracker(context) {
334
437
  + 'projection, not stored state; add the related entities themselves');
335
438
  }
336
439
  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
- }
440
+ if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
441
+ throw needsOwnKey(record.entity, property.name, 'add()');
342
442
  ops.push(joinDiff(record.entity, null, record.current, ownKey, property.name));
343
443
  }
344
444
  return ops;
@@ -368,6 +468,10 @@ export function createTracker(context) {
368
468
  continue;
369
469
  }
370
470
  if (record.current === record.snapshot) continue;
471
+ // a record with a pending removal is deleted, not updated: planning
472
+ // both bumped the version on the UPDATE and left the DELETE's
473
+ // snapshot guard matching nothing (JD2040), so the row survived
474
+ if (removals.has(recordKeyFor(record.entity, record.snapshot) ?? '')) continue;
371
475
  // probe before stamping: an update stamp must never turn a
372
476
  // deep-equal replacement into a phantom write
373
477
  if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
@@ -397,6 +501,23 @@ export function createTracker(context) {
397
501
  unversioned.add(removal.entity);
398
502
  }
399
503
 
504
+ // a link/unlink beside a put-based synchronisation of the SAME member
505
+ // folds into that op's key set: one intent per entity, own key and
506
+ // member, never two statements racing for one row
507
+ const synced = new Map(joinOps.map((op) =>
508
+ [`${op.entity}${UNIT_SEPARATOR}${op.ownKey}${UNIT_SEPARATOR}${op.member}`, op]));
509
+ for (const [id, pending] of memberships) {
510
+ const diff = synced.get(id);
511
+ if (diff === undefined) {
512
+ joinOps.push(membershipDelta(pending));
513
+ continue;
514
+ }
515
+ const after = new Set(diff.afterKeys);
516
+ for (const key of pending.unlinks) after.delete(key);
517
+ for (const key of pending.links) after.add(key);
518
+ diff.afterKeys = [...after];
519
+ }
520
+
400
521
  // resolve unknown membership baselines, then finalize each op
401
522
  const resolveJoins = (i) => {
402
523
  if (i >= joinOps.length) return null;
@@ -413,6 +534,11 @@ export function createTracker(context) {
413
534
  const finalizeJoins = () => {
414
535
  for (const op of joinOps) {
415
536
  const before = new Set(op.beforeKeys);
537
+ if (op.links !== undefined) {
538
+ op.added = op.links.filter((key) => !before.has(key));
539
+ op.removed = op.unlinks.filter((key) => before.has(key));
540
+ continue;
541
+ }
416
542
  const after = new Set(op.afterKeys);
417
543
  op.added = op.afterKeys.filter((key) => !before.has(key));
418
544
  op.removed = op.beforeKeys.filter((key) => !after.has(key));
@@ -525,7 +651,7 @@ export function createTracker(context) {
525
651
  + `(${q(op.ownColumn)}, ${q(op.targetColumn)}) VALUES `
526
652
  + op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
527
653
  statements.push({
528
- kind: 'join-insert', entity: op.joinTable, sql,
654
+ kind: 'join-insert', entity: op.joinTable, sql, tableColumns: op.tableColumns,
529
655
  params: op.added.flatMap((key) => [op.ownKey, key]),
530
656
  joinRows: op.added.map((key) => ({
531
657
  own: op.ownKey, target: key,
@@ -535,7 +661,7 @@ export function createTracker(context) {
535
661
  }
536
662
  for (const key of op.removed) {
537
663
  statements.push({
538
- kind: 'join-delete', entity: op.joinTable,
664
+ kind: 'join-delete', entity: op.joinTable, tableColumns: op.tableColumns,
539
665
  sql: `DELETE FROM ${q(op.joinTable)} WHERE ${q(op.ownColumn)} = ${parameterAt(1)} `
540
666
  + `AND ${q(op.targetColumn)} = ${parameterAt(2)}`,
541
667
  params: [op.ownKey, key],
@@ -633,14 +759,8 @@ export function createTracker(context) {
633
759
  ? captureJoinDelete(statement.entity, statement.removal.parts)
634
760
  : null,
635
761
  () => {
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) => {
762
+ return chain(attempt(() => prepared.run(statement.params),
763
+ (error) => wrapDb(error, statement)), (outcome) => {
644
764
  const changed = Number(outcome?.changes ?? 0);
645
765
  report.statements.push({ sql: statement.sql, rows: changed });
646
766
  if (statement.kind === 'insert') report.inserted += statement.records.length;
@@ -709,12 +829,11 @@ export function createTracker(context) {
709
829
  else if (statement.kind === 'join-insert' || statement.kind === 'join-delete') {
710
830
  for (const row of statement.joinRows ?? []) {
711
831
  // 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('_');
832
+ // (the mapping's left, right) so both capture modes agree exactly
714
833
  const value = { [row.ownColumn]: row.own, [row.targetColumn]: row.target };
715
834
  const ordered = {};
716
- for (const part of pair) ordered[part + '_key'] = value[part + '_key'];
717
- const keyParts = pair.map((part) => ordered[part + '_key']);
835
+ for (const column of statement.tableColumns) ordered[column] = value[column];
836
+ const keyParts = statement.tableColumns.map((column) => ordered[column]);
718
837
  if (statement.kind === 'join-insert') {
719
838
  captureRecord?.(statement.entity, keyParts, null, ordered);
720
839
  }
@@ -734,6 +853,7 @@ export function createTracker(context) {
734
853
  }
735
854
  }
736
855
  removals.clear();
856
+ memberships.clear();
737
857
  };
738
858
 
739
859
  const saveChanges = () => {
@@ -767,10 +887,15 @@ export function createTracker(context) {
767
887
  /** Drop tracking for a key without scheduling anything. */
768
888
  const discard = (entityName, keyOrDoc) => {
769
889
  const parts = coreFor(entityName).normalizeKey(keyOrDoc);
770
- records.delete(keyOf(entityName, parts));
890
+ const key = keyOf(entityName, parts);
891
+ records.delete(key);
892
+ // a pending membership change belongs to the key it attaches to
893
+ for (const id of memberships.keys()) {
894
+ if (id.startsWith(`${key}${UNIT_SEPARATOR}`)) memberships.delete(id);
895
+ }
771
896
  };
772
897
 
773
898
  return {
774
- register, registerGraph, add, put, remove, discard, counts, saveChanges,
899
+ register, registerGraph, add, put, remove, discard, link, unlink, counts, saveChanges,
775
900
  };
776
901
  }