@jarenjs/linq 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.
- package/ARCHITECTURE.md +217 -0
- package/README.md +559 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +771 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +437 -36
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +231 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
# The Jaren linq client
|
|
2
|
+
|
|
3
|
+
> `./db` — the client: the store's typed front door, not a pen, and the
|
|
4
|
+
> package's one runtime edge. **Read it when** you are reading or
|
|
5
|
+
> writing rows: `load`, `include`, `link`/`unlink`, `live`
|
|
6
|
+
|
|
7
|
+
Version 0.1. The key words MUST, MUST NOT, SHOULD and MAY are to be
|
|
8
|
+
interpreted as described in RFC 2119. This document is a **guide** — read
|
|
9
|
+
it in order and you can write the format — whose one normative section is
|
|
10
|
+
[§2 The surface](#2-the-surface); the rules every pen keeps, the shared refusal table, the
|
|
11
|
+
index of the other pens and every pen's mapping table collected in one
|
|
12
|
+
place are the normative reference,
|
|
13
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md).
|
|
14
|
+
|
|
15
|
+
## 1. What it writes
|
|
16
|
+
|
|
17
|
+
You have a store declared with the model pen and you want to read it — a
|
|
18
|
+
list with a filter, the related rows beside each one, a page after the
|
|
19
|
+
last key you saw. The store already answers a query document and a load
|
|
20
|
+
specification; what you would rather not do is write either by hand,
|
|
21
|
+
naming entities and relations in strings that nothing checks. This is the
|
|
22
|
+
door that types both from the model you already wrote.
|
|
23
|
+
|
|
24
|
+
The store's front door: `open(model, options)` opens `@jarenjs/db`'s
|
|
25
|
+
store and fronts it with handles typed from the model pen. **The running example**
|
|
26
|
+
throughout is the blog store [MODEL-PEN.md](MODEL-PEN.md) §3 declares —
|
|
27
|
+
users, their posts, the labels a post carries and the comments under it —
|
|
28
|
+
read four ways in §3 and typed in §5. It is **not a
|
|
29
|
+
pen** in [LINQ-FORMAT.md](LINQ-FORMAT.md) §1's sense — it emits no
|
|
30
|
+
document of its own, so there is no format it writes and no grammar to
|
|
31
|
+
validate against — but it keeps the pen rules where they apply:
|
|
32
|
+
|
|
33
|
+
- every read is a document, EMITTED here and run by the store: a chain
|
|
34
|
+
over a handle is the query document ([QUERY-PEN.md](QUERY-PEN.md) §8),
|
|
35
|
+
and a load graph is the `load` specification MODEL-FORMAT §10.4 reads.
|
|
36
|
+
Both are plain, deep-frozen JSON, and `toJSON()` answers them as a
|
|
37
|
+
pen's does;
|
|
38
|
+
- the types are the pen's phantoms (`InferMeta<>` of the model pen's
|
|
39
|
+
document), with no cast and no generate step;
|
|
40
|
+
- refusals are coded `JL01xx` build errors (`JL0107`, `JL0101`), raised
|
|
41
|
+
where the client can see them earlier than the store and mirrored from
|
|
42
|
+
the store's own rules, never invented;
|
|
43
|
+
- the store stays the engine. The client adds no storage semantics and
|
|
44
|
+
duplicates no algorithm: `include` emits the spec the store already
|
|
45
|
+
runs, membership is the store's own `link`/`unlink`, `live` is the
|
|
46
|
+
store's registration, and every read is one an `explain()` can name.
|
|
47
|
+
|
|
48
|
+
That distinction is why this document is `DB-CLIENT.md` and not
|
|
49
|
+
`DB-PEN.md`, and it is visible in every section below — most of all in
|
|
50
|
+
§2, which enumerates a surface rather than a mapping, and in §3, whose
|
|
51
|
+
fences are specifications the client hands over rather than documents it
|
|
52
|
+
authored.
|
|
53
|
+
|
|
54
|
+
### 1.1 The edge
|
|
55
|
+
|
|
56
|
+
This subpath is the package's one runtime import edge:
|
|
57
|
+
`packages/linq/src/db/` imports `@jarenjs/db`, `@jarenjs/validate` and
|
|
58
|
+
`@jarenjs/formats`, declared under `peerDependencies` with
|
|
59
|
+
`peerDependenciesMeta.optional: true` and never under `dependencies`. A
|
|
60
|
+
consumer of `.` (the chain) or of any pen installs nothing new; a
|
|
61
|
+
consumer of `./db` installs the three; the store never imports this
|
|
62
|
+
package.
|
|
63
|
+
|
|
64
|
+
Three gates hold it, and §7 states what it costs:
|
|
65
|
+
|
|
66
|
+
- the **tree-shaking probes** — the `.` entry carries no client module
|
|
67
|
+
and not one byte of the three; the `./db` bundle carries all three and
|
|
68
|
+
no other pen;
|
|
69
|
+
- the **packed-consumer gate** — every subpath is imported WITHOUT the
|
|
70
|
+
peers first, where `./db` must fail by a peer's name and nothing else
|
|
71
|
+
may fail, then with them installed from the tarballs;
|
|
72
|
+
- the **edge suite** in `test/db/provider.test.js` — both manifests, and
|
|
73
|
+
every source and declaration file of both packages, for every import
|
|
74
|
+
spelling.
|
|
75
|
+
|
|
76
|
+
## 2. The surface
|
|
77
|
+
|
|
78
|
+
**A note on this section's title.** Every other document in this family
|
|
79
|
+
titles its §2 "The mapping table", because a pen maps a method to the
|
|
80
|
+
member it emits. The client maps nothing: it opens a store and hands
|
|
81
|
+
back typed handles, so a table with an "Emits" column would have to
|
|
82
|
+
invent one. §2 keeps its D3 slot and its meaning — this is where every
|
|
83
|
+
name a caller writes is named — under the title that describes what it
|
|
84
|
+
holds.
|
|
85
|
+
|
|
86
|
+
The vocabulary is small and the surface is not. Two exported names, and
|
|
87
|
+
then whatever those two hand back: a client of frozen handles, each of
|
|
88
|
+
which is the store's own set plus the chain plus three additions. §2.1
|
|
89
|
+
divides the two; §2.2 to §2.5 enumerate them.
|
|
90
|
+
|
|
91
|
+
### 2.1 What is the store's and what is the client's
|
|
92
|
+
|
|
93
|
+
Which half of every member you meet belongs to `@jarenjs/db` and which
|
|
94
|
+
is added here — the line to have in mind before the tables, because it
|
|
95
|
+
decides which document answers a question about behaviour.
|
|
96
|
+
|
|
97
|
+
| Member | Whose | What the client does |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `open(model, { driver, …, validator? })` | the store's `openStore`, every option forwarded verbatim (`capture`, `live`, `jobs`, `profile`, … included) | wires `validator` as `compileSchema` — the default, `defaultValidator()`, is `new JarenValidator({ collectErrors: true })` with the string and date-time formats registered (the configuration MIGRATING-FROM-ZOD's recipe reproduces, so `s.string().email()` asserts out of the box); an explicit `compileSchema` wins; `validator: null` opens unvalidated, by name (`capabilities.validated === false`) |
|
|
100
|
+
| `client.entities.<Name>` | one frozen handle per declared entity, built at open (no Proxy; an unknown name is `undefined`, and for a pen model a compile error) | the store's typed entity set, every member — `create get update delete load explainLoad add put remove discard link unlink asNoTracking execute explain root scope relations` — plus §2.3's additions |
|
|
101
|
+
| `where`, `select`, `orderBy`, …, `toArray`, `first`, `count`, … | the chain: `fromAsync(handle)` ([QUERY-PEN.md](QUERY-PEN.md) §8, §10) | every `AsyncSequence` operator and terminal, delegated — nothing is duplicated, every read is the chain's document and pushes down; the handle is iterable (`for await`); two handles of one client share a `scope`, so a join's inner may be `fromAsync(otherHandle)` |
|
|
102
|
+
| `include(pick, spec?)` | the store's `load(spec)` (MODEL-FORMAT §10.4, §10.5) | opens a graph that EMITS the spec (§2.4, §3), typed `Loaded<>` by what it included |
|
|
103
|
+
| `link(own, member, target)`, `unlink(…)` | the store's membership API (MODEL-FORMAT §11.7) | reads the relation table first — the member must be a many-to-many relation (`JL0107`, naming the kind it is, or the members that are) — then records through the store; `saveChanges()` writes the join rows |
|
|
104
|
+
| `live(chain \| document, options?)` | the store's registration — `store.live` for an entity root, `collection.live` for a collection (LIVE-FORMAT §7) | hands over the chain's document and its `explain().bindings` as the externals (`options.externals` merge over them); the strategy, the reason and the maintenance are the store's |
|
|
105
|
+
| `client.collections.<name>` | the store's collection | the same chain start and `live`, typed from the pen's collection schema (§2.5) |
|
|
106
|
+
| `saveChanges()`, `transaction(fn)`, `close()`, `capabilities`, `store` | the store's | pass-throughs; `saveChanges` and `live` exist exactly when the model declares entities, as on the store; `store` is the escape hatch, typed `TypedStore` |
|
|
107
|
+
|
|
108
|
+
### 2.2 The two exported names
|
|
109
|
+
|
|
110
|
+
The whole export surface: a door, and a type-level reader for what it
|
|
111
|
+
hands back.
|
|
112
|
+
|
|
113
|
+
| Name | Answers | Type reading |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `open(model, options)` | a promise of the frozen client — `store`, `capabilities`, `entities`, `collections`, `transaction`, `close`, and `saveChanges`/`live` when the model declares entities | `Client<InferMeta<typeof model>>` for a pen model; `Client<E>` for `open<E>(json, …)`; the wide map for a bare JSON model |
|
|
116
|
+
| `defaultValidator()` | `new JarenValidator({ collectErrors: true })` with `stringFormats` and `dateTimeFormats` registered | `JarenValidator` |
|
|
117
|
+
|
|
118
|
+
`open` is the only door, and it is deliberately not a coded refusal: a
|
|
119
|
+
missing `options`, or a `validator` that is not a `JarenValidator`, is a
|
|
120
|
+
plain `TypeError` naming the driver imports (`open needs { driver } from
|
|
121
|
+
@jarenjs/db/node, /bun or /wasm`). A `JL01xx` is a refusal to write
|
|
122
|
+
something into a document, and neither of those is about a document.
|
|
123
|
+
|
|
124
|
+
`defaultValidator()` is exported so a host can build the same validator
|
|
125
|
+
and add to it — `defaultValidator().addFormats(myFormats)` — rather than
|
|
126
|
+
reconstruct the configuration by reading this paragraph.
|
|
127
|
+
|
|
128
|
+
### 2.3 The entity handle
|
|
129
|
+
|
|
130
|
+
A handle is 59 members and no Proxy: 18 from the store's entity set, 40
|
|
131
|
+
from the chain, one name in both (`explain`, resolved below), and two of
|
|
132
|
+
the client's own.
|
|
133
|
+
|
|
134
|
+
| Group | Members |
|
|
135
|
+
|---|---|
|
|
136
|
+
| the unit of work | `create` `get` `update` `delete` `add` `put` `remove` `discard` `asNoTracking` |
|
|
137
|
+
| the store's reads | `load` `explainLoad` `execute` |
|
|
138
|
+
| the provider seam | `root` `scope` `relations` |
|
|
139
|
+
| membership | `link` `unlink` — the store's, behind §4.2's check |
|
|
140
|
+
| the chain | every `AsyncSequence` operator and terminal: `where` `select` `selectMany` `orderBy` `orderByDescending` `thenBy` `thenByDescending` `groupBy` `aggregate` `join` `groupJoin` `skip` `take` `distinct` `reverse` `concat` `defaultIfEmpty` `ofType` `cast` `zip` `mapAsync` `params` `toDocument` `toArray` `first` `firstOrDefault` `single` `singleOrDefault` `last` `lastOrDefault` `elementAt` `elementAtOrDefault` `count` `sum` `average` `min` `max` `any` `all`, and `Symbol.asyncIterator` |
|
|
141
|
+
| the client's own | `include` (§2.4) and `live` |
|
|
142
|
+
| in both | `explain` |
|
|
143
|
+
|
|
144
|
+
**`explain` is the one name the store's set and the chain both carry, and
|
|
145
|
+
it is resolved by arity rather than by precedence.** `handle.explain()`
|
|
146
|
+
with no argument explains the EMPTY chain — `{ barriers: [], hops: [],
|
|
147
|
+
bindings: {}, document: '$.Post[*]' }` — and `handle.explain(document,
|
|
148
|
+
options?)` is the store's own explanation of that document, the one that
|
|
149
|
+
names the translator, the SQL and the referenced roots. It is the only
|
|
150
|
+
collision: a sweep of the chain's 40 names against the entity set's 18
|
|
151
|
+
finds `explain` and nothing else, which is what makes the delegation
|
|
152
|
+
safe to state as a rule rather than as a list of exceptions.
|
|
153
|
+
|
|
154
|
+
A chain over a handle is the query document and pushes down:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
client.entities.Post.where((p) => p.stars.ge(3)).toDocument()
|
|
158
|
+
```
|
|
159
|
+
```jsonc
|
|
160
|
+
{ "$for": { "it": "$.Post[*]" }, "$where": { "$ge": ["$it.stars", 3] }, "$return": "$it" }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
That document is the chain's, not the client's — `fromAsync(handle)`
|
|
164
|
+
would build the same one — which is exactly the claim "nothing is
|
|
165
|
+
duplicated" makes checkable. `test/linq/client.test.js` asserts it, then
|
|
166
|
+
hands it to `explain()` and asserts `mode: 'native'` with no residual.
|
|
167
|
+
|
|
168
|
+
### 2.4 The graph
|
|
169
|
+
|
|
170
|
+
`include(pick, spec?)` opens a graph: an immutable builder of the store's
|
|
171
|
+
`load` specification, with 15 members of its own.
|
|
172
|
+
|
|
173
|
+
| Member | Emits | Note |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `include(pick, spec?)` | one entry of `include` | `pick` is `(u) => u.posts`, or `u.get('posts')` for a name that collides with a proxy method |
|
|
176
|
+
| `where(predicate)` | `where` | consecutive calls conjoin under one `$and` |
|
|
177
|
+
| `orderBy(key, options?)`, `orderByDescending(key, options?)` | `orderBy` | replaces; `options` is `{ empty?, collation? }` |
|
|
178
|
+
| `thenBy(key, options?)`, `thenByDescending(key, options?)` | appends to `orderBy` | `JL0005` when no `orderBy` precedes it |
|
|
179
|
+
| `take(n)`, `skip(n)` | `take`, `skip` | the offset window |
|
|
180
|
+
| `after(cursor)` | `after` | the keyset cursor (§10.5); the ROOT only |
|
|
181
|
+
| `maxDepth(n)` | `maxDepth` | the include depth bound (§10.4) |
|
|
182
|
+
| `asNoTracking()` | — | changes the load, never the document |
|
|
183
|
+
| `toSpec()`, `toJSON()` | the spec | plain deep-frozen JSON, a snapshot: mutating it changes nothing, and two builds are one document |
|
|
184
|
+
| `toArray()` | — | `load(spec)`: the store's one statement |
|
|
185
|
+
| `explain()` | — | `explainLoad(spec)`: the SQL, the includes, the pagination strategy |
|
|
186
|
+
|
|
187
|
+
The spec's member order is fixed — `where, orderBy, take, skip, after,
|
|
188
|
+
maxDepth, include` at the root; `where, orderBy, take, skip, count,
|
|
189
|
+
include` in an include — so one graph is one document however it was
|
|
190
|
+
built. An include spec is `true` (or absent) for the rows, `{ count:
|
|
191
|
+
true }` for the number, or an object of clauses:
|
|
192
|
+
|
|
193
|
+
| Spec member | Emitted | Note |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| absent, or `true` | `true` | the rows |
|
|
196
|
+
| `{ count: true }` | `{ count: true }` | the number; any other member beside it is the store's `JD0032` |
|
|
197
|
+
| `where: (p) => p.stars.ge(3)` | `where: { $ge: ["$it.stars", 3] }` | the target row is `it`; translatability is the store's verdict (`JD0032`), and a relation hop is a plain path here and refused there |
|
|
198
|
+
| `orderBy: (p) => p.pid` | `orderBy: "$it.pid"` | a bare key, ascending |
|
|
199
|
+
| `orderBy: { key, desc?, empty?, collation? }` | `orderBy: { $key, $dir, $empty, $collation }` | as the chain spells `$orderby`; an array of either is an array |
|
|
200
|
+
| `take`, `skip` | `take`, `skip` | the window inside the subquery (a non-integer is the store's `JD0032`) |
|
|
201
|
+
| `include: { comments: spec }` | `include: { comments: <lowered> }` | over the TARGET's relation table (the scope carries every root's) |
|
|
202
|
+
| anything else | `JL0101` | the vocabulary is closed; `after` paginates the root, never an include |
|
|
203
|
+
|
|
204
|
+
Every callback is captured over `$it` through the chain's recording proxy
|
|
205
|
+
with **no parameters** — a load clause binds no externals, so `p.min` is
|
|
206
|
+
`JL0004` and a value that varies belongs in a JavaScript constant the
|
|
207
|
+
capture closes over.
|
|
208
|
+
|
|
209
|
+
What comes back from `explain()` is the store's, and it is worth showing
|
|
210
|
+
once because it is the answer to "did my graph become one statement":
|
|
211
|
+
|
|
212
|
+
```jsonc
|
|
213
|
+
// client.entities.User.include((u) => u.posts).explain(), abridged
|
|
214
|
+
{
|
|
215
|
+
"sql": "SELECT \"r\".*, … (SELECT json_group_array(…) FROM \"Post\" …) AS \"__posts\" FROM \"User\" AS \"r\" …",
|
|
216
|
+
"pagination": "none",
|
|
217
|
+
"includes": [{ "path": "posts", "kind": "oneToMany", "count": false }]
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
One `sql`, one `pagination` strategy, and one `includes` entry per loaded
|
|
222
|
+
relation with the path it was reached by. `test/linq/client.test.js`
|
|
223
|
+
counts the driver's statement executions and asserts exactly one for a
|
|
224
|
+
graph with two includes.
|
|
225
|
+
|
|
226
|
+
### 2.5 The collection handle
|
|
227
|
+
|
|
228
|
+
A collection handle is 49 members: 10 from the store's collection
|
|
229
|
+
(`stats` `get` `insert` `put` `patch` `delete` `execute` `query`
|
|
230
|
+
`explain` `live`), the same 40 chain members, and the same one overlap on
|
|
231
|
+
`explain`. It has no `include`, no `link`/`unlink` and no unit of work,
|
|
232
|
+
because a collection has no relations and no tracking — and neither does
|
|
233
|
+
its client: a collections-only model opens a client with no
|
|
234
|
+
`saveChanges` and no `live` of its own, exactly as the store does.
|
|
235
|
+
|
|
236
|
+
## 3. Worked examples
|
|
237
|
+
|
|
238
|
+
The client's examples are not builder-to-document pairs, and this is
|
|
239
|
+
where a reader who has read a pen document should slow down. A `js`
|
|
240
|
+
fence here exports a **graph**, and the `json` fence beside it is the
|
|
241
|
+
`load` specification that graph emits — a document the client hands the
|
|
242
|
+
store, not one it authored. Every pair is executed by
|
|
243
|
+
`test/linq/pen-docs.test.js`, and each graph in it also loads: the
|
|
244
|
+
fences were run through `explain()` and `toArray()` against a real
|
|
245
|
+
`node:sqlite` store before they were written down.
|
|
246
|
+
|
|
247
|
+
A chain over a handle cannot be a pair, because the chain's document is
|
|
248
|
+
not read by `schemaOf`; §2.3 shows one as prose with its assertion cited
|
|
249
|
+
from `test/linq/client.test.js`, and §2.4 does the same for an
|
|
250
|
+
`explain()`. Four pairs is what the graph surface supports honestly, and
|
|
251
|
+
four is what §3 carries.
|
|
252
|
+
|
|
253
|
+
Each fence opens its own store over the smallest model that carries the
|
|
254
|
+
relations it needs, so a reader can run any one of them alone — but they
|
|
255
|
+
are all the same store, the blog [MODEL-PEN.md](MODEL-PEN.md) §3
|
|
256
|
+
declares: users, their posts, the labels a post carries and the comments
|
|
257
|
+
under it. Read in order, the four are one reading session against it: an
|
|
258
|
+
include with a spec and a counted membership, then the root clauses and a
|
|
259
|
+
cursor, then every way to spell an ordering, then a bracketed pick with a
|
|
260
|
+
two-level include.
|
|
261
|
+
|
|
262
|
+
### 3.1 An include with a spec, and a counted membership
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
import { open } from '@jarenjs/linq/db';
|
|
266
|
+
import * as m from '@jarenjs/linq/model';
|
|
267
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
268
|
+
|
|
269
|
+
const User = m.object({
|
|
270
|
+
id: m.string().identity('uuid'),
|
|
271
|
+
email: m.string().email(),
|
|
272
|
+
posts: m.rel.hasMany('Post', { via: 'authorId', onDelete: 'cascade' }),
|
|
273
|
+
labels: m.rel.belongsToMany('Label'),
|
|
274
|
+
});
|
|
275
|
+
const Post = m.object({ pid: m.integer().identity('auto'), stars: m.integer(), authorId: m.string() });
|
|
276
|
+
const Label = m.object({ name: m.string().key() });
|
|
277
|
+
const client = await open(m.defineModel({ entities: { User, Post, Label } }), { driver: nodeDriver() });
|
|
278
|
+
|
|
279
|
+
// the graph EMITS the spec below; toArray() is load(spec) — one statement — and
|
|
280
|
+
// explain() is explainLoad(spec). The rows type as User & { posts: Post[]; labels: number }
|
|
281
|
+
export const graph = client.entities.User
|
|
282
|
+
.include((u) => u.posts, { where: (p) => p.stars.ge(3), orderBy: { key: (p) => p.stars, desc: true }, take: 2 })
|
|
283
|
+
.include((u) => u.labels, { count: true });
|
|
284
|
+
```
|
|
285
|
+
```json
|
|
286
|
+
{
|
|
287
|
+
"include": {
|
|
288
|
+
"posts": {
|
|
289
|
+
"where": { "$ge": ["$it.stars", 3] },
|
|
290
|
+
"orderBy": { "$key": "$it.stars", "$dir": "desc" },
|
|
291
|
+
"take": 2
|
|
292
|
+
},
|
|
293
|
+
"labels": { "count": true }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`labels` is a many-to-many member and `{ count: true }` is the shape that
|
|
299
|
+
answers "how many" without loading the rows — a number on the loaded
|
|
300
|
+
row, not an array. The include's `where` and `orderBy` are captured over
|
|
301
|
+
the TARGET (`p` is a `Post`), which is the one thing about `include`
|
|
302
|
+
that a reader coming from the chain has to re-learn.
|
|
303
|
+
|
|
304
|
+
### 3.2 The root clauses, and the keyset cursor
|
|
305
|
+
|
|
306
|
+
```js
|
|
307
|
+
import { open } from '@jarenjs/linq/db';
|
|
308
|
+
import * as m from '@jarenjs/linq/model';
|
|
309
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
310
|
+
|
|
311
|
+
const User = m.object({
|
|
312
|
+
id: m.string().identity('uuid'),
|
|
313
|
+
email: m.string().email(),
|
|
314
|
+
posts: m.rel.hasMany('Post', { via: 'authorId', onDelete: 'cascade' }),
|
|
315
|
+
labels: m.rel.belongsToMany('Label'),
|
|
316
|
+
});
|
|
317
|
+
const Post = m.object({
|
|
318
|
+
pid: m.integer().identity('auto'),
|
|
319
|
+
title: m.string(),
|
|
320
|
+
stars: m.integer(),
|
|
321
|
+
authorId: m.string(),
|
|
322
|
+
author: m.rel.hasOne('User', { via: 'authorId', onDelete: 'cascade' }),
|
|
323
|
+
comments: m.rel.hasMany('Comment', { via: 'postId', onDelete: 'cascade' }),
|
|
324
|
+
});
|
|
325
|
+
const Comment = m.object({ cid: m.integer().identity('auto'), text: m.string(), postId: m.integer() });
|
|
326
|
+
const Label = m.object({ name: m.string().key() });
|
|
327
|
+
const entities = { User, Post, Comment, Label };
|
|
328
|
+
const client = await open(m.defineModel({ entities }), { driver: nodeDriver() });
|
|
329
|
+
|
|
330
|
+
// the root clauses: two where()s conjoin under one $and, orderBy is a bare key
|
|
331
|
+
// ascending, and after() is the keyset cursor the store pages on
|
|
332
|
+
export const graph = client.entities.Post
|
|
333
|
+
.include((p) => p.author, { include: { labels: true } })
|
|
334
|
+
.include((p) => p.comments, { count: true })
|
|
335
|
+
.where((p) => p.stars.ge(1))
|
|
336
|
+
.where((p) => p.title.ne('none'))
|
|
337
|
+
.orderBy((p) => p.pid)
|
|
338
|
+
.take(2)
|
|
339
|
+
.after(1)
|
|
340
|
+
.maxDepth(4);
|
|
341
|
+
```
|
|
342
|
+
```json
|
|
343
|
+
{
|
|
344
|
+
"where": { "$and": [{ "$ge": ["$it.stars", 1] }, { "$ne": ["$it.title", "none"] }] },
|
|
345
|
+
"orderBy": "$it.pid",
|
|
346
|
+
"take": 2,
|
|
347
|
+
"after": 1,
|
|
348
|
+
"maxDepth": 4,
|
|
349
|
+
"include": { "author": { "include": { "labels": true } }, "comments": { "count": true } }
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Three facts the fence carries that a sentence would only assert. The
|
|
354
|
+
member order is the spec's, not the call order — `include` was called
|
|
355
|
+
first and is written last. Two `where`s became one `$and` rather than
|
|
356
|
+
two members, because a spec has one `where`. And `include: { labels:
|
|
357
|
+
true }` under `author` resolved against **`User`**'s relation table, not
|
|
358
|
+
`Post`'s: a nested include walks the TARGET's relations, which the graph
|
|
359
|
+
finds through the provider scope every root of one store shares.
|
|
360
|
+
`explain().pagination` for this graph is `"keyset"`; drop the `after`
|
|
361
|
+
and add a `skip` and it is `"offset"`.
|
|
362
|
+
|
|
363
|
+
### 3.3 Every ordering spelling
|
|
364
|
+
|
|
365
|
+
```js
|
|
366
|
+
import { open } from '@jarenjs/linq/db';
|
|
367
|
+
import * as m from '@jarenjs/linq/model';
|
|
368
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
369
|
+
|
|
370
|
+
const Post = m.object({
|
|
371
|
+
pid: m.integer().identity('auto'),
|
|
372
|
+
title: m.string(),
|
|
373
|
+
stars: m.integer(),
|
|
374
|
+
comments: m.rel.hasMany('Comment', { via: 'postId', onDelete: 'cascade' }),
|
|
375
|
+
});
|
|
376
|
+
const Comment = m.object({ cid: m.integer().identity('auto'), text: m.string(), postId: m.integer() });
|
|
377
|
+
const client = await open(m.defineModel({ entities: { Post, Comment } }), { driver: nodeDriver() });
|
|
378
|
+
|
|
379
|
+
// a bare key is ascending; anything more is the $orderby spec the chain writes;
|
|
380
|
+
// an include's own orderBy takes the same spellings, and an array of them
|
|
381
|
+
export const graph = client.entities.Post
|
|
382
|
+
.include((p) => p.comments, { orderBy: [(c) => c.postId, { key: (c) => c.text, desc: true }], skip: 1, take: 5 })
|
|
383
|
+
.orderByDescending((p) => p.stars)
|
|
384
|
+
.thenBy((p) => p.title, { empty: 'greatest' })
|
|
385
|
+
.thenByDescending((p) => p.pid)
|
|
386
|
+
.skip(1)
|
|
387
|
+
.take(2);
|
|
388
|
+
```
|
|
389
|
+
```json
|
|
390
|
+
{
|
|
391
|
+
"orderBy": [
|
|
392
|
+
{ "$key": "$it.stars", "$dir": "desc" },
|
|
393
|
+
{ "$key": "$it.title", "$empty": "greatest" },
|
|
394
|
+
{ "$key": "$it.pid", "$dir": "desc" }
|
|
395
|
+
],
|
|
396
|
+
"take": 2,
|
|
397
|
+
"skip": 1,
|
|
398
|
+
"include": {
|
|
399
|
+
"comments": {
|
|
400
|
+
"orderBy": ["$it.postId", { "$key": "$it.text", "$dir": "desc" }],
|
|
401
|
+
"take": 5,
|
|
402
|
+
"skip": 1
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
One term is written as the bare key and the rest as `{ $key, … }`,
|
|
409
|
+
because the spec form is what carries a direction, an empty-ordering or
|
|
410
|
+
a collation and the bare form is what a plain ascending key needs. The
|
|
411
|
+
rule is mechanical: a term with nothing but a key IS the key. Note the
|
|
412
|
+
second term — `thenBy(key, { empty: 'greatest' })` — carries `$empty`
|
|
413
|
+
and no `$dir`, since ascending is the default and §1.1's no-defaults
|
|
414
|
+
rule is the chain's too.
|
|
415
|
+
|
|
416
|
+
### 3.4 A bracketed pick, a two-level include, and `asNoTracking()`
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
import { open } from '@jarenjs/linq/db';
|
|
420
|
+
import * as m from '@jarenjs/linq/model';
|
|
421
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
422
|
+
|
|
423
|
+
const User = m.object({
|
|
424
|
+
id: m.string().identity('uuid'),
|
|
425
|
+
email: m.string().email(),
|
|
426
|
+
posts: m.rel.hasMany('Post', { via: 'authorId', onDelete: 'cascade' }),
|
|
427
|
+
labels: m.rel.belongsToMany('Label'),
|
|
428
|
+
});
|
|
429
|
+
const Post = m.object({
|
|
430
|
+
pid: m.integer().identity('auto'),
|
|
431
|
+
title: m.string(),
|
|
432
|
+
stars: m.integer(),
|
|
433
|
+
authorId: m.string(),
|
|
434
|
+
author: m.rel.hasOne('User', { via: 'authorId', onDelete: 'cascade' }),
|
|
435
|
+
comments: m.rel.hasMany('Comment', { via: 'postId', onDelete: 'cascade' }),
|
|
436
|
+
});
|
|
437
|
+
const Comment = m.object({ cid: m.integer().identity('auto'), text: m.string(), postId: m.integer() });
|
|
438
|
+
const Label = m.object({ name: m.string().key() });
|
|
439
|
+
const entities = { User, Post, Comment, Label };
|
|
440
|
+
const client = await open(m.defineModel({ entities }), { driver: nodeDriver() });
|
|
441
|
+
|
|
442
|
+
// a member picked by its bracketed name (u.get('labels')), a two-level include
|
|
443
|
+
// over the TARGET's relation table, and asNoTracking() — which changes the load,
|
|
444
|
+
// never the document
|
|
445
|
+
export const graph = client.entities.User
|
|
446
|
+
.include((u) => u.get('labels'))
|
|
447
|
+
.include((u) => u.posts, { include: { comments: { where: (c) => c.text.ne(''), take: 3 } } })
|
|
448
|
+
.asNoTracking();
|
|
449
|
+
```
|
|
450
|
+
```json
|
|
451
|
+
{
|
|
452
|
+
"include": {
|
|
453
|
+
"labels": true,
|
|
454
|
+
"posts": { "include": { "comments": { "where": { "$ne": ["$it.text", ""] }, "take": 3 } } }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`u.get('labels')` and `u.labels` capture the same member. The bracketed
|
|
460
|
+
spelling exists for a member whose name collides with a method of the
|
|
461
|
+
recording proxy — `at`, `get`, `all`, `count` and their kin — where the
|
|
462
|
+
plain read would answer the proxy's function instead of recording a
|
|
463
|
+
path. A model that names a relation `count` needs `u.get('count')`, and
|
|
464
|
+
nothing else about it changes.
|
|
465
|
+
|
|
466
|
+
`asNoTracking()` is absent from the emitted spec, and that is correct:
|
|
467
|
+
it selects `set.asNoTracking().load(spec)` over `set.load(spec)`, which
|
|
468
|
+
is a choice about the unit of work rather than about the query. Two
|
|
469
|
+
graphs that differ only in it are one document.
|
|
470
|
+
|
|
471
|
+
## 4. Refusals
|
|
472
|
+
|
|
473
|
+
The client raises these two `LinqBuildError` codes and no others —
|
|
474
|
+
`test/linq/pen-docs.test.js` holds this list equal, in both directions,
|
|
475
|
+
to the codes `packages/linq/src/db/` names. The full condition each code
|
|
476
|
+
states across every pen is the binder's,
|
|
477
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
|
|
478
|
+
|
|
479
|
+
| Code | What this pen raises it for |
|
|
480
|
+
|---|---|
|
|
481
|
+
| `JL0101` | a value the client cannot put in a specification: a clause that is not a callback, a spec member the vocabulary does not carry, or a value that is not JSON |
|
|
482
|
+
| `JL0107` | a member that is not the relation kind the operation needs |
|
|
483
|
+
|
|
484
|
+
Every message below is the one the client raised when the spelling beside
|
|
485
|
+
it was run, with the code prefix removed. None of them carries a
|
|
486
|
+
`docPath`: a load specification's positions are named in the message text
|
|
487
|
+
itself (`the include spec at posts.comments`), because a spec is not
|
|
488
|
+
assembled node by node the way a pen's document is.
|
|
489
|
+
|
|
490
|
+
Two codes the client does NOT raise, and a reader will meet both. `JL0004`
|
|
491
|
+
and `JL0005` are the chain's, and they reach a graph unchanged — a load
|
|
492
|
+
clause that binds an external is `JL0004` (`parameter 'x' is not
|
|
493
|
+
declared`), and a `thenBy()` with no `orderBy` before it is `JL0005`.
|
|
494
|
+
Everything the store's own vocabulary cannot carry is the store's
|
|
495
|
+
refusal, raised where the store raises it: `JD0032` for a spec the load
|
|
496
|
+
engine cannot translate, `JD2003` for a write or a membership target the
|
|
497
|
+
store rejects, `JD0050` for a live query on a store opened without
|
|
498
|
+
capture.
|
|
499
|
+
|
|
500
|
+
### 4.1 `JL0101` — the clause and the spec member
|
|
501
|
+
|
|
502
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
503
|
+
|---|---|---|
|
|
504
|
+
| `include('posts')` | `include() takes a callback over the row, got a string` | `include((u) => u.posts)` |
|
|
505
|
+
| `include((u) => u.posts, { wehre: 1 })` | `the include spec at posts does not take 'wehre' — the members are where, orderBy, take, skip, count, include` | `{ where: (p) => … }` |
|
|
506
|
+
| `include((u) => u.posts, { after: 1 })` | `the include spec at posts does not take 'after' — the members are where, orderBy, take, skip, count, include; a keyset cursor paginates the root: after() on the graph` | `.after(cursor)` on the graph |
|
|
507
|
+
| `include((u) => u.posts, 7)` | `the include spec at posts is true or { where?, orderBy?, take?, skip?, count?, include? }, got 7` | `true`, or a spec object |
|
|
508
|
+
| `include((u) => u.posts, { count: 1 })` | `the include spec at posts: count takes true` | `{ count: true }` |
|
|
509
|
+
| `include((u) => u.posts, { orderBy: 5 })` | `posts orderBy takes a key callback ((p) => p.stars) or { key, desc?, empty?, collation? }, got 5` | `{ orderBy: (p) => p.pid }` |
|
|
510
|
+
| `include((u) => u.posts, { include: 3 })` | `include at posts is a record of relation members, got 3` | `{ include: { comments: true } }` |
|
|
511
|
+
| `include((u) => u.posts, { take: new Date(0) })` | `posts take received a Date instance, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | `{ take: 2 }` |
|
|
512
|
+
| `.orderBy(5)`, `.thenBy(5)`, `.thenByDescending(5)`, `.where(5)` | `orderBy() takes a callback over the row, got 5` | a callback |
|
|
513
|
+
|
|
514
|
+
Rows two, three and four are one rule seen three ways, and they are held
|
|
515
|
+
to each other: the member list the shape message shows is DERIVED from
|
|
516
|
+
the same constant the member check reads, so the two cannot disagree.
|
|
517
|
+
They did — the message named `after?` while the check refused `after` by
|
|
518
|
+
name — and `test/linq/client.test.js` now reads the members out of the
|
|
519
|
+
message and asserts the spec accepts every one of them, which is the
|
|
520
|
+
check that would have caught it.
|
|
521
|
+
|
|
522
|
+
### 4.2 `JL0107` — the relation kind
|
|
523
|
+
|
|
524
|
+
`JL0107` is the client's own code — no pen raises it — and it has exactly
|
|
525
|
+
two conditions, one per operation that reads the relation table.
|
|
526
|
+
|
|
527
|
+
**`include()` picks a declared relation member.** The pick is captured to
|
|
528
|
+
a member PATH and looked up; a scalar member, a name the model does not
|
|
529
|
+
declare, or a callback that is not a bare member read is refused naming
|
|
530
|
+
what the entity does declare.
|
|
531
|
+
|
|
532
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
533
|
+
|---|---|---|
|
|
534
|
+
| `client.entities.User.include((u) => u.email)` | `'email' is not a relation member of 'User' — include() loads a declared relation ('posts', 'labels')` | `include((u) => u.posts)` |
|
|
535
|
+
| `client.entities.User.include((u) => u.nope)` | `'nope' is not a relation member of 'User' — include() loads a declared relation ('posts', 'labels')` | a declared relation |
|
|
536
|
+
| `client.entities.User.include((u) => u.age.ge(1))` | `include() picks one relation member of 'User' by name ((u) => u.posts); got an operator result` | a bare member read |
|
|
537
|
+
| `include((u) => u.posts, { include: { nope: true } })` | `'nope' is not a relation member of 'Post' — a nested include loads a declared relation ('author', 'comments')` | a relation of the TARGET |
|
|
538
|
+
|
|
539
|
+
The last row names `Post`'s relations rather than `User`'s, which is the
|
|
540
|
+
whole point of resolving a nested include against the target's table: a
|
|
541
|
+
reader who mistyped a member is shown the members that exist where they
|
|
542
|
+
mistyped it.
|
|
543
|
+
|
|
544
|
+
**`link()` and `unlink()` attach many-to-many memberships only.** The
|
|
545
|
+
member must be a `manyToMany` relation of the entity; anything else is
|
|
546
|
+
refused naming the kind it actually is, or the many-to-many members the
|
|
547
|
+
entity does declare.
|
|
548
|
+
|
|
549
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
550
|
+
|---|---|---|
|
|
551
|
+
| `client.entities.User.link('u1', 'posts', 1)` | `'posts' is a oneToMany relation of 'User' — link() attaches many-to-many memberships only; write the related entity's foreign key instead` | `post.authorId = 'u1'` and `put()` |
|
|
552
|
+
| `client.entities.Post.link(1, 'author', 'u1')` | `'author' is a oneToOne relation of 'Post' — link() attaches many-to-many memberships only; write the related entity's foreign key instead` | write the foreign key |
|
|
553
|
+
| `client.entities.User.unlink('u1', 'email', 'x')` | `'email' is not a relation member of 'User' — unlink() attaches a many-to-many membership ('labels')` | `unlink('u1', 'labels', 'admin')` |
|
|
554
|
+
| `client.entities.Comment.link(1, 'nope', 'x')` | `'nope' is not a relation member of 'Comment' — link() attaches a many-to-many membership, and 'Comment' declares none` | declare a `belongsToMany` |
|
|
555
|
+
|
|
556
|
+
The two messages differ in what they can name. When the member exists,
|
|
557
|
+
the client knows its kind and says it; when it does not, the client lists
|
|
558
|
+
the many-to-many members the entity has — or says plainly that it has
|
|
559
|
+
none, which is the case where a caller is looking for a feature the model
|
|
560
|
+
never declared.
|
|
561
|
+
|
|
562
|
+
The store would refuse the same members itself (`JD2003`, MODEL-FORMAT
|
|
563
|
+
§11.7); the client sees it earlier, from a table it already reads, and
|
|
564
|
+
the check is mirrored rather than invented. What stays the store's is
|
|
565
|
+
everything about the TARGET: a document with no key, and an own side
|
|
566
|
+
whose `auto` key the save has not allocated yet (`JD2003`: "save the
|
|
567
|
+
entity first, then attach").
|
|
568
|
+
|
|
569
|
+
## 5. The types
|
|
570
|
+
|
|
571
|
+
The client is typed from the model pen's phantom with no cast and no
|
|
572
|
+
generate step. `open()` reads `InferMeta<>` off a pen model; a JSON
|
|
573
|
+
literal is never inferred, so a bare JSON model opens the honest wide map
|
|
574
|
+
and a caller who has a generated map names it. The model below is §3's —
|
|
575
|
+
the blog — and every line is a reading of it.
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
import { open } from '@jarenjs/linq/db';
|
|
579
|
+
import type { Client, EntityHandle, Graph, TypedLiveQuery } from '@jarenjs/linq/db';
|
|
580
|
+
import type { InferMeta } from '@jarenjs/linq/model';
|
|
581
|
+
import type { EntityMetaMap } from './generated.js'; // a JSON model's map, when one exists
|
|
582
|
+
|
|
583
|
+
const client = await open(model, { driver }); // Client<InferMeta<typeof model>>
|
|
584
|
+
client.entities.Post.where((p) => p.stars.ge(3)); // AsyncSequence<Post> — the entity document, no cast
|
|
585
|
+
const users = await client.entities.User
|
|
586
|
+
.include((u) => u.posts, { where: (p) => p.stars.ge(3) }) // p: Expr<Post> — the TARGET entity
|
|
587
|
+
.include((u) => u.labels, { count: true })
|
|
588
|
+
.toArray(); // (User & { posts: Post[]; labels: number })[]
|
|
589
|
+
client.entities.User.link('u1', 'labels', 'admin'); // member: the many-to-many members only
|
|
590
|
+
client.entities.User.link('u1', 'posts', 1); // does not compile — posts is oneToMany
|
|
591
|
+
client.entities.User.include((u) => u.email); // does not compile — not a relation member
|
|
592
|
+
const live: TypedLiveQuery<Post> = await client.live(client.entities.Post.where((p) => p.stars.ge(3)));
|
|
593
|
+
open<EntityMetaMap>(json, { driver }); // a JSON model with a named map
|
|
594
|
+
open(json, { driver }); // Client<Record<string, EntityMeta>> — a literal is never inferred
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### 5.1 `NoInfer` is what keeps a spec's callbacks typed
|
|
598
|
+
|
|
599
|
+
`include<K, const I extends IncludeSpec<…> = true>(pick, spec?: I |
|
|
600
|
+
NoInfer<IncludeSpec<…>>)` looks redundant and is not. `I` has to be
|
|
601
|
+
inferred from the spec LITERAL, because `Loaded<>` reads it to widen the
|
|
602
|
+
row; the callbacks inside that literal have to be contextually typed from
|
|
603
|
+
`IncludeSpec`, because `(p) => p.stars.ge(3)` has no annotation. Without
|
|
604
|
+
the `NoInfer` arm TypeScript fixes `I` to its default before it types
|
|
605
|
+
them, and `p` arrives as `any` — which compiles, and silently stops
|
|
606
|
+
catching the mistake the pin's third negative is about (`{ where: (p) =>
|
|
607
|
+
p.email.eq('x') }` over a target that has no `email`).
|
|
608
|
+
|
|
609
|
+
### 5.2 The auto key is in `required`, and the exemption is the store's
|
|
610
|
+
|
|
611
|
+
`identity('auto')` emits `{ key: true, default: 'auto' }` and the member
|
|
612
|
+
stays in the entity schema's `required`, because the model pen writes the
|
|
613
|
+
schema of a STORED document. The write-time exemption is the store's
|
|
614
|
+
(MODEL-FORMAT §9.6), and the types say the same from the other side:
|
|
615
|
+
`generated` makes the member optional on `EntityInput` and required on
|
|
616
|
+
`EntityDoc`. `create({ title, stars, authorId })` therefore type-checks
|
|
617
|
+
with no `pid`, and every row that comes back has one. Spelling it
|
|
618
|
+
`optional()` in the model to "fix" the emission would make the READ shape
|
|
619
|
+
wrong — see [MODEL-PEN.md](MODEL-PEN.md) §5.
|
|
620
|
+
|
|
621
|
+
### 5.3 Two `include` behaviours a reader meets at run time otherwise
|
|
622
|
+
|
|
623
|
+
- **An include that is `skip`ped still renders.** `skip` is an include's
|
|
624
|
+
own member and the store runs it inside the subquery, so
|
|
625
|
+
`include((u) => u.posts, { skip: 1, take: 2 })` emits `{ take: 2, skip:
|
|
626
|
+
1 }` and loads the second and third rows. Empty is not absent either: a
|
|
627
|
+
`true` include that matched nothing renders `[]` and a `{ count: true }`
|
|
628
|
+
one renders `0`, so a `??` guard on an included member is dead code and
|
|
629
|
+
the widened type (`posts: Post[]`, `labels: number`) is honest.
|
|
630
|
+
- **An include that arrives `after` is refused.** `after` is the one
|
|
631
|
+
window member an include does NOT take: a keyset cursor pages the root
|
|
632
|
+
and only the root, because the cursor is a key of the root entity and
|
|
633
|
+
there is one root per load. `{ after: 1 }` inside a spec is `JL0101`
|
|
634
|
+
naming the graph's own `after()` (§4.1); `.after(cursor)` on the graph
|
|
635
|
+
is the spelling that works, and the graph's `after` is typed `M['key']`
|
|
636
|
+
so a cursor of the wrong type does not compile.
|
|
637
|
+
|
|
638
|
+
### 5.4 What the pin holds
|
|
639
|
+
|
|
640
|
+
`test/consumer/linq-db.ts`, compiled by `npm run test:types`, proves over
|
|
641
|
+
the model corpus: `open()` inferring `Client<Meta, {}>` from a pen model;
|
|
642
|
+
the chain over a handle typed by the entity document, including a join
|
|
643
|
+
between two handles; `include` widening the loaded rows by exactly what
|
|
644
|
+
it included, two levels deep and through `asNoTracking()`; membership
|
|
645
|
+
typed over the many-to-many members with the target as its key or its
|
|
646
|
+
document; `live` rows typed by the chain's item; the pass-throughs and
|
|
647
|
+
the escape hatch; the wide map and the named map; and a collection handle
|
|
648
|
+
typed from the pen's collection schema.
|
|
649
|
+
|
|
650
|
+
Nine negatives sit beside them, each of which FAILS the build the day it
|
|
651
|
+
starts compiling:
|
|
652
|
+
|
|
653
|
+
```ts
|
|
654
|
+
void client.entities.Post.where((p) => p.strs.ge(3)); // a misspelled member
|
|
655
|
+
void client.entities.User.include((u) => u.email); // not a relation member
|
|
656
|
+
void client.entities.User.include((u) => u.posts, { where: (p) => p.email.eq('x') }); // the target's shape
|
|
657
|
+
void client.entities.Post.include((p) => p.author, { include: { nope: true } }); // the target's relations
|
|
658
|
+
void client.entities.Post.include((p) => p.author).after('one'); // the cursor is the key's type
|
|
659
|
+
client.entities.User.link('u1', 'posts', 1); // oneToMany is not a membership
|
|
660
|
+
client.entities.Post.link(1, 'author', 'u1'); // oneToOne is not a membership
|
|
661
|
+
client.entities.User.link('u1', 'labels', 42); // the target's key type
|
|
662
|
+
void places.saveChanges; // a collections-only model has no unit of work
|
|
663
|
+
void named.entities.Nope; // the named map declares no such entity
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
The honest limits, stated where a reader will look for them:
|
|
667
|
+
`where`/`orderBy` inside an include are typed over the target entity but
|
|
668
|
+
checked for TRANSLATABILITY by the store, not by TypeScript, so
|
|
669
|
+
`c.text.length().gt(1)` compiles and is `JD0032` at `explain()`; a `link`
|
|
670
|
+
target is typed as the target's key or document and the store's own
|
|
671
|
+
reading of it still applies (`JD2003` for a document carrying no key);
|
|
672
|
+
and a JSON model opened without a named map is the wide map, where every
|
|
673
|
+
name exists at the type level and an unknown one is `undefined` at run
|
|
674
|
+
time.
|
|
675
|
+
|
|
676
|
+
## 6. What it cannot spell
|
|
677
|
+
|
|
678
|
+
The client writes no document of its own, so it has no construct set to
|
|
679
|
+
refuse as unspellable and raises no `JL0102`. This section is therefore
|
|
680
|
+
about something else: what the client deliberately does not do, where the
|
|
681
|
+
edges of what it can express actually are, and — in §6.1 — when the
|
|
682
|
+
honest answer is to open the store some other way.
|
|
683
|
+
|
|
684
|
+
**It is not a second engine.** The store's planner, its unit of work, its
|
|
685
|
+
translator and its live maintenance are `@jarenjs/db`'s, and nothing here
|
|
686
|
+
reimplements one. `include` emits a specification and hands it over;
|
|
687
|
+
`link` records through the store's own membership API;`live` calls the
|
|
688
|
+
store's registration. The consequence a reader should expect is that a
|
|
689
|
+
verdict about a query — is it translatable, is it one statement, is it
|
|
690
|
+
incremental — comes from `explain()` and never from this document.
|
|
691
|
+
|
|
692
|
+
**A join across two different sources is not expressible.** Two entity
|
|
693
|
+
sets of one store join in one document, because they share a provider
|
|
694
|
+
scope; a join between two STORES, or between a store and an array, would
|
|
695
|
+
need one query document with two inputs and there is no such document.
|
|
696
|
+
Three or more bindings the entity translator names a residual. Both are
|
|
697
|
+
on [docs/ROADMAP.md](../../../docs/ROADMAP.md) under `@jarenjs/linq &
|
|
698
|
+
@jarenjs/db`, "Cross-source linq joins beyond one store".
|
|
699
|
+
|
|
700
|
+
**A many-to-many join table is not a queryable root in this version.**
|
|
701
|
+
`u.labels` as a chain HOP is `JL0105` — the join table has no root to
|
|
702
|
+
bind, so there is no phrase to lower to — and the way to read a
|
|
703
|
+
membership is `include`: `client.entities.User.include((u) => u.labels)`
|
|
704
|
+
loads the rows and `{ count: true }` counts them. A question ABOUT the
|
|
705
|
+
membership ("which pairs were attached since Friday") is reachable only
|
|
706
|
+
by loading and then asking in JavaScript. The change that closes all of
|
|
707
|
+
it is one change, on [docs/ROADMAP.md](../../../docs/ROADMAP.md) under
|
|
708
|
+
`@jarenjs/linq & @jarenjs/db`, "Join tables are not queryable roots".
|
|
709
|
+
|
|
710
|
+
**A chain split by a host callback has no document to register.**
|
|
711
|
+
`mapAsync` runs a JavaScript function per row, so the chain after it is
|
|
712
|
+
not one query document; `live()` over such a chain is the chain's own
|
|
713
|
+
`JL0005` naming the operator that split it. Register the part before the
|
|
714
|
+
split, or write the document by hand.
|
|
715
|
+
|
|
716
|
+
**The client is not the place a model is authored.** `open()` takes a
|
|
717
|
+
`$model` document, from [MODEL-PEN.md](MODEL-PEN.md) or from JSON, and
|
|
718
|
+
never builds one; the migration between two of them is
|
|
719
|
+
[MIGRATION-PEN.md](MIGRATION-PEN.md)'s.
|
|
720
|
+
|
|
721
|
+
### 6.1 When not to reach for this door
|
|
722
|
+
|
|
723
|
+
- **You want the store, not the types.** `openStore` from `@jarenjs/db`
|
|
724
|
+
is the same store with untyped handles, and it is what a program that
|
|
725
|
+
reads its model from JSON at boot already has. This subpath's whole
|
|
726
|
+
value is the phantoms; where there is no model constant to read them
|
|
727
|
+
off, there is nothing to buy. §7 is what the difference costs.
|
|
728
|
+
- **The model is not the model pen's.** `InferMeta<>` reads a model-pen
|
|
729
|
+
document's phantoms. A `$model` parsed from a file carries no phantom, so
|
|
730
|
+
`InferMeta<>` over it is the honest wide map, the handles come back
|
|
731
|
+
untyped, and the door is `openStore` with a generated map named
|
|
732
|
+
explicitly (§5) — or a model-pen constant the parsed document is
|
|
733
|
+
checked against.
|
|
734
|
+
- **The question is about the plan, not the rows.** `explain()` answers
|
|
735
|
+
whether a chain translates, in how many statements, and what stayed
|
|
736
|
+
residual. Read it there. Nothing in this document decides it, and a
|
|
737
|
+
spelling change made to please a sentence here rather than an
|
|
738
|
+
`explain()` output is a guess.
|
|
739
|
+
- **The read is one statement of SQL you already know.** The store takes
|
|
740
|
+
raw statements; a reporting query with three joins and a window
|
|
741
|
+
function is a statement, not a chain, and pretending otherwise costs a
|
|
742
|
+
residual nobody sees until it is slow.
|
|
743
|
+
- **The relation is a membership you want to interrogate.** §6 says it
|
|
744
|
+
above: a join table is not a queryable root, so a question about the
|
|
745
|
+
pairs themselves is a load and then JavaScript. That is a real cost and
|
|
746
|
+
it is worth knowing before the model is shaped around it.
|
|
747
|
+
|
|
748
|
+
## 7. Cost
|
|
749
|
+
|
|
750
|
+
`@jarenjs/linq/db` builds to **<!--fact:bundle.db-->478,172<!--/fact--> bytes** as a minified,
|
|
751
|
+
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
752
|
+
measures and `npm run test:tree-shaking` reports, published rounded
|
|
753
|
+
(<!--fact:bundle.db.kb-->478<!--/fact--> kB) beside the other nine subpath prices in
|
|
754
|
+
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
755
|
+
|
|
756
|
+
It is by far the largest of the ten, and the reason is §1.1's edge rather
|
|
757
|
+
than the client itself: the store, the validator and the formats ride by
|
|
758
|
+
construction, because they are what the client opens. The client's own
|
|
759
|
+
six modules are under 600 source lines. Taking `./db` means taking a SQL
|
|
760
|
+
planner, a unit of work, a live-maintenance engine and a JSON Schema
|
|
761
|
+
validator, and the honest way to read the figure is as the price of the
|
|
762
|
+
database, not of the front door.
|
|
763
|
+
|
|
764
|
+
What the probe asserts, and fails the build on:
|
|
765
|
+
|
|
766
|
+
- **all three peers ride** — `@jarenjs/db`, `@jarenjs/validate` and
|
|
767
|
+
`@jarenjs/formats` each contribute bytes. This direction matters as
|
|
768
|
+
much as the exclusions: a bundle that had shaken one of them away
|
|
769
|
+
would mean the client had stopped opening a real store;
|
|
770
|
+
- **no other pen** — not the contract, flow, app or forms pens, and no
|
|
771
|
+
`@jarenjs/emit` or `@jarenjs/refs` byte;
|
|
772
|
+
- **the edge is droppable everywhere else** — the `.` entry (the chain,
|
|
773
|
+
priced in [QUERY-PEN.md](QUERY-PEN.md) §17) carries no module of
|
|
774
|
+
`packages/linq/src/db/` and not one byte of the three peers, which is
|
|
775
|
+
the tree-shaken proof that a consumer of the chain or of any pen
|
|
776
|
+
installs nothing new. The same probe run over each pen's own bundle
|
|
777
|
+
asserts the same exclusion.
|
|
778
|
+
|
|
779
|
+
A consumer who wants the model pen's types without the store pays
|
|
780
|
+
`./model`'s <!--fact:bundle.model-->40,857<!--/fact--> bytes and installs no peer; one who wants to run
|
|
781
|
+
queries against an array rather than a database pays the chain's price
|
|
782
|
+
(§17 of [QUERY-PEN.md](QUERY-PEN.md)) and installs no peer. `./db` is
|
|
783
|
+
the one subpath whose `package.json` entry carries an optional peer at
|
|
784
|
+
all.
|
|
785
|
+
|
|
786
|
+
### 7.1 What the door costs at run time, measured
|
|
787
|
+
|
|
788
|
+
`benchmark/orm.js` runs the client as one more route in every table
|
|
789
|
+
beside Prisma, Drizzle and Kysely over the same SQLite corpus, equality
|
|
790
|
+
asserted before anything is timed and statement counts printed beside
|
|
791
|
+
the timings.
|
|
792
|
+
|
|
793
|
+
Against the store it fronts, the door is nearly free: <!--fact:orm.clientDoorPrice-->0.9× on a point read, 1.4× on an indexed predicate at 10 % selectivity, 1.0× on the two-level graph load<!--/fact-->
|
|
794
|
+
— because it issues the same documents the store would. What it does
|
|
795
|
+
NOT amortize is capture: a chain re-captures its callbacks and re-emits
|
|
796
|
+
its document on **every** call, by design, which is the predicate row's
|
|
797
|
+
difference and which a caller with a hot query removes by holding the
|
|
798
|
+
`Sequence` (or the emitted document) instead of rebuilding it.
|
|
799
|
+
|
|
800
|
+
Against the rivals, at this corpus, it is faster on <!--fact:orm.clientVsRivals-->8 of 9 against Prisma, 4 of 9 against Drizzle, 1 of 9 against Kysely<!--/fact-->,
|
|
801
|
+
and here is every row where the *fastest* rival beats it — <!--fact:orm.clientLosses-->update one column by primary key 18.8× (Drizzle), nested json member filter 6.3× (Kysely), cold start 3.0× (Prisma), posts per user 2.4× (Kysely), graph load 2.2× (Kysely), indexed predicate over 500 users, ids only 2.0× (Kysely), pagination over 5000 comments, page size 20 1.6× (Kysely), insert 1.4× (Kysely), point read by primary key 1.2× (Drizzle)<!--/fact-->.
|
|
802
|
+
|
|
803
|
+
Three things make that list readable rather than damning, and none of
|
|
804
|
+
them removes a row from it. **Kysely is a SQL builder**: on every row it
|
|
805
|
+
wins, you wrote the SQL — the comparison it belongs in is against a
|
|
806
|
+
hand-written statement, not against a schema-first ORM. **The update row
|
|
807
|
+
is the widest loss and has one cause**: the client's only write door is
|
|
808
|
+
the unit of work (`get`, mutate, `saveChanges`), where a rival issues one
|
|
809
|
+
prepared `UPDATE`; the store's own `update()` sits in the same table so
|
|
810
|
+
the difference is visible rather than argued. And **the claim that
|
|
811
|
+
survives is structural, not temporal** — the graph load's statement
|
|
812
|
+
counts are printed beside its timings, and the client answers a
|
|
813
|
+
two-level graph in ONE statement where the schema-first ORM takes three,
|
|
814
|
+
whatever the corpus and whatever the clock says.
|