@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.
Files changed (77) hide show
  1. package/ARCHITECTURE.md +217 -0
  2. package/README.md +559 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1217 -0
  5. package/docs/DB-CLIENT.md +814 -0
  6. package/docs/FLOW-PEN.md +1026 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +771 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1083 -0
  12. package/docs/QUERY-PEN.md +1636 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +255 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +260 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +329 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +9 -4
  24. package/src/contract/define.js +269 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +342 -0
  28. package/src/db/handle.js +86 -0
  29. package/src/db/include.js +316 -0
  30. package/src/db/index.js +19 -0
  31. package/src/db/live.js +43 -0
  32. package/src/db/membership.js +37 -0
  33. package/src/db/open.js +82 -0
  34. package/src/document.js +143 -13
  35. package/src/effect.js +65 -0
  36. package/src/errors.js +69 -6
  37. package/src/expression.js +437 -36
  38. package/src/flow/capture.js +33 -0
  39. package/src/flow/dag.js +302 -0
  40. package/src/flow/fsm.js +328 -0
  41. package/src/flow/index.js +22 -0
  42. package/src/forms/index.js +43 -0
  43. package/src/forms/rules.js +170 -0
  44. package/src/forms/submit.js +177 -0
  45. package/src/index.js +4 -2
  46. package/src/jslt/body.js +226 -0
  47. package/src/jslt/index.js +18 -0
  48. package/src/jslt/rules.js +207 -0
  49. package/src/json-boundary.js +90 -0
  50. package/src/migration/define.js +323 -0
  51. package/src/migration/index.js +15 -0
  52. package/src/migration/steps.js +248 -0
  53. package/src/model/collection.js +171 -0
  54. package/src/model/define.js +125 -0
  55. package/src/model/entity.js +307 -0
  56. package/src/model/index.js +47 -0
  57. package/src/model/relation.js +85 -0
  58. package/src/provider.js +137 -20
  59. package/src/schema/brand.js +31 -0
  60. package/src/schema/builders.js +526 -0
  61. package/src/schema/check.js +29 -0
  62. package/src/schema/emit.js +394 -0
  63. package/src/schema/factories.js +239 -0
  64. package/src/schema/index.js +37 -0
  65. package/src/schema-of.js +24 -0
  66. package/src/sequence.js +233 -103
  67. package/src/sources.js +10 -3
  68. package/types/app.d.ts +293 -0
  69. package/types/contract.d.ts +371 -0
  70. package/types/db.d.ts +188 -0
  71. package/types/flow.d.ts +285 -0
  72. package/types/forms.d.ts +253 -0
  73. package/types/index.d.ts +231 -26
  74. package/types/jslt.d.ts +193 -0
  75. package/types/migration.d.ts +201 -0
  76. package/types/model.d.ts +493 -0
  77. 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.