@jarenjs/db 0.49.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/ARCHITECTURE.md +27 -15
  2. package/README.md +141 -41
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +38 -9
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +232 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/capture.js +66 -28
  11. package/src/cli.js +225 -44
  12. package/src/ddl.js +23 -3
  13. package/src/dialects/sqlite.js +2 -1
  14. package/src/driver.js +63 -16
  15. package/src/drivers/wasm.js +1 -0
  16. package/src/emit-model.js +14 -0
  17. package/src/emit.js +10 -3
  18. package/src/entity.js +92 -47
  19. package/src/errors.js +25 -0
  20. package/src/index.js +2 -2
  21. package/src/jobs.js +40 -5
  22. package/src/live-time.js +12 -3
  23. package/src/live.js +11 -1
  24. package/src/migrate.js +397 -191
  25. package/src/model.js +173 -8
  26. package/src/plan.js +135 -38
  27. package/src/query.js +138 -13
  28. package/src/store.js +221 -66
  29. package/src/tracker.js +173 -48
  30. package/types/index.d.ts +152 -10
  31. package/types/node.d.ts +3 -1
  32. package/types/typed.d.ts +58 -2
  33. package/types/wasm.d.ts +7 -0
  34. package/dist/types/algebra.d.ts +0 -230
  35. package/dist/types/app.d.ts +0 -49
  36. package/dist/types/capture.d.ts +0 -85
  37. package/dist/types/cli.d.ts +0 -2
  38. package/dist/types/dag-job.d.ts +0 -40
  39. package/dist/types/ddl.d.ts +0 -229
  40. package/dist/types/derive.d.ts +0 -250
  41. package/dist/types/dialect.d.ts +0 -154
  42. package/dist/types/dialects/sqlite.d.ts +0 -9
  43. package/dist/types/driver.d.ts +0 -110
  44. package/dist/types/drivers/bun.d.ts +0 -47
  45. package/dist/types/drivers/node.d.ts +0 -37
  46. package/dist/types/drivers/wasm.d.ts +0 -65
  47. package/dist/types/emit-model.d.ts +0 -44
  48. package/dist/types/emit.d.ts +0 -75
  49. package/dist/types/entity.d.ts +0 -23
  50. package/dist/types/errors.d.ts +0 -170
  51. package/dist/types/graph.d.ts +0 -28
  52. package/dist/types/index.d.ts +0 -37
  53. package/dist/types/jobs.d.ts +0 -140
  54. package/dist/types/knn.d.ts +0 -69
  55. package/dist/types/live-time.d.ts +0 -141
  56. package/dist/types/live.d.ts +0 -64
  57. package/dist/types/migrate.d.ts +0 -170
  58. package/dist/types/model.d.ts +0 -36
  59. package/dist/types/patch-sql.d.ts +0 -37
  60. package/dist/types/plan.d.ts +0 -142
  61. package/dist/types/profile.d.ts +0 -80
  62. package/dist/types/query.d.ts +0 -112
  63. package/dist/types/residual.d.ts +0 -64
  64. package/dist/types/series.d.ts +0 -227
  65. package/dist/types/store.d.ts +0 -60
  66. package/dist/types/tracker.d.ts +0 -43
  67. package/dist/types/typed.d.ts +0 -15
  68. package/dist/types/types.d.ts +0 -26
  69. package/dist/types/udf.d.ts +0 -75
  70. package/dist/types/window.d.ts +0 -52
@@ -163,9 +163,9 @@ Three things follow, and none of them is optional:
163
163
  per-mapping cell.
164
164
 
165
165
  **What it costs, both halves** (`benchmark/spatial.js`, the store's own
166
- 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
167
- 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
168
- 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
169
169
  second table: choose it deliberately, per index, which is why it is
170
170
  neither automatic nor a store-wide option.
171
171
 
@@ -198,11 +198,11 @@ Three things follow:
198
198
 
199
199
  **What it buys, and what it costs.** Measured against the same query
200
200
  over a collection with no such column — the whole embedding parsed out
201
- of the stored JSON per row — the column is worth <!--bm:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/bm-->
202
- 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-->
203
203
  on the way in, because every write pays a JSON round trip of the member
204
204
  plus the normalize and the pack. On
205
- 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-->
206
206
  per vector — smaller than the member, and *added* to it, since the
207
207
  document still carries what the column is derived from. Choose it the
208
208
  way `physical: 'rtree'` is chosen: per index, with both halves in view
@@ -617,6 +617,15 @@ returns a validation function; the function returns `true`/`false` or
617
617
  and `store.capabilities.validated === false` — a declared downgrade.
618
618
  The cost of running without one: the database constraints only see the
619
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.
620
629
  `@jarenjs/db` never runs a validator of its own; what it imports from
621
630
  `@jarenjs/validate` is only the pure same-document `$ref`/`$anchor`
622
631
  resolution in `@jarenjs/validate/normalize`, for model compilation.
@@ -640,6 +649,13 @@ throw rolls back exactly its own level and rethrows — an outer
640
649
  transaction that catches the error continues and its own work
641
650
  commits. There is no implicit retry.
642
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
+
643
659
  ### 5.1 Transaction ownership
644
660
 
645
661
  A SQLite connection holds ONE savepoint stack, so two transactions that
@@ -727,6 +743,7 @@ error.
727
743
  | `JD0030` | an unknown x-entity member was declared |
728
744
  | `JD0031` | relation declarations contradict each other |
729
745
  | `JD0032` | the include specification is invalid |
746
+ | `JD0033` | an entity query names no entity array |
730
747
  | `JD0040` | the save spans a relation cycle |
731
748
  | `JD0050` | live queries require change capture |
732
749
  | `JD0051` | the demanded live mode is unavailable |
@@ -745,6 +762,7 @@ error.
745
762
  | `JD2060` | the maintained live state exceeded its bound |
746
763
  | `JD2061` | another context owns the database |
747
764
  | `JD2062` | the store closed with job handlers still in flight |
765
+ | `JD2063` | the store is closed |
748
766
 
749
767
  The table above is proven in sync with the runtime `DB_CODES` table by
750
768
  a test.
@@ -763,7 +781,11 @@ collection.query(doc, { profile: { maxRows: 200, externals: ['min'] } });
763
781
  ```
764
782
 
765
783
  `'safe'` is the default table; a profile object overrides members over
766
- 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
767
789
  `{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
768
790
  `maxRows: 1000`, no externals, no host functions, no collations, all
769
791
  of the store's collections, no mandatory predicates, no scan refusal.
@@ -945,15 +967,15 @@ changing state. Measured on the same terms as the `$sqrt` rows
945
967
  mean of 20 executions after a warm one; the residual comparator
946
968
  pushes everything BUT the spatial conjunct):
947
969
 
948
- <!--bm:spatial.udfTable-->
970
+ <!--fact:spatial.udfTable-->
949
971
  | shape | pushed (ms) | residual (ms) | verdict |
950
972
  |---|---|---|---|
951
973
  | solo `$within` over a full scan | 94 | 80 | ~even |
952
974
  | indexed `$eq` **and** `$within` (~5 % pass the index) | 5.7 | 52 | push **9.1×** |
953
975
  | `$within` with `LIMIT 10` | 1.9 | 77 | push **41.0×** |
954
- <!--/bm-->
976
+ <!--/fact-->
955
977
 
956
- 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-->,
957
979
  by the same rule as `$sqrt`: a sole `$within` over a full scan is a
958
980
  loss (the UDF re-parses every row in the callback, and the exact
959
981
  containment test is dearer than a square root), a `$within` beside
@@ -1013,7 +1035,10 @@ names:
1013
1035
  The schema stays a valid JSON Schema throughout: strip every
1014
1036
  `x-entity` member and it accepts and rejects exactly the same values
1015
1037
  (test-asserted over a corpus). The vocabulary is invisible to the
1016
- 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.
1017
1042
 
1018
1043
  ### 9.2 The `x-entity` vocabulary (a closed set)
1019
1044
 
@@ -1033,6 +1058,13 @@ happen, so this vocabulary is deliberately stricter than the
1033
1058
  validator's ignore-unknown posture — the strictness is local to the
1034
1059
  one namespace this package owns.
1035
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
+
1036
1068
  ### 9.3 The hybrid mapping
1037
1069
 
1038
1070
  Stated once, mechanically applied, and returned as data by
@@ -1042,7 +1074,8 @@ Stated once, mechanically applied, and returned as data by
1042
1074
  |---|---|
1043
1075
  | scalar (`string`/`number`/`integer`/`boolean`) at the top level | a real typed column |
1044
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 |
1045
- | `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 |
1046
1079
  | nested object / array, or `column: "json"` | the JSONB document column, queryable by path exactly as in phase A |
1047
1080
  | relation | a foreign-key column, or a join table for many-to-many (§9.4) |
1048
1081
 
@@ -1052,13 +1085,39 @@ everything else; a read merges them back. **The absent-versus-null
1052
1085
  rule, plainly**: for a column-mapped scalar, JSON `null` and absence
1053
1086
  both store as SQL `NULL` and read back as ABSENT. A property that
1054
1087
  needs present-`null` semantics declares `column: "json"` and stays in
1055
- 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.
1056
1094
 
1057
1095
  ### 9.4 Relations and referential integrity
1058
1096
 
1059
1097
  Declared on one side, inferred on the other; when both sides declare,
1060
1098
  the inverses MUST agree (`JD0031` on any contradiction).
1061
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
+
1062
1121
  - **one-to-many** — `{ to, many: true, via, onDelete }`: `via` names
1063
1122
  the foreign-key property on the TARGET entity (`authorId` on
1064
1123
  `Post`). If the target declares that property it MUST be a
@@ -1096,19 +1155,34 @@ reads and deletes take `{ prop: value, … }`.
1096
1155
  Applied on write in JavaScript, never by SQL `DEFAULT`, so the value
1097
1156
  the application sees and the value stored are the same — and the
1098
1157
  behaviour is identical on every driver. `"now"` stamps an RFC 3339
1099
- 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
1100
1161
  on insert AND on every update, always; `{ "value": … }` fills a
1101
1162
  literal when absent; `{ "query": … }` evaluates a query document over
1102
1163
  the document being written. Defaults run BEFORE validation, so the
1103
- 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).
1104
1173
 
1105
1174
  ### 9.7 Error-code additions
1106
1175
 
1107
- The entity engine adds two codes to the package's single table (§7):
1108
- `JD0030` — an unknown `x-entity` member; `JD0031` — relation
1109
- declarations whose inverses contradict. Everything else raises the
1110
- existing codes (`JD0005` for structural model defects, `JD2005` for
1111
- 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.
1112
1186
 
1113
1187
  ## 10. Relational translation
1114
1188
 
@@ -1122,13 +1196,65 @@ translatable runs the set residual over the fetched entity root,
1122
1196
 
1123
1197
  `store.execute(document)` queries the **multi-entity root**: the
1124
1198
  engine-side value is `{ <EntityName>: [documents…], … }` and bindings
1125
- 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
1126
1227
  can actually prove — the in-memory engine sees exactly the documents
1127
1228
  the entity sets return (`test/db/oracle/relations/`). Relation-NAME
1128
1229
  navigation (`$.author.name`) is deliberately not query-document sugar:
1129
1230
  the engine has no embedded `author` member to walk, so no oracle could
1130
1231
  vouch for it. Name-based navigation lives on the `load` surface
1131
- (§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.
1132
1258
 
1133
1259
  Per binding, predicates resolve through three reference flavors:
1134
1260
 
@@ -1212,8 +1338,11 @@ store.entity('User').load({
1212
1338
  })
1213
1339
  ```
1214
1340
 
1215
- Per-relation `where`/`orderBy`/`take` apply INSIDE the subquery — the
1216
- 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
1217
1346
  the child's own reference flavors; an untranslatable clause is a
1218
1347
  refusal (`JD0032`) naming the include path, never a silent residual.
1219
1348
  Include depth is bounded (default 3, override with `maxDepth`);
@@ -1242,14 +1371,33 @@ a number appears only where `capabilities.rowEstimates` is filled):
1242
1371
 
1243
1372
  - three or more bindings;
1244
1373
  - non-equality join predicates, and disjunctions spanning bindings;
1245
- - `$groupby` (the engine's post-group cardinality rebinding deserves
1246
- its own order; the count-of-related-rows case ORMs are bad at is
1247
- 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;
1248
1378
  - projections (`$return` objects) — over one binding or across a join;
1249
1379
  - externals against document paths; booleans and `null` at bind time;
1250
1380
  - everything phase A already listed (§8 of `QUERY-FORMAT.md`
1251
1381
  notwithstanding, the truth table is the contract).
1252
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
+
1253
1401
 
1254
1402
  ## 11. The unit of work
1255
1403
 
@@ -1293,8 +1441,19 @@ const report = await store.saveChanges(); // one transaction
1293
1441
 
1294
1442
  ### 11.2 Explicit updates (the other mode)
1295
1443
 
1296
- `set.update(key, changes)` and `set.delete(key)` skip tracking: one
1297
- 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
1298
1457
  still bumps a declared version property, so optimistic savers observe
1299
1458
  the row changed. This is the path reactive layers and job runners use.
1300
1459
 
@@ -1337,7 +1496,10 @@ the 100× statement reduction is the point for anything remote.
1337
1496
 
1338
1497
  ### 11.5 Optimistic concurrency
1339
1498
 
1340
- 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()`
1341
1503
  update and guarded delete carries `WHERE version = ?` (the SNAPSHOT
1342
1504
  version) and writes snapshot + 1; a zero-row result is **`JD2040`**
1343
1505
  carrying the entity and key, and the whole save rolls back. Without a
@@ -1367,3 +1529,43 @@ The return value is data, not a boolean:
1367
1529
  elapsedMs,
1368
1530
  }
1369
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.49.2",
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.49.2",
75
- "@jarenjs/json": "^0.49.2",
76
- "@jarenjs/validate": "^0.49.2"
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
  }