@jarenjs/db 0.56.0 → 0.67.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 (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
package/src/tracker.js CHANGED
@@ -17,9 +17,15 @@
17
17
  * parent-first, deletes child-first, updates in between, join rows
18
18
  * after both endpoints exist. A foreign-key cycle among the entities
19
19
  * being inserted or deleted is `JD0040`, reported, never a deadlock.
20
- * The whole save is one transaction; the tracker is mutated ONLY
21
- * after commit, so a failed save leaves it exactly as it was and a
22
- * 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.
23
29
  */
24
30
 
25
31
  import { createJSONPatch } from '@jarenjs/json/patch';
@@ -103,8 +109,9 @@ export function createTracker(context) {
103
109
  const memberships = new Map();
104
110
  let pendingSequence = 0;
105
111
 
106
- const keyOf = (entityName, parts) =>
107
- `${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]);
108
115
 
109
116
  const recordKeyFor = (entityName, doc) => {
110
117
  const plan = coreFor(entityName).plan;
@@ -206,13 +213,34 @@ export function createTracker(context) {
206
213
  });
207
214
  };
208
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
+
209
234
  /**
210
235
  * The pending membership delta a `link`/`unlink` addresses (§11.7):
211
236
  * 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
237
+ * key is read from a key or a document; the target is a key or a
214
238
  * document carrying the target's key — the reading a membership array
215
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.
216
244
  */
217
245
  const membershipOf = (entityName, own, member, target, verb) => {
218
246
  const plan = coreFor(entityName).plan;
@@ -225,10 +253,14 @@ export function createTracker(context) {
225
253
  + "memberships only; write the related entity's foreign key instead");
226
254
  }
227
255
  let ownKey;
256
+ let ownRecord;
228
257
  if (own !== null && typeof own === 'object' && !Array.isArray(own)) {
229
258
  ownKey = own[plan.keys[0]];
230
- if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
231
- throw needsOwnKey(entityName, member, `${verb}()`);
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
+ }
232
264
  }
233
265
  else {
234
266
  ownKey = coreFor(entityName).normalizeKey(own)[0];
@@ -236,10 +268,11 @@ export function createTracker(context) {
236
268
  const targetKey = mapping.entities[relation.to].keys[0];
237
269
  const [key] = membershipKeys([target], targetKey, member,
238
270
  (reason) => contractError(entityName, reason));
239
- const id = `${keyOf(entityName, [ownKey])}${UNIT_SEPARATOR}${member}`;
271
+ const id = `${ownToken(entityName, ownKey, ownRecord)}${UNIT_SEPARATOR}${member}`;
240
272
  let pending = memberships.get(id);
241
273
  if (pending === undefined) {
242
- pending = { entity: entityName, member, ownKey, links: new Set(), unlinks: new Set() };
274
+ pending = { entity: entityName, member, ownKey, ownRecord,
275
+ links: new Set(), unlinks: new Set() };
243
276
  memberships.set(id, pending);
244
277
  }
245
278
  return { pending, key };
@@ -414,7 +447,10 @@ export function createTracker(context) {
414
447
  const membershipDelta = (pending) => ({
415
448
  ...joinEndpoints(pending.entity, pending.member),
416
449
  ownKey: pending.ownKey,
417
- beforeKeys: null,
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 : [],
418
454
  links: [...pending.links],
419
455
  unlinks: [...pending.unlinks],
420
456
  });
@@ -437,9 +473,16 @@ export function createTracker(context) {
437
473
  + 'projection, not stored state; add the related entities themselves');
438
474
  }
439
475
  const ownKey = record.current[plan.keys[0]];
440
- if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
476
+ const known = typeof ownKey === 'string' || typeof ownKey === 'number';
477
+ if (!known && plan.autoKey === null)
441
478
  throw needsOwnKey(record.entity, property.name, 'add()');
442
- ops.push(joinDiff(record.entity, null, record.current, ownKey, property.name));
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
+ });
443
486
  }
444
487
  return ops;
445
488
  };
@@ -452,6 +495,10 @@ export function createTracker(context) {
452
495
  const updates = [];
453
496
  /** @type {any[]} */
454
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 = [];
455
502
  let fallbacks = 0;
456
503
  const unversioned = new Set();
457
504
 
@@ -484,7 +531,10 @@ export function createTracker(context) {
484
531
  if (parts.columnSets.size === 0 && parts.docBuild === null
485
532
  && !parts.fallback) {
486
533
  // nothing but join-table changes (or a no-op put)
487
- if (parts.m2mMembers.size > 0) record.joinOnly = true;
534
+ if (parts.m2mMembers.size > 0) {
535
+ record.joinOnly = true;
536
+ joinOnly.push(record);
537
+ }
488
538
  else record.stamped = undefined;
489
539
  continue;
490
540
  }
@@ -505,7 +555,7 @@ export function createTracker(context) {
505
555
  // folds into that op's key set: one intent per entity, own key and
506
556
  // member, never two statements racing for one row
507
557
  const synced = new Map(joinOps.map((op) =>
508
- [`${op.entity}${UNIT_SEPARATOR}${op.ownKey}${UNIT_SEPARATOR}${op.member}`, op]));
558
+ [`${ownToken(op.entity, op.ownKey, op.ownRecord)}${UNIT_SEPARATOR}${op.member}`, op]));
509
559
  for (const [id, pending] of memberships) {
510
560
  const diff = synced.get(id);
511
561
  if (diff === undefined) {
@@ -652,6 +702,9 @@ export function createTracker(context) {
652
702
  + op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
653
703
  statements.push({
654
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,
655
708
  params: op.added.flatMap((key) => [op.ownKey, key]),
656
709
  joinRows: op.added.map((key) => ({
657
710
  own: op.ownKey, target: key,
@@ -693,7 +746,7 @@ export function createTracker(context) {
693
746
  }
694
747
  }
695
748
 
696
- return { statements, fallbacks, unversioned: [...unversioned].sort() };
749
+ return { statements, fallbacks, joinOnly, unversioned: [...unversioned].sort() };
697
750
  };
698
751
  return chain(resolveJoins(0), assemble);
699
752
  };
@@ -734,6 +787,15 @@ export function createTracker(context) {
734
787
  const next = (i) => {
735
788
  if (i >= statements.length) return report;
736
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
+ }
737
799
  return chain(connection.prepare(statement.sql), (prepared) => {
738
800
  if (statement.kind === 'insert' && statement.returning === true) {
739
801
  let fetched;
@@ -749,6 +811,9 @@ export function createTracker(context) {
749
811
  // test, not assumed silently)
750
812
  const keys = rows.map((row) => row.key).sort((a, b) => a - b);
751
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]; });
752
817
  report.inserted += statement.records.length;
753
818
  report.statements.push({ sql: statement.sql, rows: statement.records.length });
754
819
  return next(i + 1);
@@ -783,21 +848,116 @@ export function createTracker(context) {
783
848
  return next(0);
784
849
  };
785
850
 
786
- /** Commit phase: only reached after the transaction succeeded. */
787
- 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;
788
945
  for (const statement of statements) {
789
946
  if (statement.kind === 'insert') {
790
947
  statement.records.forEach((record, i) => {
791
948
  const plan = coreFor(statement.entity).plan;
792
- let doc = record.current;
793
- if (statement.returning === true) {
794
- doc = deepFreeze({ ...doc, [plan.autoKey]: statement.generatedKeys[i] });
795
- }
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;
796
955
  // re-key under the real identity
797
956
  records.delete(record.pendingKey);
798
957
  const key = recordKeyFor(statement.entity, doc);
799
958
  records.set(/** @type {string} */ (key), {
800
- entity: statement.entity, snapshot: doc, current: doc, pendingInsert: false,
959
+ entity: statement.entity, snapshot: doc,
960
+ current: untouched(record) ? doc : record.current, pendingInsert: false,
801
961
  });
802
962
  record.saved = doc;
803
963
  captureRecord?.(statement.entity,
@@ -811,8 +971,9 @@ export function createTracker(context) {
811
971
  const saved = statement.newVersion === null
812
972
  ? record.stamped
813
973
  : deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
974
+ const pending = untouched(record) ? null : record.current;
814
975
  record.snapshot = deepFreeze(saved);
815
- record.current = record.snapshot;
976
+ record.current = pending ?? record.snapshot;
816
977
  record.stamped = undefined;
817
978
  captureRecord?.(statement.entity,
818
979
  plan.keys.map((k) => record.snapshot[k]), before, record.snapshot);
@@ -846,8 +1007,9 @@ export function createTracker(context) {
846
1007
  // join-only records: their member state is now persisted
847
1008
  for (const record of records.values()) {
848
1009
  if (record.joinOnly === true) {
849
- record.snapshot = record.stamped ?? record.current;
850
- 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;
851
1013
  record.joinOnly = undefined;
852
1014
  record.stamped = undefined;
853
1015
  }
@@ -858,7 +1020,7 @@ export function createTracker(context) {
858
1020
 
859
1021
  const saveChanges = () => {
860
1022
  const startedAt = performance.now();
861
- return chain(planSave(), ({ statements, fallbacks, unversioned }) => {
1023
+ return chain(planSave(), ({ statements, fallbacks, joinOnly, unversioned }) => {
862
1024
  const report = {
863
1025
  inserted: 0, updated: 0, deleted: 0,
864
1026
  joinInserted: 0, joinDeleted: 0,
@@ -874,10 +1036,22 @@ export function createTracker(context) {
874
1036
  report.elapsedMs = performance.now() - startedAt;
875
1037
  return report;
876
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);
877
1043
  return chain(
878
1044
  connection.transaction(() => runStatements(statements, report)),
879
1045
  (finished) => {
880
- 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) });
881
1055
  finished.elapsedMs = performance.now() - startedAt;
882
1056
  return finished;
883
1057
  });
package/src/udf.js CHANGED
@@ -16,10 +16,16 @@
16
16
  * fingerprint clash between DIFFERENT identities is disambiguated with
17
17
  * a suffix rather than collapsed.
18
18
  *
19
- * WHERE-clause use only: an INDEX over a registered function would
20
- * make the database unwritable from any connection that has not
21
- * registered the identical function that schema-dependency hazard is
22
- * why the model format declares no UDF-expression indexes.
19
+ * WHERE-clause use only, and that is the difference between this hatch
20
+ * and a DECLARED index expression (MODEL-FORMAT §7A). An index over a
21
+ * registered function makes the database unwritable from a connection
22
+ * that has not registered the identical function; a fragment registered
23
+ * here is a QUERY's, discovered from the caller's document at run time,
24
+ * and indexing one would make a passing query a permanent schema
25
+ * dependency nobody declared. A model's `indexes[].expression` carries
26
+ * exactly that dependency in the model, where every store that opens it
27
+ * is handed the same declaration and one that cannot honour it refuses
28
+ * at open.
23
29
  *
24
30
  * Ring 3 extends the hatch to registry `pushable:'scalar'`
25
31
  * operators: a predicate fragment that uses a registered scalar operator
@@ -34,7 +40,7 @@
34
40
 
35
41
  import { semanticKey } from '@jarenjs/core/object';
36
42
  import { hashContent } from '@jarenjs/core/string';
37
- import { compileJsonQuery, analyzeQuery } from '@jarenjs/json/query';
43
+ import { compileJsonQuery, analyzeQuery, JsonQueryRuntimeError } from '@jarenjs/json/query';
38
44
 
39
45
  /**
40
46
  * The SQL identifier for one fragment identity: a short fingerprint of
@@ -61,7 +67,14 @@ const functionNameFor = (identity) => `jaren_p_${hashContent(identity)}`;
61
67
  * external, the determinism check below rejects the fragment, and the
62
68
  * hatch silently never engages.
63
69
  * @returns {{ key: string, name: string,
64
- * compile: () => (docText: string) => number } | null}
70
+ * compile: () => (docText: string, mount?: string) => number } | null}
71
+ * - the compiled function takes the row's document text and the
72
+ * conjunct's JSON Pointer in the CALLER's document (`/$where`, or
73
+ * `/$where/$and/<i>`), which the emitter passes as a literal: an
74
+ * engine error raised inside names the wrapper's path (`/$return/…`)
75
+ * and is rebased onto that mount, so the native mode and the residual
76
+ * report the same location while one registration still serves every
77
+ * document that carries the fragment
65
78
  */
66
79
  export function deterministicFragment(fragment, operators = null, binding = 'it') {
67
80
  const analyzeOpts = operators === null
@@ -103,6 +116,7 @@ export function deterministicFragment(fragment, operators = null, binding = 'it'
103
116
  // correct, so it does not qualify for the hatch
104
117
  return null;
105
118
  }
119
+ const WRAPPER = '/$return';
106
120
  return {
107
121
  key,
108
122
  name: functionNameFor(key),
@@ -111,7 +125,25 @@ export function deterministicFragment(fragment, operators = null, binding = 'it'
111
125
  // different name than the analysis would judge one document and
112
126
  // run another
113
127
  const compiled = compileJsonQuery(wrap(fragment), analyzeOpts);
114
- return (docText) => (compiled.ebv(JSON.parse(docText)) ? 1 : 0);
128
+ // two declared parameters on purpose: node:sqlite registers the
129
+ // function with the arity `fn.length` reports, and the emitter
130
+ // always passes the mount beside the document
131
+ return (docText, mount) => {
132
+ try {
133
+ return compiled.ebv(JSON.parse(docText)) ? 1 : 0;
134
+ }
135
+ catch (error) {
136
+ // the engine's own refusal, relocated from the wrapper onto
137
+ // the caller's document; anything else propagates as it is
138
+ if (error instanceof JsonQueryRuntimeError && typeof error.docPath === 'string'
139
+ && error.docPath.startsWith(WRAPPER)) {
140
+ throw new JsonQueryRuntimeError(error.code, error.reason,
141
+ (typeof mount === 'string' ? mount : '/$where') + error.docPath.slice(WRAPPER.length),
142
+ Object.hasOwn(error, 'cause') ? { cause: error.cause } : undefined);
143
+ }
144
+ throw error;
145
+ }
146
+ };
115
147
  },
116
148
  };
117
149
  }
@@ -139,3 +171,52 @@ export function registerFragment(connection, registered, fragment) {
139
171
  registered.set(fragment.key, name);
140
172
  return name;
141
173
  }
174
+
175
+ /**
176
+ * The SQL identifier for one registered aggregate. Unlike a predicate
177
+ * fragment, the identity IS the operator name — one registry, one
178
+ * function per name — so the fingerprint has nothing to disambiguate.
179
+ * @param {string} name
180
+ * @returns {string}
181
+ */
182
+ const aggregateNameFor = (name) => `jaren_a_${hashContent(name)}`;
183
+
184
+ /**
185
+ * Register a pushable aggregate once per store and answer the SQL name
186
+ * to call. The SQL fold accumulates the column's values and hands them
187
+ * to the SAME pure function the residual would call, so the two sides
188
+ * differ in who drives the loop and in nothing else.
189
+ *
190
+ * A `NULL` column value is SKIPPED, because the engine's sequence has no
191
+ * item where the member is absent — which is why only a path the schema
192
+ * types as a number that cannot hold `null` reaches here: a stored
193
+ * `null` and an absent member are one value in SQL, and dropping a
194
+ * present `null` would answer where the engine does not.
195
+ *
196
+ * `undefined` — what these summaries answer for an input they cannot
197
+ * summarise — becomes SQL `NULL`, which the aggregate decoder reads back
198
+ * as the empty answer, exactly as the engine's empty sequence does.
199
+ * @param {any} connection
200
+ * @param {Map<string, string>} registered - operator name → SQL name
201
+ * @param {string} name - the registry operator name (`$mean`)
202
+ * @param {{ fn: Function }} spec
203
+ * @returns {string} the SQL function name to call
204
+ */
205
+ export function registerAggregateOperator(connection, registered, name, spec) {
206
+ const owned = registered.get(name);
207
+ if (owned !== undefined) return owned;
208
+ const sqlName = aggregateNameFor(name);
209
+ connection.registerAggregate(sqlName, {
210
+ start: () => [],
211
+ step: (values, value) => {
212
+ if (value !== null && value !== undefined) values.push(value);
213
+ return values;
214
+ },
215
+ result: (values) => {
216
+ const out = spec.fn(values);
217
+ return out === undefined || out === null ? null : out;
218
+ },
219
+ });
220
+ registered.set(name, sqlName);
221
+ return sqlName;
222
+ }