@jarenjs/db 0.72.3 → 0.75.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.
package/ARCHITECTURE.md CHANGED
@@ -177,9 +177,19 @@ stable code, an object and a detail, sorted, once:
177
177
  | `unmapped-table` | a table with neither a document column nor a key |
178
178
  | `unmapped-column` | a generated column whose expression is not a member path this dialect wrote |
179
179
  | `unmapped-index` | an index over an expression, a predicate, or a column no member path explains |
180
+ | `unmapped-constraint` | a CHECK that is not a complete scalar enum of a mapped entity column |
180
181
  | `unmapped-type` | a column type no schema type maps back from — the member is derived untyped |
181
182
  | `ambiguous-relation` | a foreign key says which entity it points at; which side declared the edge, and whether the other holds many, is not in the shape |
182
183
 
184
+ Scalar entity enum CHECKs are recovered on both dialects. SQLite reads its
185
+ stored CREATE text; PostgreSQL reads `pg_constraint` expressions, including
186
+ its `ANY (ARRAY[...])` rendering and numeric literal casts. The parser accepts
187
+ only the entire enum expression. Unknown predicates, NULL lists, non-binary SQLite collations, nondeterministic
188
+ PostgreSQL collations and casts whose meaning is not proven remain `unmapped-constraint`. Partial indexes and
189
+ expression terms retain their catalog flags; they are never converted into
190
+ unconditional uniqueness. Optional `introspect.checks`/`readChecks` hooks leave
191
+ injected dialects compatible with the earlier catalog surface.
192
+
183
193
  `strict: true` refuses instead of returning a partial model, because a
184
194
  caller about to diff the result against a declared model needs to know
185
195
  the difference is real.
@@ -351,7 +361,8 @@ A single-binding FLWOR over the collection (`$for: { <name>: '$[*]' }`
351
361
  — the examples here write `it`, but the binding is the **document's** to
352
362
  name and nothing translates differently under another one)
353
363
  with: comparison predicates (`$eq $ne $lt $le $gt $ge`) between a
354
- singular member path and a literal or external; `$and`/`$or`/`$not`
364
+ singular member path and a literal or external, or two paths in the same
365
+ non-null number/string family; `$and`/`$or`/`$not`
355
366
  composition; `$exists`/`$empty`; `$starts-with`/`$ends-with`/
356
367
  `$contains` on schema-typed string paths with literal patterns;
357
368
  `$orderby` over singular schema-typed paths (`$dir`, `$empty`, no
@@ -359,11 +370,33 @@ collation); a top-level `$subsequence` window with literal bounds; the
359
370
  top-level aggregates `$count` and `$sum`/`$avg`/`$min`/`$max` over a
360
371
  singular schema-typed path; the whole-document projection that returns
361
372
  the bare binding; ONE member path, projected as its value beside its
362
- JSON type; and a nested SHAPE of objects, arrays, literals and member
373
+ JSON type; and a nested SHAPE of objects, arrays, literals, whole collection bindings and member
363
374
  paths, projected as one value/type pair per distinct leaf and rebuilt
364
375
  by the decoder — never by parsing a JSON text the database assembled,
365
376
  which could not tell an absent member from a present `null`.
366
377
 
378
+ Constant projection trees fetch a row marker instead of a document. A
379
+ window over a single path removes absent members before its SQL limit;
380
+ a window over an opaque projection stays set-residual because that projection
381
+ may emit zero or several items per source row.
382
+
383
+ General scalar grouping preserves absent and null keys separately through
384
+ value/type pairs. Null and boolean keys may group, but ordering them stays
385
+ residual to preserve `JQ2005`. Explicit group ordering accepts orderable keys and proven count/min/max
386
+ expressions, including aggregates absent from the return; first appearance
387
+ breaks ties. SUM/AVG ordering remains residual: compensated or differently
388
+ ordered SQL accumulation can change which group sorts first. Literal windows lower over singleton group constructors;
389
+ `$count` over such constructors with only row-count aggregates counts a grouped
390
+ subquery. `$distinct` over a typed scalar projection uses the same key relation, with
391
+ absent members removed first. Unordered inputs retain first appearance; an
392
+ ordering composed solely of the projected path lowers to group-key order.
393
+
394
+ Entity projection trees, constants, counts and single-path windows follow the
395
+ same cardinality rules. An equijoin graph can additionally filter through
396
+ boolean trees whose leaves belong to individual bindings. The entity emitter
397
+ resolves each leaf's binding alias before composing the total predicates;
398
+ a disjunction never supplies a mandatory join edge.
399
+
367
400
  Plus the spatial predicates a **derived** index makes decidable:
368
401
  `$bbox-intersects` against a literal or external region, a geohash
369
402
  prefix no longer than a `derive: 'geohash'` column's precision, and the
@@ -396,15 +429,16 @@ mode, `knn`, beside native, row and set.
396
429
  | construct | reason |
397
430
  |---|---|
398
431
  | `$let` bindings, `$fold`, positional/window bindings | no equivalence proof exists yet; residual by default |
399
- | a `$groupby` whose key is untyped or nullable, whose `$return` reads the binding, or whose `$orderby` names anything but a key | SQL's grouping and the engine's need not agree on an untyped key; after a grouping the binding holds the group's ROWS, which an object member cannot take |
400
- | a window over the GROUPS, or an aggregate of them | the plan groups whole; a `LIMIT` over the groups would cut a different set |
432
+ | a `$groupby` whose key is untyped, whose `$return` reads the binding, or whose `$orderby` names anything but an orderable key or count/min/max | SQL's grouping and the engine's need not agree on an untyped key; after a grouping the binding holds the group's ROWS, which an object member cannot take |
433
+ | a window over a group return that may omit an item, or a group aggregate beyond the proven constructor count | SQL group cardinality must equal the projected item cardinality; numeric and error semantics need their own proof |
401
434
  | a `$for` binding nothing joins to — a cartesian product | the engine builds the product; a plan that emitted one by accident is the thing an equi-join graph exists to prevent |
402
435
  | non-singular path expansion | one relation per binding in this version |
403
436
  | `$match` and other unlisted operators, `$call` | no native spelling proven equivalent |
404
437
  | `$orderby` with a `$collation` | a collation the dialect cannot reproduce is refused, not approximated |
405
- | a projection the tree cannot rebuild: an operator over a member, a reference to the binding itself, a non-singular path, a projection with no member path at all | the WHOLE projection runs per row (the row residual) — pushed, ordered and windowed rows, projected by the engine; promoting the part that composes would answer a shape nobody asked for |
438
+ | a projection the tree cannot rebuild: an operator over a member, a reference to the binding itself, a non-singular path | the WHOLE projection runs per row (the row residual) — pushed and ordered rows, projected by the engine; a window over these items runs in the set residual; promoting the part that composes would answer a shape nobody asked for |
406
439
  | string operators with an external pattern | the pattern's type is unknowable at plan time and the engine ERRORS on non-string patterns |
407
- | comparisons where both sides are paths | join territory |
440
+ | path comparisons with untyped, nullable, boolean or differing comparison families | the total typed comparison proof does not cover these shapes |
441
+ | `$distinct` over untyped/compound projections or ordering by other paths | first-occurrence order and structural equality need additional lowering |
408
442
  | array/object literals in comparisons | deep-equality has no guarded native form |
409
443
  | `$within` over a `derive: 'bbox'` column | a bounding-box pre-filter is pushed; exact containment refines in the engine |
410
444
  | a bounded `$distance` over a `derive: 'bbox'` column | a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine |
@@ -788,7 +822,7 @@ exactly what an honest explain may not print.
788
822
  ### The two residual modes
789
823
 
790
824
  - **Row residual** — only the projection is untranslated: predicates,
791
- ordering and the window are fully pushed; each fetched row runs
825
+ and ordering are fully pushed, with no output window; each fetched row runs
792
826
  `{ $for: { <the document's own binding>: '$[*]' },
793
827
  $return: [ <the document's $return> ] }` (the array wrapper keeps
794
828
  array-valued items unambiguous) and the items concatenate in row
@@ -1116,3 +1150,12 @@ by refreshing affected parents from bounded leaves. Source and result payloads
1116
1150
  consume row and byte credits; unsupported shapes keep named reruns. See
1117
1151
  [the replication contract](docs/REPLICATION-FORMAT.md) and
1118
1152
  [the strategy matrix](docs/LIVE-FORMAT.md).
1153
+
1154
+ Parameterized distance bounds use the closed `circleAxis` derived parameter:
1155
+ `{ kind: 'circleAxis', centre: { external } | { literal }, radius: { external }
1156
+ | { literal }, axis: 'w' | 's' | 'e' | 'n' }`. Both inputs are named in every
1157
+ edge slot. The binder uses the shared geographic kernel and diverts the whole
1158
+ query for missing/invalid values, negative radii, polar or antimeridian boxes.
1159
+ The exact distance predicate still refines the candidates. The shared oracle
1160
+ covers SQLite column/R*Tree indexes and PostgreSQL, including repeated cached
1161
+ calls with different bound values.
package/README.md CHANGED
@@ -214,7 +214,7 @@ no continuation to emit.
214
214
  `explain().projection` names the path or the leaf `paths`, and
215
215
  reading a shape no longer reads every document. A shape the tree
216
216
  cannot rebuild — an operator over a member, a reference to the
217
- binding itself, a projection with no path at all — refuses WHOLE and
217
+ whole entity binding, a non-singular path — refuses WHOLE and
218
218
  runs per row, with `explain().residualProjection` naming what stayed
219
219
  behind: promoting the half that composes would answer a shape nobody
220
220
  asked for. Every `explain()` also carries `budget`: the profile
@@ -227,6 +227,10 @@ no continuation to emit.
227
227
  arithmetic deviation declared rather than hidden (MODEL-FORMAT §10.6: SQLite's
228
228
  compensated `SUM` and the engine's naive one differ in the last
229
229
  bit). `strict: true` turns any residual into a compile error.
230
+ Constant trees fetch only a row marker. Counts over entity projection trees
231
+ lower too; a single-path count or window excludes absent members before
232
+ counting items. A window over a projection that can emit several items runs
233
+ in the engine.
230
234
  - **Registered operators, correct in the residual, pushed where it
231
235
  pays.** Open with a registry (`operators:
232
236
  createJsltRegistry().use(mathPack).use(financePack)`) and a query may
@@ -254,6 +258,12 @@ no continuation to emit.
254
258
  `openStore` by name — and the promotion still needs a numeric member
255
259
  the schema forbids `null` on, because SQL cannot tell a stored `null`
256
260
  from an absent one and the engine can.
261
+ - **Typed path comparisons and distinct scalar projections.** Comparisons
262
+ between two paths in the same non-null number/string family lower without
263
+ a UDF. `$distinct` over a typed scalar projection groups by its value and JSON
264
+ type, excluding missing members. Unordered inputs retain first occurrence;
265
+ ordering solely by the projected path also lowers. Literal windows apply
266
+ to the distinct items.
257
267
  - **Grouping and joins lower whole, or not at all.** A `$groupby` over
258
268
  schema-typed member keys becomes a real `GROUP BY`: the keys come back
259
269
  with their JSON types beside them, so a group whose key is ABSENT
@@ -262,13 +272,20 @@ no continuation to emit.
262
272
  in SQL under the ENGINE's empty rules (`0` for a count or a sum, no
263
273
  member at all for the other three), and the groups come out in the
264
274
  engine's own order of first appearance unless an `$orderby` over the
265
- keys says otherwise. Entity queries join any number of bindings: every
275
+ keys or count/min/max says otherwise. Entity queries join any number of bindings: every
266
276
  binding past the first must be attached by a column equality to one
267
277
  already joined, which is what makes the plan a nested loop the engine
268
278
  can be compared against — a binding nothing attaches would be a
269
279
  cartesian product, so it is the residual, named, and `strict: true`
270
280
  refuses it. `explain()` lists the join order with the equalities that
271
281
  attached each binding, and the group's keys, aggregates and order.
282
+ Nullable scalar keys also group; ordering null or boolean keys remains
283
+ residual to preserve the engine's error. Windows over singleton group
284
+ constructors lower, and counts over constructors containing only keys,
285
+ literals and row counts count the grouped subquery. Ties under a partial
286
+ group ordering retain first appearance.
287
+ Boolean predicates may span joined bindings when each leaf belongs to one
288
+ binding; the equality graph still establishes every join.
272
289
  A join predicate that is not an equality — a range between two mapped
273
290
  columns of one family — refines a match it never makes: the anchor is
274
291
  still an equality, so a range alone stays the residual. A projected
@@ -1177,6 +1194,13 @@ be declared (`unmapped-view`), a foreign key cannot say which side
1177
1194
  declared the edge (`ambiguous-relation`). `strict: true` refuses rather
1178
1195
  than answering a partial model.
1179
1196
 
1197
+ Scalar enum CHECKs on mapped entity columns are recovered from both
1198
+ catalogs. A partial index or an expression term is reported as `unmapped-index`
1199
+ without inventing unconditional uniqueness. CHECKs outside the complete scalar
1200
+ enum grammar, including NULL lists, collation-dependent equality and unproven
1201
+ type conversions, remain
1202
+ `unmapped-constraint`.
1203
+
1180
1204
  The derived model is usable as a migration's `from`: read a database,
1181
1205
  plan against your declared model, and an unchanged shape plans nothing.
1182
1206
  SQLite and PostgreSQL derive the same logical model from equivalent
@@ -1309,10 +1333,10 @@ its side-effect-free status read are in
1309
1333
  capture after a disposable live probe. Failed session probes select journal
1310
1334
  capture. OPFS, IndexedDB snapshots and memory have explicit capability and
1311
1335
  durability differences; see [execution hosts](docs/HOSTS.md).
1312
- - **Named future work, not silent gaps**: `$groupby` pushdown beyond
1313
- the `$time-bucket` ladder, a many-to-many hop on the chain, membership
1314
- on an auto-keyed pending insert, additional join/group shapes, other SQL dialects,
1315
- transport policy, database introspection (MODEL-FORMAT §10.6, the roadmap).
1336
+ - **Remaining boundaries**: untyped and opaque grouped/projection shapes,
1337
+ federation beyond two sources, richer incremental live queries, portable
1338
+ capture and introspection outside the model vocabulary (MODEL-FORMAT §10.6
1339
+ and the roadmap).
1316
1340
 
1317
1341
  The normative formats are
1318
1342
  [docs/MODEL-FORMAT.md](docs/MODEL-FORMAT.md) (storage §§1–7, safe
@@ -1368,3 +1392,17 @@ The output is one atomically published collection bundle; read it with
1368
1392
  `--format collections`. Assertion plans expose provider, ordered-fold and
1369
1393
  bounded materialization strategies through `onAssertionPlan`.
1370
1394
  See [MIGRATION-FORMAT §6 and §11](docs/MIGRATION-FORMAT.md).
1395
+
1396
+ Collection constructor projections can include a whole row beside member
1397
+ paths. Group ordering lowers count/min/max expressions, including aggregates
1398
+ used only for sorting; SUM/AVG ordering remains in the engine to preserve
1399
+ floating-point accumulation order. Parameterized distance bounds bind both
1400
+ centre and radius through the spatial index, with full-query fallback for
1401
+ invalid, polar and antimeridian probes.
1402
+
1403
+ Live collection groups now maintain multiple keys, unordered typed scalar
1404
+ distinct projections, and count/sum/avg/min/max over canonical group results.
1405
+ Only affected groups are reevaluated in source order; a final aggregate folds
1406
+ the retained group outputs in first-appearance order. Input documents and
1407
+ group outputs consume both state-entry and byte credits. See
1408
+ [LIVE-FORMAT](docs/LIVE-FORMAT.md) for the supported shapes and measured costs.
@@ -192,11 +192,11 @@ Choosing how much history to keep is the host's decision
192
192
  (`retention`); what the reader owes is that when rows go, it reports
193
193
  the gap instead of hiding it.
194
194
 
195
- **Replication is not built here**, and this log alone does not make
196
- it safe: there is no conflict resolution, no site identity, no causal
197
- ordering across writers. The bounded reader with its watermarks and
198
- its explicit gap is the precondition a replication protocol would be
199
- built on — not the protocol. That sentence is the whole claim.
195
+ **The log alone is not replication.** Replica identity, causal frontiers,
196
+ durable receipts, conflicts and bounded reset snapshots belong to the opt-in
197
+ [replication subsystem](REPLICATION-FORMAT.md). Its protocol builds on these
198
+ watermarks; a change-log cursor by itself supplies no conflict policy or causal
199
+ ordering across writers.
200
200
 
201
201
  ## 6. Cross-connection behaviour and non-claims
202
202
 
@@ -208,8 +208,8 @@ which changes when ANOTHER connection commits; poll it and treat a
208
208
  change as "re-read what you care about". Cross-tab delivery is §7's
209
209
  story (the live-query layer).
210
210
 
211
- Non-claims, in one place: no replication, no conflict resolution, no
212
- capture of writes made by other connections, no capture on stores
211
+ Capture-layer non-claims: no conflict resolution or capture of writes made
212
+ by other connections, no capture on stores
213
213
  opened without `capture`, and no statement-level ordering within a
214
214
  commit (§2).
215
215
 
@@ -255,9 +255,8 @@ the maintenance are this table's — an entity chain re-runs, declared —
255
255
  and this document stays the only place they are decided.
256
256
 
257
257
  **This table is normative.** Every row is implemented and tested;
258
- nothing outside it is attempted. Classification reads the compiled
259
- PLAN (never the raw document), so "extractable" below means exactly
260
- what the pushdown planner already means by it.
258
+ nothing outside it is attempted. Classification combines canonical document shapes with the compiled selection
259
+ plan; "extractable" below means what the pushdown planner already proves.
261
260
 
262
261
  | Construct (as planned) | Strategy | Maintained state |
263
262
  |---|---|---|
@@ -265,7 +264,10 @@ what the pushdown planner already means by it.
265
264
  | the same with a per-row `select` projection (row-mode plan) | **incremental rows**: the affected row alone is recomputed; a source row may project to several items | result rows, grouped by source key |
266
265
  | `orderBy` over extractable paths, optional `limit`, offset 0 | **maintained window**: a sorted structure; ties broken by the collection key, appended as the final sort term; an insert sorting beyond a full window is a no-op | the window rows and their sort keys |
267
266
  | whole-query `count` / `sum` / `avg` / `min` / `max` (the plan's aggregate), optional `where` | **running accumulator** plus a per-row contribution map — a delete can only be answered from retained contributions (§3: a `remove` carries no old value). `min`/`max` removal of the last extremum holder FALLS BACK to a recompute over the retained contributions; the accumulator alone cannot answer, and this fallback is the documented cost | one contribution per matching row |
268
- | single-level `groupBy` with aggregate returns, in the canonical form below | **per-group deltas**: the accumulator machinery, one instance per group; groups appear in first-appearance order, exactly the engine's order | per-group, per-row contributions |
267
+ | single-level `groupBy` with one or more keys and canonical returns below | **group maintenance**: reevaluate affected groups through the engine in source-row order; first surviving source occurrence determines group order | source documents and group outputs, with entry and byte credits |
268
+ | unordered SQL-native typed scalar distinct projections | **distinct maintenance**: keep the source holders of each value; deleting its earliest holder may move the value in first-occurrence order | source documents and unique outputs, with entry and byte credits |
269
+ | count/sum/avg/min/max over canonical unwindowed groups | **group maintenance** followed by an engine fold over the retained group outputs in first-occurrence order | source documents and group outputs, with entry and byte credits |
270
+ | ordered/windowed distinct or other aggregates over groups | **re-run on invalidation** | the previous result, for diffing |
269
271
  | `where` whose spatial predicate is **refined** (the plan pushed a bounding-box or cell-range pre-filter and left the exact `$within`, bounded `$distance` or over-long cell prefix to the residual — `explain().prefilters` with `exact: false`), no order, no aggregate; optional per-row `select` | **incremental rows** — the geofence: the initial fetch narrows through the derived index, and every touched row is re-evaluated by the engine's EXACT predicate, so a point emits `add` when it enters the region, `remove` when it leaves, and nothing while it moves within (a whole-document return sees a `replace` carrying the new position) | the result rows |
270
272
  | a refined spatial predicate over a collection with **no document key** (`key: null`, rowid identity) | **re-run on invalidation** — the per-row strategy tracks a row by its declared key, and a rowid is not one; the reason says `rows without a document key cannot be tracked` | the previous result, for diffing |
271
273
  | `orderBy` beside a refined spatial predicate — over `$distance` (not a path) or over a member (the set residual drops the planner's order terms) | **re-run on invalidation**, the ordering named as the reason | the previous result, for diffing |
@@ -326,8 +328,8 @@ nested groups have a separate bounded strategy below):
326
328
 
327
329
  After `$groupby`, `$it` is the group's item sequence and `$g` its key;
328
330
  return members are the group key or an aggregate over `$it` (a path
329
- below it selects the aggregated member). Anything else in the return
330
- is not canonical and re-runs.
331
+ below it selects the aggregated member). A bare key or a single aggregate is also canonical; other return shapes
332
+ re-run. Multiple declared keys follow the same rule.
331
333
 
332
334
  ## 8. Invalidation
333
335
 
@@ -657,3 +659,36 @@ Count, sum, average, minimum and maximum recompute from only the affected
657
659
  parent's bounded leaves. An offset, an unsupported operator, a global input to
658
660
  the nested group, or a group-of-groups LINQ emission remains a named rerun.
659
661
  Replicated writes enter the same committed capture stream as local writes.
662
+
663
+ ### Group maintenance details
664
+
665
+ Canonical single-level groups accept one or more key expressions over one
666
+ collection binding, an optional fully translated filter, and either a bare
667
+ key, one count/sum/avg/min/max expression, or an object containing key,
668
+ `$default: [key, null]`, and aggregate members. Global-root reads, ordering,
669
+ and windows remain rerun shapes. Aggregate expressions are evaluated by the
670
+ query engine, including empty results and errors; maintenance does not
671
+ substitute JavaScript arithmetic for query operators.
672
+
673
+ Keys preserve structural equality and distinguish missing from null. Source
674
+ holders retain physical row positions: changing a holder preserves its
675
+ position, and deleting a group's earliest holder may move that group's
676
+ output. Unaffected group outputs retain reference identity. Only affected
677
+ groups are reevaluated, followed by an optional final aggregate over all group
678
+ outputs. This costs work proportional to the affected groups plus group-output
679
+ ordering/folding, rather than constant-time arithmetic deltas.
680
+
681
+ `live.maxMaintained` counts source holders and group output items;
682
+ `live.maxBytes` counts their serialized documents. This includes document
683
+ members omitted from a small aggregate result. Exceeding a bound refuses
684
+ registration or closes an active query with `JD2060`; the last delivered
685
+ result remains unchanged. Engine failures follow the same invalidation path.
686
+
687
+ `node --expose-gc benchmark/changeflow.js` compares every mutation against a
688
+ fresh query and a patch-only consumer. The
689
+ [generated comparison table](REPLICATION-FORMAT.md#measurements) includes
690
+ multiple-key groups, an aggregate of groups and distinct beside forced reruns.
691
+ It reports initialization allocations and both median and tail mutation time;
692
+ heap deltas before collection are not precise retained-state sizes. The fixture
693
+ has 200 initial rows and 15 mutations per case, so these measurements establish
694
+ correctness and costs for that fixture rather than a universal crossover.
@@ -1503,10 +1503,10 @@ The token is a promise about the FOLD, and three rules enforce it:
1503
1503
  the promotion needs the same schema-typed path the core `$sum` and
1504
1504
  `$avg` need. An absent member contributes nothing on either side.
1505
1505
 
1506
- A grouped fold is not promoted — a `$groupby` outside the fixed
1507
- temporal bucket is engine work (§6) and neither is an aggregate under
1508
- a window. Both answer what the engine answers, and `explain()` names
1509
- the reason. As with the scalar hatch, a profiled document triggers no
1506
+ A registered fold inside a `$groupby` return is not promoted — the
1507
+ grouped SQL fold currently handles the built-in aggregate set only and
1508
+ neither is an aggregate under a window. Both answer what the engine answers,
1509
+ and `explain()` names the reason. As with the scalar hatch, a profiled document triggers no
1510
1510
  registration: the same `$mean` under a profile folds in the residual,
1511
1511
  and `strict: true` refuses it by name (`JD0010`).
1512
1512
 
@@ -1826,16 +1826,24 @@ paths stay residual.
1826
1826
 
1827
1827
  ### 10.2 Joins
1828
1828
 
1829
- Two bindings joined by one equality between their column references
1830
- become an INNER equijoin — exactly the engine's
1829
+ Bindings connected by equalities between their column references
1830
+ become an INNER equijoin graph — exactly the engine's
1831
1831
  cross-product-plus-filter semantics. Result order is deterministic:
1832
- any `$orderby` keys first, then BOTH bindings' row identities in
1832
+ any `$orderby` keys first, then all bindings' row identities in
1833
1833
  binding order, which is the engine's nested-loop order. `explain()`
1834
1834
  reports the join (`{ left, right }`) and the `EXPLAIN QUERY PLAN`
1835
1835
  narrative; the paired foreign key carries an index (every foreign key
1836
1836
  does — unique for a strict one-to-one, plain otherwise), so the probe
1837
1837
  side of the join is a `SEARCH`, never a second scan.
1838
1838
 
1839
+ Projection trees of singular member paths and constants lower over one or
1840
+ several bindings. Counts over those trees count their items; a single-path
1841
+ count or window first excludes rows where that path is absent. Boolean
1842
+ predicates may span bindings when each leaf uses one binding's proven total
1843
+ forms. The mandatory column equalities still establish the join graph;
1844
+ a disjunction is never treated as a mandatory join edge. Compatible path
1845
+ comparisons within one binding also lower for non-null numbers and strings.
1846
+
1839
1847
  On the `load` surface the join KIND is derived from the schema
1840
1848
  (§10.4): a `oneToOne` include reports `inner (fk required)` when the
1841
1849
  `via` property is in `required`, `left (fk optional)` otherwise —
@@ -2034,17 +2042,26 @@ a number appears only where `capabilities.rowEstimates` is filled):
2034
2042
  must never emit by accident; every binding past the first attaches by
2035
2043
  a column equality to one already joined, and a graph that does not
2036
2044
  close is the engine's;
2037
- - non-equality join predicates, and disjunctions spanning bindings;
2038
- - a `$groupby` whose key is untyped or admits `null`, whose `$return`
2039
- reads the binding (after a grouping it holds the group's ROWS), or
2040
- whose `$orderby` names anything but a group key; a window over the
2041
- groups, or an aggregate of them;
2042
- - projections (`$return` objects) ACROSS a join over one binding a
2043
- nested shape of member paths lowers the projection tree);
2045
+ - a non-equality predicate used as the only join anchor, or a boolean
2046
+ predicate whose individual leaf cannot be assigned to one binding;
2047
+ - collection grouping with untyped keys, row-binding returns, or ordering by
2048
+ anything except an orderable key or count/min/max expression; entity grouping; aggregates over groups
2049
+ beyond a count of singleton constructors containing keys, literals and row
2050
+ counts; windows over group returns that may omit an item;
2051
+ - projection trees containing operators, whole entity binding references or
2052
+ non-singular paths; windows over those opaque projections;
2053
+ - `$distinct` over untyped/compound projections or ordering by other paths;
2054
+ typed scalar projections lower when unordered or ordered solely by the
2055
+ projected path, preserving first occurrence or that declared order;
2044
2056
  - externals against document paths; booleans and `null` at bind time;
2045
2057
  - everything phase A already listed (§8 of `QUERY-FORMAT.md`
2046
2058
  notwithstanding, the truth table is the contract).
2047
2059
 
2060
+ Collection group keys may include null or boolean values, but ordering those
2061
+ keys stays residual to preserve `JQ2005`. Group ordering is stable under ties.
2062
+ Literal windows over singleton group constructors lower directly, and the
2063
+ proven constructor count wraps the grouped statement in `COUNT(*)`.
2064
+
2048
2065
  Two deviations between a pushed answer and the engine's are DECLARED
2049
2066
  rather than refused, because in both the database is right by its own
2050
2067
  arithmetic:
@@ -184,25 +184,31 @@ allocations observed after initialization, not precise retained-heap sizes.
184
184
 
185
185
  <!--fact:db.changeflow-->
186
186
 
187
- Measured 2026-09-08, v24.19.0, AMD Ryzen 9 5900HX with Radeon Graphics; 15 mutations per case.
187
+ Measured 2026-09-09, v24.20.0, AMD Ryzen 9 5900HX with Radeon Graphics; 15 mutations per case.
188
188
 
189
189
  | Shape | Strategy | Initialize ms | Mutation p50 ms | Mutation p95 ms | Initialization heap bytes |
190
190
  |---|---|---:|---:|---:|---:|
191
- | selective join | join | 16.663 | 0.323 | 2.119 | 4878848 |
192
- | selective join | rerun | 0.899 | 0.746 | 1.622 | 347160 |
193
- | high fan-out join | join | 7.314 | 1.901 | 3.179 | 4851232 |
194
- | high fan-out join | rerun | 0.860 | 0.850 | 1.188 | 340328 |
195
- | graph | graph | 7.462 | 0.459 | 0.750 | 4811712 |
196
- | graph | rerun | 4.539 | 4.042 | 4.979 | 1417928 |
197
- | nested groups | nested-group | 6.775 | 0.252 | 0.795 | 2057832 |
198
- | nested groups | rerun | 0.897 | 0.472 | 0.685 | 295808 |
199
- | offset groups | rerun | 1.519 | 0.459 | 0.547 | 383648 |
200
- | offset groups | rerun | 0.884 | 0.426 | 0.585 | 282280 |
191
+ | selective join | join | 15.740 | 0.349 | 2.173 | 4854712 |
192
+ | selective join | rerun | 1.040 | 0.850 | 1.321 | 347640 |
193
+ | high fan-out join | join | 8.115 | 1.984 | 3.040 | 4769632 |
194
+ | high fan-out join | rerun | 0.744 | 0.973 | 1.303 | 353768 |
195
+ | graph | graph | 7.742 | 0.429 | 0.728 | 4811904 |
196
+ | graph | rerun | 4.606 | 4.170 | 7.953 | 1418576 |
197
+ | multiple-key groups | group | 8.417 | 0.285 | 0.882 | 2107240 |
198
+ | multiple-key groups | rerun | 0.663 | 0.405 | 0.464 | 116960 |
199
+ | group aggregate | group | 3.839 | 0.236 | 0.398 | 1932544 |
200
+ | group aggregate | rerun | 0.846 | 0.449 | 0.670 | 275560 |
201
+ | distinct | distinct | 3.731 | 0.179 | 0.317 | 1616800 |
202
+ | distinct | rerun | 0.480 | 0.238 | 0.333 | 86128 |
203
+ | nested groups | nested-group | 4.565 | 0.206 | 0.377 | 1757528 |
204
+ | nested groups | rerun | 0.779 | 0.551 | 0.675 | 293560 |
205
+ | offset groups | rerun | 1.274 | 0.474 | 0.623 | 383792 |
206
+ | offset groups | rerun | 0.773 | 0.527 | 1.158 | 299416 |
201
207
 
202
208
  | Capture | Envelopes | Operations | Bytes | Apply p50 ms | Replay p50 ms | Conflict p50 ms |
203
209
  |---|---:|---:|---:|---:|---:|---:|---:|
204
- | session | 15 | 15 | 3169 | 0.458 | 0.118 | 0.204 |
205
- | journal | 15 | 15 | 3169 | 0.294 | 0.092 | 0.161 |
210
+ | session | 15 | 15 | 3169 | 0.463 | 0.119 | 0.213 |
211
+ | journal | 15 | 15 | 3169 | 0.333 | 0.097 | 0.160 |
206
212
 
207
213
  Selective maintenance avoids repeated full SQL evaluation. Initialization and high-fan-out maintenance can cost more than rerunning; the table includes both. Offset groups remain rerun in both requested modes.
208
214
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.72.3",
4
+ "version": "0.75.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -84,9 +84,9 @@
84
84
  "prepack": "npm run build:types"
85
85
  },
86
86
  "dependencies": {
87
- "@jarenjs/core": "^0.72.3",
88
- "@jarenjs/json": "^0.72.3",
89
- "@jarenjs/validate": "^0.72.3"
87
+ "@jarenjs/core": "^0.75.0",
88
+ "@jarenjs/json": "^0.75.0",
89
+ "@jarenjs/validate": "^0.75.0"
90
90
  },
91
91
  "bin": {
92
92
  "jaren-db": "./src/cli.js"
package/src/algebra.js CHANGED
@@ -50,13 +50,17 @@ export const PLAN_VERSION = 2;
50
50
  * { p: 'not', item: PlanPredicate } |
51
51
  * { p: 'cmp', op: 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge',
52
52
  * ref: PlanRef, operand: PlanOperand } |
53
+ * { p: 'refCmp', op: 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge',
54
+ * left: PlanRef, right: PlanRef } |
53
55
  * { p: 'typeIs', ref: PlanRef, types: string[], positive: boolean } |
54
56
  * { p: 'strop', kind: 'starts' | 'ends' | 'contains',
55
57
  * ref: PlanRef, operand: PlanOperand } |
56
58
  * { p: 'const', value: boolean } |
57
59
  * { p: 'udf', name: string, key: string } |
58
60
  * { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
59
- * n: string }, probe: { box: number[] } | { ext: string } } |
61
+ * n: string }, probe: { box: number[] } | { ext: string } |
62
+ * { circle: { centre: { external: string } | { literal: unknown },
63
+ * radius: { external: string } | { literal: unknown } } } } |
60
64
  * { p: 'cellIn', column: string, cells: string[], keepEmpty?: boolean } |
61
65
  * { p: 'cellPrefix', column: string, prefix: string } |
62
66
  * { p: 'interval', columns: { start: string, end: string },
@@ -123,7 +127,8 @@ export const PLAN_VERSION = 2;
123
127
  * aggregates: { as: string, fn: 'rows' | 'sum' | 'avg' | 'min' | 'max',
124
128
  * ref: PlanRef | null, empty: 'zero' | 'omit' | 'null' }[],
125
129
  * tree: PlanProjectionNode,
126
- * order: 'first-seen' | { index: number, desc: boolean, nullsFirst: boolean }[]
130
+ * order: 'first-seen' | { index?: number, aggregate?: number,
131
+ * desc: boolean, nullsFirst: boolean }[]
127
132
  * }} PlanGroup
128
133
  * The GENERAL `GROUP BY`: one key per declared grouping name, the
129
134
  * closed aggregate set over the group's rows, and the projection tree
@@ -133,7 +138,8 @@ export const PLAN_VERSION = 2;
133
138
  * an `agg` node reads one aggregate under its `empty` rule. `order` is
134
139
  * `'first-seen'`, the engine's own order of first appearance, or the
135
140
  * group-key ordering an `$orderby` declared. A plan carrying a group
136
- * carries no `bucket`, no `aggregate` and no `rank`.
141
+ * carries no `bucket` or `rank`. A `count` aggregate may wrap a
142
+ * singleton group constructor, counting groups through a subquery.
137
143
  *
138
144
  * @typedef {{ ref: PlanRef, every: number, origin: number, as: string,
139
145
  * order: 'asc' | 'desc' | 'first-seen',
package/src/dag-job.js CHANGED
@@ -199,7 +199,7 @@ export function createDagJobRunner(store, options) {
199
199
  compiled.taskVersions);
200
200
  // the handler's signal reaches every task: a worker winding down
201
201
  // inside its grace period, or a lease this attempt has lost
202
- return await compiled.run(input, { runId: runKey, signal: context.signal });
202
+ return await compiled.run(input, { runId: runKey, signal: context.signal, drainOnAbort: true });
203
203
  }
204
204
  finally {
205
205
  active.delete(runKey);
package/src/derive.js CHANGED
@@ -383,12 +383,23 @@ export function cellNeighbourhood(cell) {
383
383
  * external's bounding box, computed at bind time because a GeoJSON
384
384
  * object is not a value any database can bind. `null` when the value
385
385
  * has no box, which is what tells the caller to divert.
386
- * @param {{ kind: string, external: string, axis: string }} derived
387
- * @param {any} value - the bound external
386
+ * @param {any} derived
387
+ * @param {any} value - the bound external for bboxAxis; all externals for circleAxis
388
388
  * @returns {number | null}
389
389
  */
390
390
  export function derivedSlotValue(derived, value) {
391
- const box = bboxOf(value);
391
+ let box;
392
+ if (derived.kind === 'bboxAxis') box = bboxOf(value);
393
+ else if (derived.kind === 'circleAxis') {
394
+ const read = (input) => 'external' in input ? value[input.external] : input.literal;
395
+ const at = probePosition(read(derived.centre));
396
+ const radius = read(derived.radius);
397
+ if (at === null || typeof radius !== 'number' || !Number.isFinite(radius) || radius < 0)
398
+ return null;
399
+ box = probeCircleBox(at, radius);
400
+ if (box !== null && (box[0] < -180 || box[2] > 180)) return null;
401
+ }
402
+ else throw new TypeError(`unknown derived parameter kind '${derived.kind}'`);
392
403
  return box === null ? null : box[BBOX_AT[derived.axis]];
393
404
  }
394
405
 
package/src/dialect.js CHANGED
@@ -185,6 +185,7 @@ function normalizeCapabilities(declared) {
185
185
  * memberPathOf?: (expression: string) => (JsonPathSegment[] | null),
186
186
  * expressionOf?: (expression: string, byName: Record<string, string>) => (any | null),
187
187
  * readGenerated?: (rows: any[]) => { name: string, expression: string }[],
188
+ * readChecks?: (rows: any[]) => { name: string, column?: string, values?: any[] }[],
188
189
  * introspect: { version: () => string, compileOptions: () => string,
189
190
  * pragma: (name: string) => string,
190
191
  * tableExists: () => string, columns: (table: string) => string,
@@ -640,6 +641,7 @@ export function createDialect(spec) {
640
641
  * one that can read it.
641
642
  */
642
643
  readGenerated: spec.readGenerated,
644
+ readChecks: spec.readChecks,
643
645
  explainQuery: spec.explainQuery,
644
646
  /** The plan narrative, one line per row the engine answered. */
645
647
  explainLines: spec.explainLines,