@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.
- package/ARCHITECTURE.md +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -149
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -167
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live.d.ts +0 -62
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -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 <!--
|
|
149
|
-
costs <!--
|
|
150
|
-
connection, the same probe is <!--
|
|
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 <!--
|
|
184
|
-
on the read, and costs <!--
|
|
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 <!--
|
|
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.
|
|
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
|
-
<!--
|
|
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
|
-
<!--/
|
|
976
|
+
<!--/fact-->
|
|
936
977
|
|
|
937
|
-
So the spatial hatch <!--
|
|
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
|
|
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
|
|
1089
|
-
`JD0030` — an unknown `x-entity` member; `JD0031` — relation
|
|
1090
|
-
declarations whose inverses contradict
|
|
1091
|
-
|
|
1092
|
-
|
|
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>[*]`.
|
|
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
|
|
1197
|
-
|
|
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
|
|
1227
|
-
|
|
1228
|
-
|
|
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)`
|
|
1278
|
-
immediate statement, last-write-wins by contract.
|
|
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).
|
|
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.
|
|
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.
|
|
75
|
-
"@jarenjs/json": "^0.
|
|
76
|
-
"@jarenjs/validate": "^0.
|
|
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
|
}
|