@jarenjs/db 0.46.5 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
@@ -71,6 +71,24 @@ The store runs over SQLite — on Node (`@jarenjs/db/node`), on Bun
71
71
  An invalid model document is `JD0005` with a `docPath` pointing at the
72
72
  offending member. Model checking happens before any database work.
73
73
 
74
+ **A time series needs no new declaration.** A composite index whose
75
+ LAST path is a finite numeric epoch member is the whole physical
76
+ feature a temporal plan reads:
77
+
78
+ ```json
79
+ { "indexes": [{ "name": "by_series_at", "path": ["$.series", "$.at"] }] }
80
+ ```
81
+
82
+ There is no `derive` kind for it, no column type and no host function.
83
+ The columns before the instant are the prefix an equality has to pin
84
+ for the index to seek, exactly as for any other composite index, and
85
+ the schema is the type source as always: type the instant `integer` and
86
+ a bucket ladder is pushed as integer arithmetic; type it `number` and
87
+ the ladder stays a core refinement, because a truncating division over
88
+ a real would put an instant in the wrong bucket. Which shapes are
89
+ pushed, and the reason each refinement is one, are in ARCHITECTURE.md's
90
+ "The temporal plan".
91
+
74
92
  ### 2.1 Derived indexes (spatial and vector storage)
75
93
 
76
94
  A generated column must be a scalar (§3), and a GeoJSON position is an
@@ -145,9 +163,9 @@ Three things follow, and none of them is optional:
145
163
  per-mapping cell.
146
164
 
147
165
  **What it costs, both halves** (`benchmark/spatial.js`, the store's own
148
- rows over 50 000 points): the same `$within` measures <!--bm:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/bm-->, and loading them
149
- costs <!--bm:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/bm-->. Isolated from the store on a raw
150
- connection, the same probe is <!--bm:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/bm-->. Read speed bought with write cost and a
166
+ rows over 50 000 points): the same `$within` measures <!--fact:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/fact-->, and loading them
167
+ costs <!--fact:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/fact-->. Isolated from the store on a raw
168
+ connection, the same probe is <!--fact:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/fact-->. Read speed bought with write cost and a
151
169
  second table: choose it deliberately, per index, which is why it is
152
170
  neither automatic nor a store-wide option.
153
171
 
@@ -180,11 +198,11 @@ Three things follow:
180
198
 
181
199
  **What it buys, and what it costs.** Measured against the same query
182
200
  over a collection with no such column — the whole embedding parsed out
183
- of the stored JSON per row — the column is worth <!--bm:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/bm-->
184
- on the read, and costs <!--bm:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/bm-->
201
+ of the stored JSON per row — the column is worth <!--fact:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/fact-->
202
+ on the read, and costs <!--fact:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/fact-->
185
203
  on the way in, because every write pays a JSON round trip of the member
186
204
  plus the normalize and the pack. On
187
- disk it is <!--bm:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/bm-->
205
+ disk it is <!--fact:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/fact-->
188
206
  per vector — smaller than the member, and *added* to it, since the
189
207
  document still carries what the column is derived from. Choose it the
190
208
  way `physical: 'rtree'` is chosen: per index, with both halves in view
@@ -599,6 +617,15 @@ returns a validation function; the function returns `true`/`false` or
599
617
  and `store.capabilities.validated === false` — a declared downgrade.
600
618
  The cost of running without one: the database constraints only see the
601
619
  key and the indexed members; everything else is stored as given.
620
+ Stated more sharply, because it changes ANSWERS and not only what is
621
+ stored: a typed generated column carries SQLite affinity, so a value
622
+ that violates the collection schema — the string `'020'` under an
623
+ `integer` path — reads as the integer `20` in the column while the
624
+ document still holds text, and a pushed `$eq: ['$it.n', '20']` finds
625
+ a row the engine, which compares the JSON value, does not. On an
626
+ unvalidated store the native and residual paths agree only over
627
+ documents that conform to the schema; with the hook injected no other
628
+ document is ever stored.
602
629
  `@jarenjs/db` never runs a validator of its own; what it imports from
603
630
  `@jarenjs/validate` is only the pure same-document `$ref`/`$anchor`
604
631
  resolution in `@jarenjs/validate/normalize`, for model compilation.
@@ -622,6 +649,13 @@ throw rolls back exactly its own level and rethrows — an outer
622
649
  transaction that catches the error continues and its own work
623
650
  commits. There is no implicit retry.
624
651
 
652
+ **On an asynchronous driver a refused write rejects.** Every write —
653
+ `insert`, `put`, `patch`, `delete`, the entity set's `create`/`update`/
654
+ `delete` and the queue's `enqueue` — answers its coded error (`JD2001`,
655
+ `JD2003`, `JD2005`, …) through the promise it returned: never a
656
+ synchronous throw, and never an unhandled rejection beside a result
657
+ that looks like success.
658
+
625
659
  ### 5.1 Transaction ownership
626
660
 
627
661
  A SQLite connection holds ONE savepoint stack, so two transactions that
@@ -709,10 +743,12 @@ error.
709
743
  | `JD0030` | an unknown x-entity member was declared |
710
744
  | `JD0031` | relation declarations contradict each other |
711
745
  | `JD0032` | the include specification is invalid |
746
+ | `JD0033` | an entity query names no entity array |
712
747
  | `JD0040` | the save spans a relation cycle |
713
748
  | `JD0050` | live queries require change capture |
714
749
  | `JD0051` | the demanded live mode is unavailable |
715
750
  | `JD0052` | the live-query bound was reached |
751
+ | `JD0053` | the live event-time declaration is invalid |
716
752
  | `JD2001` | insert found the key already present |
717
753
  | `JD2002` | a usable key could not be resolved for the write |
718
754
  | `JD2003` | the write failed schema validation |
@@ -726,6 +762,7 @@ error.
726
762
  | `JD2060` | the maintained live state exceeded its bound |
727
763
  | `JD2061` | another context owns the database |
728
764
  | `JD2062` | the store closed with job handlers still in flight |
765
+ | `JD2063` | the store is closed |
729
766
 
730
767
  The table above is proven in sync with the runtime `DB_CODES` table by
731
768
  a test.
@@ -744,7 +781,11 @@ collection.query(doc, { profile: { maxRows: 200, externals: ['min'] } });
744
781
  ```
745
782
 
746
783
  `'safe'` is the default table; a profile object overrides members over
747
- it. The defaults: engine limits
784
+ it. A per-call `profile` REPLACES the store's for that call — it is
785
+ normalized over the `'safe'` defaults, not over the store's profile —
786
+ so a store-level mandatory predicate or allow-list does not carry into
787
+ `execute(doc, { profile: { maxRows: 50 } })`: spell the whole profile
788
+ per call, or set it once on the store and pass none. The defaults: engine limits
748
789
  `{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
749
790
  `maxRows: 1000`, no externals, no host functions, no collations, all
750
791
  of the store's collections, no mandatory predicates, no scan refusal.
@@ -926,15 +967,15 @@ changing state. Measured on the same terms as the `$sqrt` rows
926
967
  mean of 20 executions after a warm one; the residual comparator
927
968
  pushes everything BUT the spatial conjunct):
928
969
 
929
- <!--bm:spatial.udfTable-->
970
+ <!--fact:spatial.udfTable-->
930
971
  | shape | pushed (ms) | residual (ms) | verdict |
931
972
  |---|---|---|---|
932
973
  | solo `$within` over a full scan | 94 | 80 | ~even |
933
974
  | indexed `$eq` **and** `$within` (~5 % pass the index) | 5.7 | 52 | push **9.1×** |
934
975
  | `$within` with `LIMIT 10` | 1.9 | 77 | push **41.0×** |
935
- <!--/bm-->
976
+ <!--/fact-->
936
977
 
937
- So the spatial hatch <!--bm:spatial.udfVerdict-->earns its row: 9.1× beside the selective conjunct and 41.0× under the LIMIT<!--/bm-->,
978
+ So the spatial hatch <!--fact:spatial.udfVerdict-->earns its row: 9.1× beside the selective conjunct and 41.0× under the LIMIT<!--/fact-->,
938
979
  by the same rule as `$sqrt`: a sole `$within` over a full scan is a
939
980
  loss (the UDF re-parses every row in the callback, and the exact
940
981
  containment test is dearer than a square root), a `$within` beside
@@ -994,7 +1035,10 @@ names:
994
1035
  The schema stays a valid JSON Schema throughout: strip every
995
1036
  `x-entity` member and it accepts and rejects exactly the same values
996
1037
  (test-asserted over a corpus). The vocabulary is invisible to the
997
- validator by the same argument as `x-form`.
1038
+ validator by the same argument as `x-form`. A model document may also
1039
+ be written by code — `@jarenjs/linq/model`'s `defineModel()` emits
1040
+ exactly this document ([MODEL-PEN.md](../../linq/docs/MODEL-PEN.md)), and this section stays its
1041
+ one specification.
998
1042
 
999
1043
  ### 9.2 The `x-entity` vocabulary (a closed set)
1000
1044
 
@@ -1014,6 +1058,13 @@ happen, so this vocabulary is deliberately stricter than the
1014
1058
  validator's ignore-unknown posture — the strictness is local to the
1015
1059
  one namespace this package owns.
1016
1060
 
1061
+ The vocabulary is closed in POSITION as well as in name: `x-entity` is
1062
+ read on an entity's top-level properties (and through their `allOf`,
1063
+ `$defs` and `definitions` blocks) only. A block nested anywhere else —
1064
+ under a property's `properties`, `items`, `anyOf`, … — is never walked
1065
+ for mapping directives, so one found there is `JD0030` at its `docPath`
1066
+ rather than a key, an index or a relation that silently never existed.
1067
+
1017
1068
  ### 9.3 The hybrid mapping
1018
1069
 
1019
1070
  Stated once, mechanically applied, and returned as data by
@@ -1023,7 +1074,8 @@ Stated once, mechanically applied, and returned as data by
1023
1074
  |---|---|
1024
1075
  | scalar (`string`/`number`/`integer`/`boolean`) at the top level | a real typed column |
1025
1076
  | `format: date-time`/`date` with `column: "integer"` | an epoch-milliseconds `INTEGER` column; the document keeps the RFC 3339 string, the column carries the derived epoch |
1026
- | `enum` of scalars | a column plus a `CHECK (column IN (…))` |
1077
+ | `enum` of scalars | a column plus a `CHECK (column IN (…))` — a `null` member of the enum is left to the column's nullability, never written into the list |
1078
+ | a union of several scalar types (`['string', 'integer']`) | the JSONB document — a column has one affinity and a union has several; `['integer', 'null']` is that scalar, nullable |
1027
1079
  | nested object / array, or `column: "json"` | the JSONB document column, queryable by path exactly as in phase A |
1028
1080
  | relation | a foreign-key column, or a join table for many-to-many (§9.4) |
1029
1081
 
@@ -1033,13 +1085,39 @@ everything else; a read merges them back. **The absent-versus-null
1033
1085
  rule, plainly**: for a column-mapped scalar, JSON `null` and absence
1034
1086
  both store as SQL `NULL` and read back as ABSENT. A property that
1035
1087
  needs present-`null` semantics declares `column: "json"` and stays in
1036
- the document.
1088
+ the document. The rule reaches the WRITE's return value, not only the
1089
+ read: `create()`, `update()` and `add()` answer the document a read
1090
+ would answer, so a `null` handed in for a column-mapped scalar is
1091
+ absent from what comes back — a returned object never names a member
1092
+ no read will show. An epoch column (§9.3's `column: "integer"` row)
1093
+ keeps its string in the document, present-`null` included.
1037
1094
 
1038
1095
  ### 9.4 Relations and referential integrity
1039
1096
 
1040
1097
  Declared on one side, inferred on the other; when both sides declare,
1041
1098
  the inverses MUST agree (`JD0031` on any contradiction).
1042
1099
 
1100
+ What counts as "the same edge" is decided by `via`, not by the pair of
1101
+ entity names: two declarations pair up when they name the same
1102
+ foreign-key property, and a pair is one `many` side and one `one`
1103
+ side. Two `many` sides, or two `one` sides, on one `via` is `JD0031`,
1104
+ and a paired edge must agree on `onDelete`.
1105
+
1106
+ Between one pair of entities, a `many` declaration facing a `one`
1107
+ declaration with a DIFFERENT `via` is `JD0031` as well (the pair
1108
+ disagrees on its key) — the check is per entity pair, so **two
1109
+ one-to-many edges between the same two entities declare their inverse
1110
+ on ONE side only**. The author-and-editor model is written with both
1111
+ edges on `User` (`authored` by `authorId`, `edited` by `editorId`) and
1112
+ the foreign-key properties on `Post`, or with both edges on `Post` and
1113
+ none on `User`; declaring `Post.author` AND `User.edited` makes
1114
+ `User.authored` face `Post.editor` and is refused. Two declarations of
1115
+ the SAME kind with different `via`s are two independent edges — which
1116
+ is how a legitimate cycle is written (`Post.author` by `authorId` and
1117
+ `User.featured` by `featuredPostId`, one-to-one each way).
1118
+ Many-to-many pairs must agree on the join table, and a many-to-many
1119
+ facing a foreign-key relation is `JD0031`.
1120
+
1043
1121
  - **one-to-many** — `{ to, many: true, via, onDelete }`: `via` names
1044
1122
  the foreign-key property on the TARGET entity (`authorId` on
1045
1123
  `Post`). If the target declares that property it MUST be a
@@ -1077,19 +1155,34 @@ reads and deletes take `{ prop: value, … }`.
1077
1155
  Applied on write in JavaScript, never by SQL `DEFAULT`, so the value
1078
1156
  the application sees and the value stored are the same — and the
1079
1157
  behaviour is identical on every driver. `"now"` stamps an RFC 3339
1080
- UTC string on insert when the property is absent; `"updated"` stamps
1158
+ UTC string on insert when the property is absent the calendar date
1159
+ alone (`YYYY-MM-DD`) on a `format: date` property, so the stamp
1160
+ validates against its own format; `"updated"` stamps
1081
1161
  on insert AND on every update, always; `{ "value": … }` fills a
1082
1162
  literal when absent; `{ "query": … }` evaluates a query document over
1083
1163
  the document being written. Defaults run BEFORE validation, so the
1084
- injected hook sees the completed document.
1164
+ injected hook sees the completed document — with one member exempt: a
1165
+ store-allocated key (`default: "auto"`) is allocated by the database
1166
+ AFTER validation, so a write validates against the schema with that
1167
+ key dropped from `required` (the generated input type marks it
1168
+ optional for the same reason); the document the store answers carries
1169
+ it, and the read shape keeps it required. A `version` property is
1170
+ engine-owned and never defaulted by the caller: an insert without one
1171
+ writes `0` — not SQL `NULL`, which no `WHERE version = ?` guard could
1172
+ match — and every successful write bumps it (§11.5).
1085
1173
 
1086
1174
  ### 9.7 Error-code additions
1087
1175
 
1088
- The entity engine adds two codes to the package's single table (§7):
1089
- `JD0030` — an unknown `x-entity` member; `JD0031` — relation
1090
- declarations whose inverses contradict. Everything else raises the
1091
- existing codes (`JD0005` for structural model defects, `JD2005` for
1092
- database-refused writes including foreign-key violations).
1176
+ The entity engine adds three codes to the package's single table (§7):
1177
+ `JD0030` — an unknown or unread `x-entity` member; `JD0031` — relation
1178
+ declarations whose inverses contradict; `JD0033` an entity query
1179
+ document that binds no entity array (§10.1). Everything else raises the
1180
+ existing codes: `JD0005` for structural model defects (a key, index or
1181
+ version property without a column of its own, a default on a relation,
1182
+ `default: "auto"` off the key, a self-referencing many-to-many, a
1183
+ foreign key onto a composite key), `JD2001` for a duplicate key on
1184
+ `create()` exactly as on a collection `insert`, and `JD2005` for
1185
+ database-refused writes including foreign-key violations.
1093
1186
 
1094
1187
  ## 10. Relational translation
1095
1188
 
@@ -1103,13 +1196,65 @@ translatable runs the set residual over the fetched entity root,
1103
1196
 
1104
1197
  `store.execute(document)` queries the **multi-entity root**: the
1105
1198
  engine-side value is `{ <EntityName>: [documents…], … }` and bindings
1106
- range over `$.<Entity>[*]`. This is the shape the differential oracle
1199
+ range over `$.<Entity>[*]`. A document that binds no entity array at
1200
+ all — a `$for` over a member the model does not name, or over a
1201
+ scalar — is `JD0033` at compile, never an empty answer. The
1202
+ array-constructor spelling `["$.<Entity>[*]"]`, which `@jarenjs/linq`
1203
+ emits so that an item that is itself an array stays one item
1204
+ (QUERY-PEN §3), names the same whole-entity source; the planner
1205
+ reads through it for collections (`["$[*]"]`) and entities alike.
1206
+
1207
+ An entity set is a provider (QUERY-PEN §8): `store.entity(name)` and
1208
+ `store.sync.entity(name)` carry `execute(document, options)` and
1209
+ `explain(document, options)` — the document is over the multi-entity
1210
+ root and goes to the entity translator whole, exactly as
1211
+ `store.execute` runs it — plus `root` (`"$.<Entity>[*]"`, the one
1212
+ spelling `collectEntityRoots` reads, so a chain binds its items through
1213
+ it) and `scope` (one identity per store, shared by every set, so two
1214
+ sets may be joined in one document). The store itself carries `roots`,
1215
+ the entity names, because it has no single root: a chain over it is
1216
+ refused by name (`JL0007`) rather than answering the mixed rows or
1217
+ counting the sets, and a document naming no entity array stays
1218
+ `JD0033`. A chain's element terminal hands over the one-item window
1219
+ `[<phrase>]` (QUERY-PEN §6); the planner reads through the window as
1220
+ it reads through the packed source — the phrase inside plans as if
1221
+ bare, and the store answers its rows as the ONE array item the engine
1222
+ would (`[]` for none, `[row]` for one, never singleton-unwrapped;
1223
+ `explain().wrapped` says so) — so `toArray()` over an entity set runs
1224
+ the translator, and a two-root equijoin returning a bare binding runs
1225
+ as one statement.
1226
+ This is the shape the differential oracle
1107
1227
  can actually prove — the in-memory engine sees exactly the documents
1108
1228
  the entity sets return (`test/db/oracle/relations/`). Relation-NAME
1109
1229
  navigation (`$.author.name`) is deliberately not query-document sugar:
1110
1230
  the engine has no embedded `author` member to walk, so no oracle could
1111
1231
  vouch for it. Name-based navigation lives on the `load` surface
1112
- (§10.4), where results and statement counts are the proof.
1232
+ (§10.4), where results and statement counts are the proof — and, by
1233
+ code, on a query producer that lowers a hop to what the engine CAN walk.
1234
+
1235
+ For that, every entity set carries **`relations`**, the relation table
1236
+ a producer may read: a frozen plain record, one entry per declared
1237
+ relation member of the entity, `{ to, kind, via?, fkEntity?, fkTargets?,
1238
+ joinTable?, targetKey }` — `to` the target entity, `kind` one of
1239
+ `oneToOne`/`oneToMany`/`manyToMany`; for a foreign-key relation `via`
1240
+ the key property, `fkEntity` the entity holding it, `fkTargets` the
1241
+ entity it references and `targetKey` the key property it references
1242
+ there (the column a hop's equality compares `via` with; `kind` says
1243
+ which side holds the key: `oneToOne` the declaring entity, `oneToMany`
1244
+ the target); for a many-to-many `joinTable` and the target's `targetKey`.
1245
+ The store (`store.relations`) and every set's `scope` (`scope.relations`)
1246
+ carry the same tables keyed by entity name, so a producer holding one
1247
+ set can follow a hop into another root. `relationTables(entities)` is
1248
+ the one function that derives them. The document format is unchanged:
1249
+ `@jarenjs/linq`'s chain reads the table and lowers `p.author.email` to
1250
+ `{ $for: { r1: '$.User[*]' }, $where: { $eq: ['$r1.id', '$it.authorId'] },
1251
+ $return: '$r1.email' }` (QUERY-PEN §4, relation navigation) — a
1252
+ document a hand could have written, which this translator and the
1253
+ engine both run and the oracle proves (`15-linq-hops.json`). Every such
1254
+ lowered shape is a residual here (§10.6 — a projection, a comparison or
1255
+ an existence test over a phrase rather than a member path, an ordering
1256
+ by a phrase), named by `explain()` and refused under `strict`; none
1257
+ pushes natively in this version.
1113
1258
 
1114
1259
  Per binding, predicates resolve through three reference flavors:
1115
1260
 
@@ -1193,8 +1338,11 @@ store.entity('User').load({
1193
1338
  })
1194
1339
  ```
1195
1340
 
1196
- Per-relation `where`/`orderBy`/`take` apply INSIDE the subquery — the
1197
- point where naive loaders fall back to N+1. Clauses compile against
1341
+ Per-relation `where`/`orderBy`/`take`/`skip` apply INSIDE the subquery
1342
+ (`LIMIT`/`OFFSET` per parent row) the point where naive loaders fall
1343
+ back to N+1; `after` (§10.5) paginates the ROOT alone, since a keyset
1344
+ cursor is one position in one ordered set, and on an include it is
1345
+ `JD0032`. Clauses compile against
1198
1346
  the child's own reference flavors; an untranslatable clause is a
1199
1347
  refusal (`JD0032`) naming the include path, never a silent residual.
1200
1348
  Include depth is bounded (default 3, override with `maxDepth`);
@@ -1223,14 +1371,33 @@ a number appears only where `capabilities.rowEstimates` is filled):
1223
1371
 
1224
1372
  - three or more bindings;
1225
1373
  - non-equality join predicates, and disjunctions spanning bindings;
1226
- - `$groupby` (the engine's post-group cardinality rebinding deserves
1227
- its own order; the count-of-related-rows case ORMs are bad at is
1228
- already native via `count: true` includes);
1374
+ - `$groupby`, except the `$time-bucket` ladder the series plan pushes
1375
+ (README, *Time series*) the engine's post-group cardinality
1376
+ rebinding deserves its own order; the count-of-related-rows case
1377
+ ORMs are bad at is already native via `count: true` includes;
1229
1378
  - projections (`$return` objects) — over one binding or across a join;
1230
1379
  - externals against document paths; booleans and `null` at bind time;
1231
1380
  - everything phase A already listed (§8 of `QUERY-FORMAT.md`
1232
1381
  notwithstanding, the truth table is the contract).
1233
1382
 
1383
+ Two deviations between a pushed answer and the engine's are DECLARED
1384
+ rather than refused, because in both the database is right by its own
1385
+ arithmetic:
1386
+
1387
+ - `$sum`/`$avg` over `number` paths — SQLite sums with Kahan–Babuška
1388
+ compensation and the engine sums naively, so over `0.1, 0.2, 0.3`
1389
+ the store answers `0.6` and the residual `0.6000000000000001`: equal
1390
+ to within an ulp, never equal by `===` (the differential oracle
1391
+ draws dyadic fractions, which are exact on both sides).
1392
+ - a `$time-bucket` `$groupby` whose aggregated path admits `null`
1393
+ (`['number', 'null']`): the pushed `AVG` skips a `null` reading
1394
+ exactly as the series kernel — and therefore the `$resample`
1395
+ spelling — does, while the engine's `$avg` over a sequence holding
1396
+ `null` raises `JQ2001`; under this one spelling the store answers
1397
+ where the in-memory engine refuses. Everywhere else an ordering or
1398
+ an aggregate over a path that admits `null`, or over a boolean path,
1399
+ is a named residual, so the two paths keep answering alike.
1400
+
1234
1401
 
1235
1402
  ## 11. The unit of work
1236
1403
 
@@ -1274,8 +1441,19 @@ const report = await store.saveChanges(); // one transaction
1274
1441
 
1275
1442
  ### 11.2 Explicit updates (the other mode)
1276
1443
 
1277
- `set.update(key, changes)` and `set.delete(key)` skip tracking: one
1278
- immediate statement, last-write-wins by contract. An explicit update
1444
+ `set.create(doc)`, `set.update(key, changes)` and `set.delete(key)`
1445
+ skip tracking: one immediate statement, last-write-wins by contract.
1446
+ `create` applies defaults, validates, inserts, and — for a
1447
+ many-to-many relation whose member the document carries as an array
1448
+ of target keys — writes the join rows in the same transaction, which
1449
+ rolls back whole when a membership names a row that does not exist; a
1450
+ duplicate key is `JD2001`. `update` takes the document's own members
1451
+ only: a relation member (`posts`) is `JD2003` exactly as on `create`,
1452
+ and a membership array (`labels`) — which `create` writes — is
1453
+ `JD2003` on `update` as well, because an explicit update never writes
1454
+ join rows or child rows and never echoes back a member it did not
1455
+ store; a change to a key member is `JD2003` too (the key column would
1456
+ go stale — delete and create). An explicit update
1279
1457
  still bumps a declared version property, so optimistic savers observe
1280
1458
  the row changed. This is the path reactive layers and job runners use.
1281
1459
 
@@ -1318,7 +1496,10 @@ the 100× statement reduction is the point for anything remote.
1318
1496
 
1319
1497
  ### 11.5 Optimistic concurrency
1320
1498
 
1321
- Declare a token with `version: true` (§9.2). Every `saveChanges()`
1499
+ Declare a token with `version: true` (§9.2). A row inserted without
1500
+ one starts at `0` — never SQL `NULL`, which no guard could match
1501
+ (§9.6) — so the first tracked save after `create()`/`add()` carries
1502
+ `WHERE version = 0`. Every `saveChanges()`
1322
1503
  update and guarded delete carries `WHERE version = ?` (the SNAPSHOT
1323
1504
  version) and writes snapshot + 1; a zero-row result is **`JD2040`**
1324
1505
  carrying the entity and key, and the whole save rolls back. Without a
@@ -1348,3 +1529,43 @@ The return value is data, not a boolean:
1348
1529
  elapsedMs,
1349
1530
  }
1350
1531
  ```
1532
+
1533
+ ### 11.7 Membership: `link` and `unlink`
1534
+
1535
+ A many-to-many membership is attached or detached one row at a time
1536
+ through the unit of work, without carrying the whole membership array:
1537
+
1538
+ ```js
1539
+ users.link('u1', 'labels', 'admin'); // a key, or a document carrying the key
1540
+ users.unlink(ada, 'labels', { name: 'dev' }); // the own side is a key or a document too
1541
+ const report = await store.saveChanges(); // { joinInserted: 1, joinDeleted: 1, … }
1542
+ ```
1543
+
1544
+ - `link`/`unlink` are local, synchronous bookkeeping, like `add`/`put`/
1545
+ `remove`; nothing reaches the database until `saveChanges()`.
1546
+ - The member MUST be a many-to-many relation of the entity — `JD2003`
1547
+ otherwise, naming the relation's kind or the missing declaration. The
1548
+ target is read exactly as a membership array's element is: a key, or
1549
+ a document carrying the target's key (`JD2003` when it carries none).
1550
+ - The own side needs the entity's key. A pending insert whose `auto`
1551
+ key the save allocates has none to attach to and is refused (`JD2003`:
1552
+ "save the entity first, then attach"); a pending insert with a
1553
+ caller-supplied key may be linked in the same save, since join rows
1554
+ run after the inserts (§11.4).
1555
+ - The baseline is the join table **as read at save time**, not a
1556
+ snapshot: linking a member that already exists and unlinking one that
1557
+ does not are no-ops, so a save repeated with the same calls changes
1558
+ nothing (asserted). The last word on one target wins — `unlink` after
1559
+ `link` means unlink.
1560
+ - A `link`/`unlink` beside a `put` carrying the SAME member's membership
1561
+ array folds into that array's key-set difference (§11.3): one intent
1562
+ per entity, own key and member, never two statements racing for one
1563
+ row.
1564
+ - A tracked snapshot's loaded membership array is not rewritten by a
1565
+ saved `link`/`unlink` — a projection stays what it was read as;
1566
+ re-read (`load({ include })`) to see the membership. `discard(key)`
1567
+ drops the key's pending membership changes together with its
1568
+ tracking, and a failed save leaves them pending, as §11.6 promises.
1569
+ - The report counts the rows written under `joinInserted`/`joinDeleted`
1570
+ and `stats().tracker.pendingMemberships` counts the pending
1571
+ (entity, key, member) records.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.46.5",
4
+ "version": "0.56.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -36,7 +36,6 @@
36
36
  },
37
37
  "files": [
38
38
  "types/",
39
- "dist/types/",
40
39
  "src/",
41
40
  "schemas/",
42
41
  "docs/",
@@ -71,9 +70,9 @@
71
70
  "prepack": "npm run build:types"
72
71
  },
73
72
  "dependencies": {
74
- "@jarenjs/core": "^0.46.5",
75
- "@jarenjs/json": "^0.46.5",
76
- "@jarenjs/validate": "^0.46.5"
73
+ "@jarenjs/core": "^0.56.0",
74
+ "@jarenjs/json": "^0.56.0",
75
+ "@jarenjs/validate": "^0.56.0"
77
76
  },
78
77
  "bin": {
79
78
  "jaren-db": "./src/cli.js"
@@ -58,6 +58,12 @@
58
58
  },
59
59
  {
60
60
  "$ref": "#/definitions/deriveStep"
61
+ },
62
+ {
63
+ "$ref": "#/definitions/sqlStep"
64
+ },
65
+ {
66
+ "$ref": "#/definitions/rebuildStep"
61
67
  }
62
68
  ]
63
69
  },
@@ -216,6 +222,73 @@
216
222
  "columns"
217
223
  ],
218
224
  "additionalProperties": false
225
+ },
226
+ "sqlStep": {
227
+ "description": "A DATA step spelled directly as one SQL statement (MIGRATION-FORMAT §9.4): the planner's fold of a dropped column into the document, the move of a document member into its new column, an R*Tree backfill. Executed exactly as a ddl step; distinct in kind so a reviewer reads intent and a dry run shows it as data.",
228
+ "type": "object",
229
+ "properties": {
230
+ "kind": {
231
+ "const": "sql"
232
+ },
233
+ "sql": {
234
+ "type": "string",
235
+ "minLength": 1
236
+ },
237
+ "note": {
238
+ "type": "string"
239
+ }
240
+ },
241
+ "required": [
242
+ "kind",
243
+ "sql"
244
+ ],
245
+ "additionalProperties": false
246
+ },
247
+ "rebuildStep": {
248
+ "description": "The ALTER TABLE procedure SQLite lacks (MIGRATION-FORMAT §10), self-contained: create the target shape under `<table>__rebuild`, copy the rows through the rendered column mapping, drop the old table, rename, recreate the indexes, then `PRAGMA foreign_key_check` inside the transaction.",
249
+ "type": "object",
250
+ "properties": {
251
+ "kind": {
252
+ "const": "rebuild"
253
+ },
254
+ "table": {
255
+ "type": "string",
256
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
257
+ },
258
+ "create": {
259
+ "description": "The CREATE statements of the temporary table.",
260
+ "type": "array",
261
+ "minItems": 1,
262
+ "items": {
263
+ "type": "string",
264
+ "minLength": 1
265
+ }
266
+ },
267
+ "copy": {
268
+ "description": "The INSERT … SELECT that carries every row across the column mapping.",
269
+ "type": "string",
270
+ "minLength": 1
271
+ },
272
+ "indexes": {
273
+ "description": "The index statements recreated under the final name.",
274
+ "type": "array",
275
+ "items": {
276
+ "type": "string",
277
+ "minLength": 1
278
+ }
279
+ },
280
+ "note": {
281
+ "type": "string"
282
+ }
283
+ },
284
+ "required": [
285
+ "kind",
286
+ "table",
287
+ "create",
288
+ "copy",
289
+ "indexes"
290
+ ],
291
+ "additionalProperties": false
219
292
  }
220
293
  }
221
294
  }
@@ -58,6 +58,12 @@
58
58
  },
59
59
  {
60
60
  "$ref": "#/$defs/deriveStep"
61
+ },
62
+ {
63
+ "$ref": "#/$defs/sqlStep"
64
+ },
65
+ {
66
+ "$ref": "#/$defs/rebuildStep"
61
67
  }
62
68
  ]
63
69
  },
@@ -216,6 +222,73 @@
216
222
  "columns"
217
223
  ],
218
224
  "additionalProperties": false
225
+ },
226
+ "sqlStep": {
227
+ "description": "A DATA step spelled directly as one SQL statement (MIGRATION-FORMAT §9.4): the planner's fold of a dropped column into the document, the move of a document member into its new column, an R*Tree backfill. Executed exactly as a ddl step; distinct in kind so a reviewer reads intent and a dry run shows it as data.",
228
+ "type": "object",
229
+ "properties": {
230
+ "kind": {
231
+ "const": "sql"
232
+ },
233
+ "sql": {
234
+ "type": "string",
235
+ "minLength": 1
236
+ },
237
+ "note": {
238
+ "type": "string"
239
+ }
240
+ },
241
+ "required": [
242
+ "kind",
243
+ "sql"
244
+ ],
245
+ "additionalProperties": false
246
+ },
247
+ "rebuildStep": {
248
+ "description": "The ALTER TABLE procedure SQLite lacks (MIGRATION-FORMAT §10), self-contained: create the target shape under `<table>__rebuild`, copy the rows through the rendered column mapping, drop the old table, rename, recreate the indexes, then `PRAGMA foreign_key_check` inside the transaction.",
249
+ "type": "object",
250
+ "properties": {
251
+ "kind": {
252
+ "const": "rebuild"
253
+ },
254
+ "table": {
255
+ "type": "string",
256
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
257
+ },
258
+ "create": {
259
+ "description": "The CREATE statements of the temporary table.",
260
+ "type": "array",
261
+ "minItems": 1,
262
+ "items": {
263
+ "type": "string",
264
+ "minLength": 1
265
+ }
266
+ },
267
+ "copy": {
268
+ "description": "The INSERT … SELECT that carries every row across the column mapping.",
269
+ "type": "string",
270
+ "minLength": 1
271
+ },
272
+ "indexes": {
273
+ "description": "The index statements recreated under the final name.",
274
+ "type": "array",
275
+ "items": {
276
+ "type": "string",
277
+ "minLength": 1
278
+ }
279
+ },
280
+ "note": {
281
+ "type": "string"
282
+ }
283
+ },
284
+ "required": [
285
+ "kind",
286
+ "table",
287
+ "create",
288
+ "copy",
289
+ "indexes"
290
+ ],
291
+ "additionalProperties": false
219
292
  }
220
293
  }
221
294
  }