@jarenjs/db 0.49.2 → 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 (70) hide show
  1. package/ARCHITECTURE.md +27 -15
  2. package/README.md +141 -41
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +38 -9
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +232 -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/capture.js +66 -28
  11. package/src/cli.js +225 -44
  12. package/src/ddl.js +23 -3
  13. package/src/dialects/sqlite.js +2 -1
  14. package/src/driver.js +63 -16
  15. package/src/drivers/wasm.js +1 -0
  16. package/src/emit-model.js +14 -0
  17. package/src/emit.js +10 -3
  18. package/src/entity.js +92 -47
  19. package/src/errors.js +25 -0
  20. package/src/index.js +2 -2
  21. package/src/jobs.js +40 -5
  22. package/src/live-time.js +12 -3
  23. package/src/live.js +11 -1
  24. package/src/migrate.js +397 -191
  25. package/src/model.js +173 -8
  26. package/src/plan.js +135 -38
  27. package/src/query.js +138 -13
  28. package/src/store.js +221 -66
  29. package/src/tracker.js +173 -48
  30. package/types/index.d.ts +152 -10
  31. package/types/node.d.ts +3 -1
  32. package/types/typed.d.ts +58 -2
  33. package/types/wasm.d.ts +7 -0
  34. package/dist/types/algebra.d.ts +0 -230
  35. package/dist/types/app.d.ts +0 -49
  36. package/dist/types/capture.d.ts +0 -85
  37. package/dist/types/cli.d.ts +0 -2
  38. package/dist/types/dag-job.d.ts +0 -40
  39. package/dist/types/ddl.d.ts +0 -229
  40. package/dist/types/derive.d.ts +0 -250
  41. package/dist/types/dialect.d.ts +0 -154
  42. package/dist/types/dialects/sqlite.d.ts +0 -9
  43. package/dist/types/driver.d.ts +0 -110
  44. package/dist/types/drivers/bun.d.ts +0 -47
  45. package/dist/types/drivers/node.d.ts +0 -37
  46. package/dist/types/drivers/wasm.d.ts +0 -65
  47. package/dist/types/emit-model.d.ts +0 -44
  48. package/dist/types/emit.d.ts +0 -75
  49. package/dist/types/entity.d.ts +0 -23
  50. package/dist/types/errors.d.ts +0 -170
  51. package/dist/types/graph.d.ts +0 -28
  52. package/dist/types/index.d.ts +0 -37
  53. package/dist/types/jobs.d.ts +0 -140
  54. package/dist/types/knn.d.ts +0 -69
  55. package/dist/types/live-time.d.ts +0 -141
  56. package/dist/types/live.d.ts +0 -64
  57. package/dist/types/migrate.d.ts +0 -170
  58. package/dist/types/model.d.ts +0 -36
  59. package/dist/types/patch-sql.d.ts +0 -37
  60. package/dist/types/plan.d.ts +0 -142
  61. package/dist/types/profile.d.ts +0 -80
  62. package/dist/types/query.d.ts +0 -112
  63. package/dist/types/residual.d.ts +0 -64
  64. package/dist/types/series.d.ts +0 -227
  65. package/dist/types/store.d.ts +0 -60
  66. package/dist/types/tracker.d.ts +0 -43
  67. package/dist/types/typed.d.ts +0 -15
  68. package/dist/types/types.d.ts +0 -26
  69. package/dist/types/udf.d.ts +0 -75
  70. 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
  }
package/types/index.d.ts CHANGED
@@ -84,7 +84,9 @@ export interface ExecuteOptions {
84
84
  pushdown?: boolean;
85
85
  }
86
86
 
87
- export interface LoadInclude extends LoadSpec {
87
+ /** An include's clauses: the root's without `after` — a keyset cursor
88
+ * paginates the root alone; an include windows with `skip`/`take`. */
89
+ export interface LoadInclude extends Omit<LoadSpec, 'after'> {
88
90
  /** Project the related-row COUNT instead of the rows. */
89
91
  count?: boolean;
90
92
  }
@@ -128,6 +130,8 @@ export interface StoreStats {
128
130
  tracked: number;
129
131
  pendingInserts: number;
130
132
  pendingDeletes: number;
133
+ /** Pending `link`/`unlink` records: one per entity, own key and member (§11.7). */
134
+ pendingMemberships: number;
131
135
  } | null;
132
136
  liveQueries: number;
133
137
  }
@@ -186,12 +190,44 @@ export interface SyncCollection<T = unknown> {
186
190
 
187
191
  // ————— entities (phase B) —————
188
192
 
193
+ /** One row of an entity's relation table (MODEL-FORMAT §10.1): the
194
+ * declared relation as plain data a query producer can lower a hop
195
+ * from — never a document dialect. For a foreign-key relation `via`
196
+ * names the key property, `fkEntity` the entity holding it, `fkTargets`
197
+ * the entity it references and `targetKey` the key property it
198
+ * references there (the column a hop's equality compares `via` with);
199
+ * `kind` says which side holds the key (`oneToOne`: the declaring
200
+ * entity; `oneToMany`: the target). A many-to-many carries its
201
+ * `joinTable` and the target's `targetKey`. */
202
+ export interface RelationEntry {
203
+ readonly to: string;
204
+ readonly kind: 'oneToOne' | 'oneToMany' | 'manyToMany';
205
+ readonly via?: string;
206
+ readonly fkEntity?: string;
207
+ readonly fkTargets?: string;
208
+ readonly joinTable?: string;
209
+ readonly targetKey: string;
210
+ }
211
+
212
+ /** An entity's relation table: one entry per declared relation member. */
213
+ export type RelationTable = Readonly<Record<string, RelationEntry>>;
214
+
215
+ /** The identity every entity set of one store shares — two sets with one
216
+ * `scope` may be joined in one document — carrying the relation tables
217
+ * of every root, keyed by entity name, so a hop can chain into another
218
+ * root of the same scope. */
219
+ export interface EntityScope {
220
+ readonly relations: Readonly<Record<string, RelationTable>>;
221
+ }
222
+
189
223
  export interface UntrackedReads<T = unknown> {
190
224
  get(key: EntityKeyArg): Promise<T | undefined>;
191
225
  load(spec?: LoadSpec): Promise<T[]>;
192
226
  }
193
227
 
194
228
  export interface EntitySet<T = unknown, I = unknown> {
229
+ /** The provider phantom: a chain over this set infers its item type. */
230
+ readonly __item?: T;
195
231
  create(doc: I): Promise<Readonly<T>>;
196
232
  get(key: EntityKeyArg): Promise<Readonly<T> | undefined>;
197
233
  update(key: EntityKeyArg, changes: Partial<T>): Promise<Readonly<T>>;
@@ -206,7 +242,27 @@ export interface EntitySet<T = unknown, I = unknown> {
206
242
  remove(key: EntityKeyArg | T): void;
207
243
  /** Drop tracking without scheduling anything — conflict recovery. */
208
244
  discard(key: EntityKeyArg | T): void;
245
+ /** Attach / detach one many-to-many membership through the unit of
246
+ * work (§11.7): local bookkeeping, written by `saveChanges()` as join
247
+ * rows against the join table as it stands then — idempotent. `own`
248
+ * and `target` are each a key or a document carrying the key. */
249
+ link(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
250
+ unlink(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
209
251
  asNoTracking(): UntrackedReads<T>;
252
+ /** The provider contract over this entity's root (MODEL-FORMAT §10.1):
253
+ * the document is over the multi-entity root and arrives whole; the
254
+ * answer is the engine's result shape, value-or-promise (D2). */
255
+ execute<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
256
+ explain(document: unknown, options?: ExecuteOptions): Promise<unknown>;
257
+ /** The root expression this set's rows are bound through (`$.<Name>[*]`). */
258
+ readonly root: string;
259
+ /** The identity every entity set of one store shares: two sets with one
260
+ * `scope` may be joined in one document; it carries every root's
261
+ * relation table. */
262
+ readonly scope: EntityScope;
263
+ /** This entity's relation table (MODEL-FORMAT §10.1) — what a query
264
+ * producer lowers a relation hop from. */
265
+ readonly relations: RelationTable;
210
266
  }
211
267
 
212
268
  export interface SyncUntrackedReads<T = unknown> {
@@ -215,6 +271,8 @@ export interface SyncUntrackedReads<T = unknown> {
215
271
  }
216
272
 
217
273
  export interface SyncEntitySet<T = unknown, I = unknown> {
274
+ /** The provider phantom: a chain over this set infers its item type. */
275
+ readonly __item?: T;
218
276
  create(doc: I): Readonly<T>;
219
277
  get(key: EntityKeyArg): Readonly<T> | undefined;
220
278
  update(key: EntityKeyArg, changes: Partial<T>): Readonly<T>;
@@ -225,7 +283,15 @@ export interface SyncEntitySet<T = unknown, I = unknown> {
225
283
  put(next: T): Readonly<T>;
226
284
  remove(key: EntityKeyArg | T): void;
227
285
  discard(key: EntityKeyArg | T): void;
286
+ link(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
287
+ unlink(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
228
288
  asNoTracking(): SyncUntrackedReads<T>;
289
+ /** The provider contract over this entity's root, answering values. */
290
+ execute<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
291
+ explain(document: unknown, options?: ExecuteOptions): unknown;
292
+ readonly root: string;
293
+ readonly scope: EntityScope;
294
+ readonly relations: RelationTable;
229
295
  }
230
296
 
231
297
  // ————— the store —————
@@ -237,6 +303,14 @@ export interface SyncStore {
237
303
  entity(name: string): SyncEntitySet;
238
304
  transaction<R>(fn: (store: Store) => R): R;
239
305
  execute?<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
306
+ explain?(document: unknown, options?: ExecuteOptions): unknown;
307
+ /** The entity roots this store-level provider serves (present with
308
+ * entities): it has no single root of its own, so a chain over it is
309
+ * refused by name — chain over `entity(name)` instead. */
310
+ readonly roots?: readonly string[];
311
+ /** The relation tables of every entity, keyed by entity name (present
312
+ * with entities; MODEL-FORMAT §10.1). */
313
+ readonly relations?: Readonly<Record<string, RelationTable>>;
240
314
  saveChanges?(): SaveReport;
241
315
  }
242
316
 
@@ -253,6 +327,13 @@ export interface Store {
253
327
  * in the engine's result shape. */
254
328
  execute?<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
255
329
  explain?(document: unknown, options?: ExecuteOptions): Promise<unknown>;
330
+ /** The entity roots this store-level provider serves (present with
331
+ * entities): it has no single root of its own, so a chain over it is
332
+ * refused by name — chain over `entity(name)` instead. */
333
+ readonly roots?: readonly string[];
334
+ /** The relation tables of every entity, keyed by entity name (present
335
+ * with entities; MODEL-FORMAT §10.1). */
336
+ readonly relations?: Readonly<Record<string, RelationTable>>;
256
337
  /** The unit of work (§11); present only with entities. */
257
338
  saveChanges?(): Promise<SaveReport>;
258
339
  transaction<R>(fn: (store: Store) => R | Promise<R>): Promise<Awaited<R>>;
@@ -409,10 +490,19 @@ export interface OpenStoreOptions {
409
490
  * than answered in UTC. No time-zone database is bundled. */
410
491
  zoneProvider?: unknown;
411
492
  busyTimeout?: number;
493
+ /** How long work waits for an open transaction to settle before
494
+ * `JD0012` (MODEL-FORMAT §5.1); reaches every driver. */
495
+ queueTimeout?: number;
412
496
  journalMode?: string;
413
497
  statementCacheBound?: number;
414
498
  profile?: unknown;
415
499
  readOnly?: boolean;
500
+ /** A `createJsltRegistry()` registry (Ring 2/3): the operators a
501
+ * query may use, and the pushable subset. */
502
+ operators?: unknown;
503
+ /** Raw registry-free operators; never pushed. */
504
+ functions?: Record<string, unknown>;
505
+ extensions?: Record<string, unknown>;
416
506
  }
417
507
 
418
508
  export declare function openStore(model: unknown, options: OpenStoreOptions): Promise<Store>;
@@ -433,7 +523,9 @@ export interface Dialect {
433
523
  export interface Driver {
434
524
  readonly name: string;
435
525
  readonly dialect: Dialect;
436
- open(options?: unknown): unknown;
526
+ /** Open a connection (value-or-promise) at `path` (`':memory:'` for
527
+ * none) with the driver's own options. */
528
+ open(path: string, options?: unknown): unknown;
437
529
  }
438
530
 
439
531
  export declare const sqliteDialect: Dialect;
@@ -444,6 +536,12 @@ export declare const SQLITE_FLOOR: string;
444
536
 
445
537
  export declare function normalizeEntities(model: unknown): Map<string, unknown>;
446
538
  export declare function explainMapping(model: unknown): unknown;
539
+ /** The relation tables of normalized entities, keyed by entity name
540
+ * then by relation member (MODEL-FORMAT §10.1) — what every entity set
541
+ * exposes as `relations` and every scope carries for all its roots. */
542
+ export declare function relationTables(
543
+ entities: Map<string, unknown>,
544
+ ): Readonly<Record<string, RelationTable>>;
447
545
 
448
546
  /**
449
547
  * Build the EMIT-FORMAT model document for a model's entities.
@@ -527,7 +625,9 @@ export declare const HISTORY_TABLE: string;
527
625
  // are deliberately WIDE (unknown), never wrong.
528
626
 
529
627
  export declare function planCollection(name: string, collection: unknown, dialect: Dialect): unknown;
530
- export declare function compileIndexPath(path: string, schema: unknown): unknown;
628
+ export declare function compileIndexPath(expression: string, docPath: string): unknown;
629
+ export declare function normalizeDeclaredSql(sql: string): string;
630
+ export declare function comparableDeclaredSql(sql: string): string;
531
631
  export declare function schemaTypeAt(schema: unknown, segments: unknown): unknown;
532
632
  export declare const KEY_COLUMN: string;
533
633
  export declare const DOC_COLUMN: string;
@@ -575,7 +675,7 @@ export declare function openConnection(raw: unknown, options: unknown): unknown;
575
675
  export declare function wrapStatement(statement: unknown): unknown;
576
676
  export declare function lazyOpen(spec: unknown, reason: string, use: unknown, args?: unknown): unknown;
577
677
  export declare function classifyLiveQuery(
578
- document: unknown, queryShape: unknown, keyed: boolean): unknown;
678
+ document: unknown, queryShape: unknown, keyed: boolean, eventTime?: unknown): unknown;
579
679
  export declare function createLiveRegistry(
580
680
  bounds: { maxQueries: number; maxMaintained: number }): unknown;
581
681
  export declare function diffRows(oldRows: readonly unknown[], newRows: readonly unknown[]):
@@ -586,6 +686,38 @@ export declare function createSortedWindow(
586
686
  export declare function compareCodepoint(a: string, b: string): number;
587
687
  export declare function collectEntityRoots(
588
688
  document: unknown, entities: ReadonlyMap<string, unknown>): Set<string>;
689
+ /** The root expression an entity's rows are bound through (`$.<Name>[*]`) —
690
+ * what an entity set exposes as `root` and what `collectEntityRoots` reads. */
691
+ export declare function entityRoot(name: string): string;
692
+
693
+ // ————— the derived-index and k-nearest machinery —————
694
+ // Constants carry their real shapes; the functions take and answer the
695
+ // planner's own records, which have no published type — WIDE, never
696
+ // wrong (the line at the top of this file).
697
+
698
+ export declare const DERIVE_KINDS: ReadonlySet<string>;
699
+ export declare const DERIVE_MAPPING: Readonly<Record<string, string | null>>;
700
+ export declare const PHYSICAL_KINDS: ReadonlySet<string>;
701
+ export declare const BBOX_COMPONENTS: readonly ['w', 's', 'e', 'n'];
702
+ export declare const BBOX_INDEX_ORDER: readonly ['w', 'e', 's', 'n'];
703
+ export declare const PRECISION_MIN: number;
704
+ export declare const PRECISION_MAX: number;
705
+ export declare const DIMS_MIN: number;
706
+ export declare const DIMS_MAX: number;
707
+ export declare function derivedMappingFor(kind: string, driverMapping: unknown): unknown;
708
+ export declare function deriveGeohash(value: unknown, precision: number): unknown;
709
+ export declare function deriveBboxEdge(value: unknown, component: 'w' | 's' | 'e' | 'n'): unknown;
710
+ export declare function deriveVector(member: unknown, dims: number): unknown;
711
+ export declare function storedMemberForm(member: unknown): unknown;
712
+ export declare function derivedValue(column: unknown, member: unknown): unknown;
713
+ export declare function memberAt(doc: unknown, segments: unknown): unknown;
714
+ export declare function registerDeriveFunctions(connection: unknown): unknown;
715
+ export declare function probeVector(value: unknown, dims: number): unknown;
716
+ export declare function columnScore(bytes: unknown, dims: number, probe: unknown): unknown;
717
+ export declare const KNN_MARGIN: number;
718
+ export declare const IDENTITY_CHUNK: number;
719
+ export declare function cutCandidates(rows: unknown[], m: number, margin: number): unknown;
720
+ export declare function identityBatches(identities: unknown[]): unknown;
589
721
 
590
722
  // ————— the job queue (JOBS-FORMAT) —————
591
723
 
@@ -617,21 +749,25 @@ export interface JobCounts {
617
749
 
618
750
  export interface JobWorker {
619
751
  start(): JobWorker;
620
- /** Resolves after in-flight handlers settle. */
621
- stop(): Promise<void>;
752
+ /** Stop claiming, signal in-flight handlers, and wait up to `graceMs`
753
+ * (JOBS-FORMAT §6): the record says whether every loop drained. */
754
+ stop(options?: { graceMs?: number }): Promise<{ drained: boolean; inFlight: number }>;
622
755
  stats(): { claims: number; completions: number; failures: number;
623
- polls: number; wakes: number };
756
+ polls: number; wakes: number; claimErrors: number; inFlight: number };
624
757
  }
625
758
 
626
759
  export interface JobWorkerOptions {
627
- handlers: Record<string,
628
- (payload: unknown, context: { job: JobRecord, checkpointsFor: Function }) => unknown>;
760
+ handlers: Record<string, (payload: unknown, context: {
761
+ job: JobRecord; checkpointsFor: Function; signal: AbortSignal }) => unknown>;
762
+ /** A positive integer; the loops claiming concurrently. */
629
763
  concurrency?: number;
630
764
  pollInterval?: number;
631
765
  leaseMs?: number;
632
766
  owner?: string;
633
767
  backoffBase?: number;
634
768
  backoffCap?: number;
769
+ /** How long `stop()` waits for in-flight handlers by default. */
770
+ stopGraceMs?: number;
635
771
  }
636
772
 
637
773
  export interface JobsApi {
@@ -659,6 +795,7 @@ export interface JobsOptions {
659
795
  pollInterval?: number;
660
796
  backoffBase?: number;
661
797
  backoffCap?: number;
798
+ stopGraceMs?: number;
662
799
  /** Injectable clock and randomness — every test injects both. */
663
800
  now?: () => number;
664
801
  random?: () => number;
@@ -674,6 +811,7 @@ export declare function createDagJobRunner(store: Store, options: {
674
811
  owner?: string;
675
812
  backoffBase?: number;
676
813
  backoffCap?: number;
814
+ stopGraceMs?: number;
677
815
  }): JobWorker;
678
816
 
679
817
  export declare function createJobEngine(options: {
@@ -683,4 +821,8 @@ export declare const JOBS_TABLE: string;
683
821
  export declare const JOB_CHECKPOINTS_TABLE: string;
684
822
  export declare const JOB_DEFAULTS: Readonly<{
685
823
  maxAttempts: number; leaseMs: number; pollInterval: number;
686
- backoffBase: number; backoffCap: number }>;
824
+ backoffBase: number; backoffCap: number; stopGraceMs: number }>;
825
+ /** A total diagnostic string for any value, including ones that fight back. */
826
+ export declare function describeValue(value: unknown): string;
827
+ /** A job result as the queue stores it: JSON text, or the reason it could not be. */
828
+ export declare function serializeResult(value: unknown): unknown;
package/types/node.d.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  import type { Driver } from '@jarenjs/db';
3
3
 
4
4
  export interface NodeOpenOptions {
5
- path?: string;
5
+ /** The busy timeout in milliseconds. */
6
6
  timeout?: number;
7
7
  readOnly?: boolean;
8
+ /** How long work waits for an open transaction (`JD0012` after). */
9
+ queueTimeout?: number;
8
10
  }
9
11
 
10
12
  /** The `node:sqlite` binding; the builtin loads lazily inside open(). */