@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,1083 @@
1
+ # The Jaren model pen
2
+
3
+ > `./model` — the `x-entity` vocabulary on JSON Schema, and the `$model`
4
+ > 0.1 document `openStore` accepts unchanged. **Read it when** you are
5
+ > declaring a store's entities, their keys and their relations
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 mapping table](#2-the-mapping-table); the rules every pen keeps,
11
+ the shared refusal table, the index of the other pens and every pen's
12
+ mapping table collected in one place are the normative reference,
13
+ [LINQ-FORMAT.md](LINQ-FORMAT.md).
14
+
15
+ ## 1. What it writes
16
+
17
+ You have a database to declare — its entities, their keys, which members
18
+ become columns, which relations the store may follow — and the format for
19
+ that is a JSON document with a JSON Schema inside it. Writing one by hand
20
+ means keeping a schema and a mapping vocabulary in step in the same file,
21
+ by eye. This pen is the schema pen with that vocabulary added to every
22
+ builder, so the mapping is a method on the member it belongs to and the
23
+ document is assembled for you.
24
+
25
+ ```js
26
+ import * as m from '@jarenjs/linq/model';
27
+ ```
28
+
29
+ builds `$model` 0.1 documents — the `jaren-model` grammar
30
+ `packages/db/schemas/jaren-model.schema.json` publishes, whose one
31
+ specification is
32
+ [MODEL-FORMAT.md](../../db/docs/MODEL-FORMAT.md) §2, §2.1 and §9 — and
33
+ `@jarenjs/db`'s `openStore(model, { driver })` accepts what this pen
34
+ emits unchanged. A model declares `entities`, `collections`, or both: an
35
+ entity is a JSON Schema whose members carry the `x-entity` mapping
36
+ vocabulary (MODEL-FORMAT §9.2), a collection is a document schema with a
37
+ key pointer and a list of indexes (§2, §2.1).
38
+
39
+ Four things are worth naming before the tables:
40
+
41
+ - **The pen is the schema pen, subclassed.** Every factory here answers a
42
+ builder of a NEW class — `withEntity(Base)` applied to each of the
43
+ eight schema-pen classes at module scope
44
+ (`packages/linq/src/model/index.js:20-28`), then handed to the same
45
+ `createFactories()` the schema pen builds its own names from
46
+ (`index.js:30-39`). Nothing is patched onto an imported prototype, so a
47
+ `./schema` consumer never carries an entity method: `typeof
48
+ s.string().key` is `undefined` and `m.string() instanceof
49
+ s.StringBuilder` is `true`, both asserted.
50
+ - **The schema stays a valid JSON Schema.** Strip every `x-entity` block
51
+ and the document accepts and rejects exactly the same values — the
52
+ vocabulary is invisible to a validator that does not know it, by the
53
+ same argument as `x-form` (MODEL-FORMAT §9.1). That is why §2's
54
+ re-exported rows link [SCHEMA-PEN.md](SCHEMA-PEN.md) rather than repeat
55
+ it: the member's schema half is the schema pen's, unchanged.
56
+ - **The engine is somewhere else.** Nothing under
57
+ `packages/linq/src/model/` imports `@jarenjs/db`, `@jarenjs/validate`
58
+ or `@jarenjs/emit` — a test asserts it file by file
59
+ (`test/linq/model-pen.test.js`, "no store behind it"). The pen refuses
60
+ only what it cannot spell and what the store's own model walk would
61
+ refuse and the builder can already see; inverse agreement, foreign-key
62
+ types and the rest stay `normalizeEntities`'s (`JD0005`, `JD0030`,
63
+ `JD0031`), never re-implemented, and §6 draws the line.
64
+ - **Immutability and identity are the binder's rules, and this pen keeps
65
+ them.** Every method answers a new builder, `.schema` assembles once
66
+ and memoizes, and `defineModel()` deep-freezes what it returns — stated
67
+ in full, for every pen, in [LINQ-FORMAT.md](LINQ-FORMAT.md) §1.2.
68
+
69
+ One complete round trip — declare, open, write, read back:
70
+
71
+ ```js
72
+ import * as m from '@jarenjs/linq/model';
73
+ import { openStore } from '@jarenjs/db';
74
+ import { nodeDriver } from '@jarenjs/db/node';
75
+
76
+ const model = m.defineModel({
77
+ entities: {
78
+ User: m.object({
79
+ id: m.string().identity('uuid'),
80
+ email: m.string().email().unique(),
81
+ created: m.datetime().now().optional(),
82
+ }),
83
+ },
84
+ });
85
+
86
+ const store = await openStore(model, { driver: nodeDriver() });
87
+ const ada = await store.entity('User').create({ email: 'ada@x.test' });
88
+ ada.id; // the store allocated it
89
+ ada.created; // the store stamped it
90
+ ```
91
+
92
+ **The running example.** The round trip above is one blog's store, and it
93
+ is the store §3 declares in full — the authors and their posts, the
94
+ labels on a post, the comments the store stamps and fills, the two kinds
95
+ of key, and the collections that hold what is not an entity. §5 opens the
96
+ same model through `typedStore` and reads its types back. One example in
97
+ §3 stands outside the blog and says so where it starts.
98
+
99
+ What the store then DOES with the document — the hybrid column mapping,
100
+ relations and referential integrity, identity, defaults, the relational
101
+ translation — is MODEL-FORMAT's, linked rather than restated here. What
102
+ the migration pen does with two of these is
103
+ [MIGRATION-PEN.md](MIGRATION-PEN.md).
104
+
105
+ ## 2. The mapping table
106
+
107
+ Every name `@jarenjs/linq/model` exports that a caller writes, and every
108
+ method reachable on a builder it hands back. The eight builder classes,
109
+ the one constant and the one guard it also exports are §5's, because a
110
+ caller meets those through a type annotation, a subclass or an
111
+ `instanceof` narrow rather than by calling one.
112
+
113
+ Status: **native** (emits the named member), **refused** (a coded error
114
+ naming the reason).
115
+
116
+ ### 2.1 The `x-entity` vocabulary
117
+
118
+ Every builder this pen hands back carries these, whatever its kind. They
119
+ merge into the ONE `x-entity` annotation, and its members keep the order
120
+ they were FIRST set in (`src/model/entity.js:46-49`, and the annotation
121
+ rule of `src/schema/emit.js:221`): setting one twice replaces the value
122
+ and keeps the position, which is why `.fill('x')` after `.identity('uuid')`
123
+ leaves `key` where it was and replaces `default`.
124
+
125
+ | Method | Emits (an `x-entity` member) | `InferMeta` reading | Status |
126
+ |---|---|---|---|
127
+ | `.key()` | `key: true` — (part of) the primary key; several members make a composite one (MODEL-FORMAT §9.5) | marks the member `key`: `EntityKey` is its primitive, or the composite object over all of them | native; on a kind that can hold no column, `JL0102` |
128
+ | `.identity('uuid')` on a string, `.identity('auto')` on an integer | `key: true` **and** `default: 'uuid' \| 'auto'` — a store-allocated single key (§9.5) | marks it `key` **and** `generated`: optional on `input`, required on `doc` | native; off its kind, or beside a second `key()`, `JL0102` |
129
+ | `.unique()` | `unique: true` — a unique index over the member's column. On an ARRAY builder the schema pen already owns the name, and the base wins: it is `uniqueItems` there, unchanged ([SCHEMA-PEN.md §2.3](SCHEMA-PEN.md#23-arrays-and-tuples)) | — | native; on a kind that can hold no column, `JL0102` |
130
+ | `.index()` | `index: true` — a non-unique index over the member's column | — | native; on a kind that can hold no column, `JL0102` |
131
+ | `.version()` | `version: true` — the optimistic-concurrency token (§11.5), engine-owned: one plain integer column per entity | — | native; off an integer, `JL0102` |
132
+ | `.column('integer')` | `column: 'integer'` — an epoch-milliseconds column beside the RFC 3339 string, on a `datetime()`/`date()` member only (§9.3) | — | native; off a date-formatted string, `JL0102` |
133
+ | `.column('json')` | `column: 'json'` — the scalar stays in the JSONB document, which is the opt-out that preserves present-`null` (§9.3) | — | native |
134
+ | `.now()` | `default: 'now'` — an RFC 3339 stamp on insert, when the member is absent | marks it `generated`: optional on `input` | native |
135
+ | `.updated()` | `default: 'updated'` — a stamp on insert AND on every update | marks it `generated` | native |
136
+ | `.fill(value)` | `default: { value }` — a literal, filled when absent; the value crosses the JSON boundary (`requireJson`) | marks it `generated` | native; a value that is not JSON is `JL0101` |
137
+ | `.compute(fn)`, `.compute(query)` | `default: { query }` — captured over the document being written (`$`), or a query document verbatim | marks it `generated` | native; a captured rule that binds ANY external is `JL0104` |
138
+ | `.renamedFrom(name)` | `x-rename: name` on the ENTITY (or collection) declaration — a planning hint the migration planner reads, never part of the shape (MIGRATION-FORMAT §3) | — | native on the declaration's own builder; on a member, `JL0102` (the document has no place for one) |
139
+ | `.meta(annotations)` | as the schema pen ([SCHEMA-PEN.md](SCHEMA-PEN.md#28-annotations-and-messages)), minus one key | — | refused (`JL0104`) for `x-entity`: the pen owns that keyword |
140
+ | `.entity(patch)` | the patch, merged into `x-entity` — the primitive every row above is written in terms of, and the way to spell a member of the vocabulary that has no method of its own | — (it sets no flag; the named methods do — §5.2) | native; a member outside the closed vocabulary, `JL0102` |
141
+
142
+ A member may carry several: `m.string().unique().index().identity('uuid')`
143
+ emits `{ unique: true, index: true, key: true, default: 'uuid' }`, in
144
+ that order. **Six of these rows are checked against the member's KIND**, each
145
+ mirroring a rule the store's own walk would raise later. What a builder
146
+ cannot see is its POSITION — a scalar nested inside an object takes no
147
+ column either — so §6's second list is what remains the store's.
148
+
149
+ ### 2.2 The relation members
150
+
151
+ `rel` is a frozen object of three factories, one per kind MODEL-FORMAT
152
+ §9.4 names. Each answers a builder of no type (`{}`) whose whole
153
+ document is its `x-entity.relation` block, and each is `optional()` by
154
+ construction — a relation member is a PROJECTION, never stored state, so
155
+ it never joins `required` (`src/model/relation.js:44-46`).
156
+
157
+ | Method | Emits | `InferMeta` reading | Status |
158
+ |---|---|---|---|
159
+ | `rel.hasMany(to, { via, onDelete })` | `relation: { to, many: true, via, onDelete }` — one-to-many; `via` names the foreign key on the TARGET entity | `doc`: `to[]`, optional; dropped from `input`; `relations[name] = { entity: to, doc, many: true }` | native |
160
+ | `rel.hasOne(to, { via, onDelete })` | `relation: { to, via, onDelete }` — one-to-one, and the many-to-one side; `via` names the foreign key on the DECLARING entity | `doc`: `to`, optional; dropped from `input`; `many: false` | native |
161
+ | `rel.belongsToMany(to, { through? })` | `relation: { to, many: true, through? }` — many-to-many through a join table | `doc`: `to[]`, optional; `input`: `Array<key \| doc>`, optional; `many: true` | native |
162
+
163
+ `onDelete` is REQUIRED on the two foreign-key kinds and never defaulted
164
+ silently (`relation.js:26-30`, MODEL-FORMAT §9.4); `belongsToMany` takes
165
+ neither, because a join row dies with either side and that is not
166
+ configurable in this version.
167
+
168
+ **The join table's endpoints come from the mapping, never from its
169
+ name.** `through` names the table; without it the store derives the
170
+ sorted `<A>_<B>`. Either way the mapping records which entity each column
171
+ references, which is what a rename follows (MIGRATION-FORMAT §3 — an
172
+ entity name may itself contain `_`, so splitting the table's name is
173
+ never the answer). `explainMapping()` on §3's second example returns:
174
+
175
+ ```json
176
+ {
177
+ "Label_Post": {
178
+ "left": { "entity": "Label", "column": "Label_key", "referencesKey": "name" },
179
+ "right": { "entity": "Post", "column": "Post_key", "referencesKey": "pid" },
180
+ "onDelete": "cascade"
181
+ },
182
+ "post_tags": {
183
+ "left": { "entity": "Post", "column": "Post_key", "referencesKey": "pid" },
184
+ "right": { "entity": "Tag", "column": "Tag_key", "referencesKey": "name" },
185
+ "onDelete": "cascade"
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### 2.3 Collections and their indexes
191
+
192
+ A collection is an entity whose every member is JSONB and which declares
193
+ no relations (MODEL-FORMAT §9.1); one physical engine sits under both.
194
+
195
+ | Method | Emits | Type reading | Status |
196
+ |---|---|---|---|
197
+ | `collection(schema, { key?, identity?, indexes?, renamedFrom? })` | `{ schema, key?, identity?, indexes?, 'x-rename'? }` — `key` is an RFC 6901 pointer, a captured member path (`(d) => d.id` → `/id`) or `null` (the store allocates, `identity` says how); the other options ride verbatim | `CollectionSpec<Infer<B>>`, carrying the document shape its paths are checked against | native; an option outside the four, a key that is neither pointer nor lambda nor `null`, an `indexes` that is not an array of `index()` entries, all `JL0101` |
198
+ | `index(path, options?)` | `{ name, path, unique?, derive?, precision?, dims?, physical? }` — `path` is a captured lambda (`(p) => p.cell` → `$.cell`), a non-empty array of them (a composite), or a JSONPath string; `name` defaults to `by_<segments>`; the rest ride verbatim for the store's model walk to judge (§2.1) | `IndexPath<D>` over the collection's shape: a member the shape lacks does not compile | native; an option outside the six is `JL0101`; a lambda that answers an operator result or a surface method is `JL0102` |
199
+
200
+ The option set is exactly `name`, `unique`, `derive`, `precision`,
201
+ `dims`, `physical` (`src/model/collection.js:18`); the default name is
202
+ `by_` plus the path's member segments, identifier-safe
203
+ (`collection.js:76-80`) — `by_series_t`, `by_x_y`.
204
+
205
+ ### 2.4 The model document
206
+
207
+ The three calls that assemble the whole thing — the document, the
208
+ collection declaration inside it, and the index declaration inside that.
209
+
210
+ | Method | Emits | Type reading | Status |
211
+ |---|---|---|---|
212
+ | `defineModel({ entities?, collections? })` | `{ $model: '0.1', collections?, entities? }`, deep-frozen; each entity is `{ schema, 'x-rename'? }` | `ModelDocument<E, C>`, whose phantoms `InferMeta<>` and the migration pen read | native; neither member given, a member outside the two, a name that is not an identifier, or a declaration of the wrong kind, all `JL0101`; an undeclared relation target or a `collection()` under `entities`, `JL0102` |
213
+ | `document(root, { draft? })` | as [SCHEMA-PEN.md](SCHEMA-PEN.md#210-the-document-and-the-builder-itself) — a standalone JSON Schema, with `x-entity` blocks riding as annotations. It writes a schema, never a `$model` | — | native |
214
+ | `schemaOf(value)` | as [SCHEMA-PEN.md](SCHEMA-PEN.md#210-the-document-and-the-builder-itself) — the document of a builder, or the value as given | `unknown` | native |
215
+ | `withEntity(Base)` | nothing: a NEW class, `Base` plus §2.1's vocabulary. The eight exported classes are made with it at module scope, and a consumer subclassing one takes the same route | `B` — the base class's own type | native |
216
+
217
+ `collections` is emitted before `entities` when both are given
218
+ (`src/model/define.js:65-120`), and every name is written with
219
+ `setObjectMember` so a member called `__proto__` is ordinary data
220
+ (LINQ-FORMAT §1.1, rule 5).
221
+
222
+ ### 2.5 The schema pen's vocabulary, re-exported
223
+
224
+ These are the schema pen's names, rebuilt from the entity-aware
225
+ subclasses. **What each emits, what it infers and what it refuses is
226
+ unchanged** — the row is [SCHEMA-PEN.md](SCHEMA-PEN.md)'s, and this table
227
+ links it rather than repeating it. The one difference is the class of the
228
+ builder that comes back: it carries §2.1's methods, so any of these can
229
+ be a key, an index, a column override or a store-written default.
230
+
231
+ This is the **grouped re-export row**, the row kind
232
+ [LINQ-FORMAT.md](LINQ-FORMAT.md) §5 defines: one row per family, linking
233
+ the schema pen's row rather than restating it, with a third column
234
+ carrying the one thing that IS different here. For this pen that column
235
+ is the status, because these builders gain behaviour — `x-entity` lands
236
+ on them the moment a §2.1 method is called. [FORMS-PEN.md
237
+ §2.1](FORMS-PEN.md#21-re-exported-unchanged--27-names) uses the same row
238
+ kind with the class in that column instead, because there nothing gains
239
+ behaviour and the class is the whole difference.
240
+
241
+ | Method | Row | Status |
242
+ |---|---|---|
243
+ | `string()`, `number()`, `integer()`, `boolean()`, `nil()`, `literal(v)`, `enumOf(values)`, `datetime()`, `date()`, `time()`, `duration()`, `any()`, `never()` | [SCHEMA-PEN.md §2.1](SCHEMA-PEN.md#21-primitives-literals-and-enums) | native, plus `x-entity` when a §2.1 method is called on it |
244
+ | `object(props)`, `record(values)` | [SCHEMA-PEN.md §2.2](SCHEMA-PEN.md#22-objects) | native; an entity's own builder is an `object()` (or an `intersection()` of them), and it is where `.renamedFrom()` lands |
245
+ | `array(items)`, `tuple(items)` | [SCHEMA-PEN.md §2.3](SCHEMA-PEN.md#23-arrays-and-tuples) | native; an array or tuple member is JSONB, so it takes no column of its own (§6) |
246
+ | `union(options)`, `discriminated(key, options)`, `intersection(parts)`, `when(cond)` | [SCHEMA-PEN.md §2.6](SCHEMA-PEN.md#26-composition) | native; a union of several scalar types stays in the document (MODEL-FORMAT §9.3) |
247
+ | `named(name, b)`, `ref(name)`, `lazy(thunk)`, `from(json)` | [SCHEMA-PEN.md §2.7](SCHEMA-PEN.md#27-references-and-defs) | native; `x-entity` is read through an entity's `allOf`, `$defs` and `definitions` blocks and nowhere deeper (MODEL-FORMAT §9.2) |
248
+
249
+ Every builder method the schema pen documents — `.optional()`,
250
+ `.open()`, `.min()`, `.format()`, `.check()`, `.describe()`, `.extend()`,
251
+ `.with()`, and the rest — is reachable here too and behaves identically;
252
+ [SCHEMA-PEN.md](SCHEMA-PEN.md) §2 is their one home (D4). `.with()` is
253
+ what keeps the subclass: `m.string().min(1).nullable().key` is still a
254
+ function.
255
+
256
+ **One name is in both vocabularies, and the schema pen's meaning wins.**
257
+ An array's `.unique()` is `uniqueItems: true`, a validation keyword; this
258
+ pen's is an entity index. On an array builder the base owns the name and
259
+ keeps it (`m.array(m.string()).unique().schema` is asserted equal to
260
+ `s.array(s.string()).unique().schema`), because a mixin may add to what a
261
+ document says and must never change it — and an array member takes no
262
+ column of its own in any case (§6).
263
+
264
+ ### 2.6 Three rules the tables imply
265
+
266
+ - **A relation target is a name, checked twice.**
267
+ `rel.hasMany('Post', …)` types `'Post'` as a member of `keyof
268
+ Entities` — `'Psot'` is a compile error — and `defineModel` refuses an
269
+ undeclared name at build time (`JL0102`, with the member's `docPath`).
270
+ Inverse agreement, foreign-key types and the join-table rules stay the
271
+ store's (`JD0031`, `JD0005`): two declarations that name the same `via`
272
+ and disagree are refused by `normalizeEntities`, not here.
273
+ - **An entity has no `indexes` option.** Its indexes are `unique()` and
274
+ `index()` per member — the vocabulary has no composite and no derived
275
+ entity index — so a `collection()` declaration handed to `entities` is
276
+ refused (`JL0102`) rather than emitted as a document the store would
277
+ reject.
278
+ - **`uuid()` is the format, `identity('uuid')` is the key.** The schema
279
+ pen's `.uuid()` writes `format: 'uuid'` here as everywhere; the
280
+ store-allocated key is `identity('uuid')`, MODEL-FORMAT §9.5's own
281
+ word. A member may carry both, side by side.
282
+
283
+ A declared relation member is also what the chain — the query pen —
284
+ navigates: over a store opened with this model,
285
+ `from(store.sync.entity('Post'))` reads `p.author.email` and
286
+ `u.posts.all().count()` as relation HOPS and lowers each to the
287
+ correlated phrase the engine and the store both run, so the query
288
+ document carries no relation name ([QUERY-PEN.md](QUERY-PEN.md) §3, §4
289
+ "relation navigation"). The table the chain reads is the one the store
290
+ derives from these members (`store.entity(name).relations`,
291
+ MODEL-FORMAT §10.1); a `rel.belongsToMany` member is the one kind it
292
+ refuses (`JL0105`), because its join table is not a queryable root in
293
+ this version.
294
+
295
+ ## 3. Worked examples
296
+
297
+ Every `js` fence below exports exactly one model, and the `json` fence
298
+ that follows it is the document the pen emits — executed by
299
+ `test/linq/pen-docs.test.js`, which imports each fence from the workspace
300
+ and asserts the document. Every model here also opens under the node
301
+ driver, normalizes through `normalizeModel`/`normalizeEntities` and
302
+ validates against `jaren-model.schema.json`.
303
+
304
+ One blog's store, declared piece by piece. Each fence is a complete model
305
+ on its own — that is what the gate runs — and read in order they are one
306
+ store's entities and then its collections: the authors and their posts,
307
+ the two ways posts carry labels, the comments with every store-written
308
+ default on them, the two kinds of key, and the collections that hold what
309
+ is not an entity. Only the last stands apart, and it says so.
310
+
311
+ The spine — MODEL-FORMAT §9.1's own entity, with both sides of one edge
312
+ declared:
313
+
314
+ ```js
315
+ import * as m from '@jarenjs/linq/model';
316
+
317
+ export const model = m.defineModel({
318
+ entities: {
319
+ User: m.object({
320
+ id: m.string().identity('uuid'),
321
+ email: m.string().email().unique(),
322
+ created: m.datetime().now().column('integer').index().optional(),
323
+ profile: m.object({}).open().optional(),
324
+ posts: m.rel.hasMany('Post', { via: 'authorId', onDelete: 'cascade' }),
325
+ }).open(),
326
+ Post: m.object({
327
+ pid: m.integer().identity('auto'),
328
+ authorId: m.string(),
329
+ author: m.rel.hasOne('User', { via: 'authorId', onDelete: 'cascade' }),
330
+ }).open(),
331
+ },
332
+ });
333
+ ```
334
+
335
+ ```json
336
+ {
337
+ "$model": "0.1",
338
+ "entities": {
339
+ "User": {
340
+ "schema": {
341
+ "type": "object",
342
+ "properties": {
343
+ "id": { "type": "string", "x-entity": { "key": true, "default": "uuid" } },
344
+ "email": { "type": "string", "format": "email", "x-entity": { "unique": true } },
345
+ "created": { "type": "string", "format": "date-time",
346
+ "x-entity": { "default": "now", "column": "integer", "index": true } },
347
+ "profile": { "type": "object" },
348
+ "posts": { "x-entity": { "relation": { "to": "Post", "many": true,
349
+ "via": "authorId", "onDelete": "cascade" } } }
350
+ },
351
+ "required": ["id", "email"]
352
+ }
353
+ },
354
+ "Post": {
355
+ "schema": {
356
+ "type": "object",
357
+ "properties": {
358
+ "pid": { "type": "integer", "x-entity": { "key": true, "default": "auto" } },
359
+ "authorId": { "type": "string" },
360
+ "author": { "x-entity": { "relation": { "to": "User",
361
+ "via": "authorId", "onDelete": "cascade" } } }
362
+ },
363
+ "required": ["pid", "authorId"]
364
+ }
365
+ }
366
+ }
367
+ }
368
+ ```
369
+
370
+ Both entities are `open()`, so neither carries `additionalProperties:
371
+ false`, and `posts` and `author` are absent from both `required` lists
372
+ without anyone writing `optional()` — a relation builder is optional by
373
+ construction. The two declarations are one edge (same `via`, one `many`
374
+ side and one `one` side, agreeing on `onDelete`), which is what
375
+ `normalizeEntities` checks and `JD0031` refuses.
376
+
377
+ A post carries labels two ways, and the difference is who names the join
378
+ table — the two many-to-many spellings, one implicit and one named:
379
+
380
+ ```js
381
+ import * as m from '@jarenjs/linq/model';
382
+
383
+ export const model = m.defineModel({
384
+ entities: {
385
+ Post: m.object({
386
+ pid: m.integer().identity('auto'),
387
+ labels: m.rel.belongsToMany('Label'),
388
+ tags: m.rel.belongsToMany('Tag', { through: 'post_tags' }),
389
+ }),
390
+ Label: m.object({ name: m.string().key() }),
391
+ Tag: m.object({ name: m.string().key() }),
392
+ },
393
+ });
394
+ ```
395
+
396
+ ```json
397
+ {
398
+ "$model": "0.1",
399
+ "entities": {
400
+ "Post": {
401
+ "schema": {
402
+ "type": "object",
403
+ "properties": {
404
+ "pid": { "type": "integer", "x-entity": { "key": true, "default": "auto" } },
405
+ "labels": { "x-entity": { "relation": { "to": "Label", "many": true } } },
406
+ "tags": { "x-entity": { "relation": { "to": "Tag", "many": true,
407
+ "through": "post_tags" } } }
408
+ },
409
+ "required": ["pid"],
410
+ "additionalProperties": false
411
+ }
412
+ },
413
+ "Label": {
414
+ "schema": {
415
+ "type": "object",
416
+ "properties": { "name": { "type": "string", "x-entity": { "key": true } } },
417
+ "required": ["name"],
418
+ "additionalProperties": false
419
+ }
420
+ },
421
+ "Tag": {
422
+ "schema": {
423
+ "type": "object",
424
+ "properties": { "name": { "type": "string", "x-entity": { "key": true } } },
425
+ "required": ["name"],
426
+ "additionalProperties": false
427
+ }
428
+ }
429
+ }
430
+ }
431
+ ```
432
+
433
+ The emitted document names no table: `labels` gets the derived
434
+ `Label_Post` and `tags` the declared `post_tags`, and both mappings
435
+ record which entity each column references (§2.2's `joinTables` block).
436
+ An implicit name is safe to derive and unsafe to parse — a rename follows
437
+ the endpoints, not the string.
438
+
439
+ A comment is where the store writes the most on your behalf: an entity
440
+ with every store-written default, and a rename hint for the migration
441
+ that follows:
442
+
443
+ ```js
444
+ import * as m from '@jarenjs/linq/model';
445
+
446
+ export const model = m.defineModel({
447
+ entities: {
448
+ Comment: m.object({
449
+ id: m.string().identity('uuid'),
450
+ text: m.string(),
451
+ slug: m.string().compute((d) => d.text.lower()).optional(),
452
+ created: m.datetime().now().optional(),
453
+ touched: m.datetime().updated().column('integer').optional(),
454
+ kind: m.string().enumOf(['memo', 'todo']).fill('memo').optional(),
455
+ weight: m.integer().fill(1).optional(),
456
+ }).renamedFrom('Remark'),
457
+ },
458
+ });
459
+ ```
460
+
461
+ ```json
462
+ {
463
+ "$model": "0.1",
464
+ "entities": {
465
+ "Comment": {
466
+ "schema": {
467
+ "type": "object",
468
+ "properties": {
469
+ "id": { "type": "string", "x-entity": { "key": true, "default": "uuid" } },
470
+ "text": { "type": "string" },
471
+ "slug": { "type": "string",
472
+ "x-entity": { "default": { "query": { "$lower": "$.text" } } } },
473
+ "created": { "type": "string", "format": "date-time",
474
+ "x-entity": { "default": "now" } },
475
+ "touched": { "type": "string", "format": "date-time",
476
+ "x-entity": { "default": "updated", "column": "integer" } },
477
+ "kind": { "type": "string", "enum": ["memo", "todo"],
478
+ "x-entity": { "default": { "value": "memo" } } },
479
+ "weight": { "type": "integer", "x-entity": { "default": { "value": 1 } } }
480
+ },
481
+ "required": ["id", "text"],
482
+ "additionalProperties": false
483
+ },
484
+ "x-rename": "Remark"
485
+ }
486
+ }
487
+ }
488
+ ```
489
+
490
+ `compute()`'s callback is not stored and never runs at write time: it
491
+ runs ONCE, at build, against the chain's recording proxy, leaving the
492
+ `{ $lower: '$.text' }` document above — the query the store evaluates
493
+ over the document being written. It sees `$` and nothing else, which is
494
+ why a second parameter is `JL0104` (§4.3). `x-rename` rides on the
495
+ ENTITY: `.renamedFrom()` writes builder state and `defineModel` lifts it
496
+ (`src/model/define.js:116`), and on a member it is refused (§4.2).
497
+
498
+ The store's two kinds of key, on a revision and on a follow — one
499
+ store-allocated, one composite, and what each does to
500
+ `required`:
501
+
502
+ ```js
503
+ import * as m from '@jarenjs/linq/model';
504
+
505
+ export const model = m.defineModel({
506
+ entities: {
507
+ Revision: m.object({
508
+ rid: m.integer().identity('auto'),
509
+ title: m.string(),
510
+ rev: m.integer().version().optional(),
511
+ }),
512
+ Follow: m.object({
513
+ follower: m.string().key(),
514
+ followed: m.string().key(),
515
+ score: m.integer().optional(),
516
+ }),
517
+ },
518
+ });
519
+ ```
520
+
521
+ ```json
522
+ {
523
+ "$model": "0.1",
524
+ "entities": {
525
+ "Revision": {
526
+ "schema": {
527
+ "type": "object",
528
+ "properties": {
529
+ "rid": { "type": "integer", "x-entity": { "key": true, "default": "auto" } },
530
+ "title": { "type": "string" },
531
+ "rev": { "type": "integer", "x-entity": { "version": true } }
532
+ },
533
+ "required": ["rid", "title"],
534
+ "additionalProperties": false
535
+ }
536
+ },
537
+ "Follow": {
538
+ "schema": {
539
+ "type": "object",
540
+ "properties": {
541
+ "follower": { "type": "string", "x-entity": { "key": true } },
542
+ "followed": { "type": "string", "x-entity": { "key": true } },
543
+ "score": { "type": "integer" }
544
+ },
545
+ "required": ["follower", "followed"],
546
+ "additionalProperties": false
547
+ }
548
+ }
549
+ }
550
+ }
551
+ ```
552
+
553
+ **`rid` is still in `required`, and that is deliberate.** The pen writes
554
+ the schema of a STORED document, and a stored ticket always has its key.
555
+ The exemption belongs to the write: a store-allocated key is allocated
556
+ after validation, so the store validates an insert with that member
557
+ dropped from `required` (MODEL-FORMAT §9.6), and the type says the same
558
+ from the other side — `identity('auto')` marks the member `generated`, so
559
+ it is optional on `EntityInput` and required on `EntityDoc` (§5.2).
560
+ Spelling it `optional()` to "fix" the emission would make the READ shape
561
+ wrong. `Follow` has no such member: a composite key is caller-supplied,
562
+ both parts are required, and `EntityKey` is
563
+ `{ follower: string; followed: string }`.
564
+
565
+ Not everything in the store is an entity. A collection is a document
566
+ schema with a key pointer, and the blog keeps two: one with a captured
567
+ key and one whose key the store allocates:
568
+
569
+ ```js
570
+ import * as m from '@jarenjs/linq/model';
571
+
572
+ export const model = m.defineModel({
573
+ collections: {
574
+ notes: m.collection(
575
+ m.object({ id: m.string(), body: m.string(), pinned: m.boolean().optional() }),
576
+ { key: (d) => d.id },
577
+ ),
578
+ log: m.collection(m.object({ line: m.string() }), { key: null, identity: 'integer' }),
579
+ },
580
+ });
581
+ ```
582
+
583
+ ```json
584
+ {
585
+ "$model": "0.1",
586
+ "collections": {
587
+ "notes": {
588
+ "schema": {
589
+ "type": "object",
590
+ "properties": {
591
+ "id": { "type": "string" },
592
+ "body": { "type": "string" },
593
+ "pinned": { "type": "boolean" }
594
+ },
595
+ "required": ["id", "body"],
596
+ "additionalProperties": false
597
+ },
598
+ "key": "/id"
599
+ },
600
+ "log": {
601
+ "schema": {
602
+ "type": "object",
603
+ "properties": { "line": { "type": "string" } },
604
+ "required": ["line"],
605
+ "additionalProperties": false
606
+ },
607
+ "key": null,
608
+ "identity": "integer"
609
+ }
610
+ }
611
+ }
612
+ ```
613
+
614
+ `(d) => d.id` is captured, checked to be a member path and rewritten as
615
+ the RFC 6901 pointer `/id` (`src/model/collection.js:116-129`, escaping
616
+ `~` and `/` as `~0`/`~1`) — a pointer string is accepted verbatim, and
617
+ `key: null` says the store allocates, with `identity` naming how.
618
+
619
+ **The one example here that is not part of the blog**, and why: an index
620
+ is only worth reading on members that show what the three kinds do, and
621
+ the blog has no coordinate and no embedding. This collection has both — a
622
+ composite, a geohash and a vector, over one collection:
623
+
624
+ ```js
625
+ import * as m from '@jarenjs/linq/model';
626
+
627
+ export const model = m.defineModel({
628
+ collections: {
629
+ places: m.collection(
630
+ m.object({
631
+ id: m.string(),
632
+ series: m.string(),
633
+ t: m.integer(),
634
+ loc: m.array(m.number()),
635
+ embedding: m.array(m.number()).length(4).optional(),
636
+ }),
637
+ {
638
+ key: '/id',
639
+ indexes: [
640
+ m.index([(p) => p.series, (p) => p.t]),
641
+ m.index((p) => p.loc, { name: 'by_cell', derive: 'geohash', precision: 7 }),
642
+ m.index((p) => p.embedding, { derive: 'vector', dims: 4 }),
643
+ ],
644
+ },
645
+ ),
646
+ },
647
+ });
648
+ ```
649
+
650
+ ```json
651
+ {
652
+ "$model": "0.1",
653
+ "collections": {
654
+ "places": {
655
+ "schema": {
656
+ "type": "object",
657
+ "properties": {
658
+ "id": { "type": "string" },
659
+ "series": { "type": "string" },
660
+ "t": { "type": "integer" },
661
+ "loc": { "type": "array", "items": { "type": "number" } },
662
+ "embedding": { "type": "array", "items": { "type": "number" },
663
+ "minItems": 4, "maxItems": 4 }
664
+ },
665
+ "required": ["id", "series", "t", "loc"],
666
+ "additionalProperties": false
667
+ },
668
+ "key": "/id",
669
+ "indexes": [
670
+ { "name": "by_series_t", "path": ["$.series", "$.t"] },
671
+ { "name": "by_cell", "path": "$.loc", "derive": "geohash", "precision": 7 },
672
+ { "name": "by_embedding", "path": "$.embedding", "derive": "vector", "dims": 4 }
673
+ ]
674
+ }
675
+ }
676
+ }
677
+ ```
678
+
679
+ The composite takes its default name from both segments
680
+ (`by_series_t`); the other two are named or derive it from the one
681
+ member. `derive`, `precision` and `dims` ride verbatim — the pen does not
682
+ know whether a driver has R\*Tree, and `normalizeModel` is the judge
683
+ (MODEL-FORMAT §2.1, §3.1).
684
+
685
+ ## 4. Refusals
686
+
687
+ The model pen raises these three `LinqBuildError` codes and no others —
688
+ `test/linq/pen-docs.test.js` holds this list equal, in both directions,
689
+ to the codes `packages/linq/src/model/` throws. The full condition each
690
+ code states across every pen is the binder's,
691
+ [LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
692
+
693
+ | Code | What this pen raises it for |
694
+ |---|---|
695
+ | `JL0101` | a value this pen cannot spell, an option it does not know, or a name → value map it cannot read |
696
+ | `JL0102` | a construct the format cannot carry, or a mapping the store's own walk would refuse and the builder can already see |
697
+ | `JL0104` | `x-entity` written through `meta()`, or an external a captured `compute()` rule named |
698
+
699
+ Every message below is the one the pen raised when the spelling beside it
700
+ was run, with the code prefix (`JL0101: `) removed. Where a row lists
701
+ several spellings, the message shown is the first one's — the shared
702
+ predicates interpolate the method name, so the others differ only in the
703
+ word the message opens with. `docPath`, where the refusal carries one, is
704
+ the JSON pointer of the node being assembled, and is appended to the
705
+ message text as well (`… at /entities/A`).
706
+
707
+ The schema pen's own refusals reach a caller here unchanged — a member
708
+ that is not a builder, a constraint given the wrong kind of value, a
709
+ `$defs` collision — and are
710
+ [SCHEMA-PEN.md](SCHEMA-PEN.md#4-refusals)'s rows, not repeated below.
711
+ This section is the vocabulary this pen adds.
712
+
713
+ ### 4.1 `JL0101` — the value, the option and the map
714
+
715
+ | The spelling that trips it | The message | The spelling that works |
716
+ |---|---|---|
717
+ | `m.string().column('text')` | `column() takes 'integer' (an epoch column for a date) or 'json' (stay in the document), got "text"` | `.column('integer')` or `.column('json')` |
718
+ | `m.string().identity('random')` | `identity() takes 'uuid' or 'auto', got "random"` | `.identity('uuid')` on a string, `.identity('auto')` on an integer |
719
+ | `m.string().entity(null)`, `m.string().entity([])` | `entity() takes a plain object of x-entity members` | a plain object |
720
+ | `m.string().fill(() => 1)` | `fill() received a function, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a JSON value |
721
+ | `m.rel.hasMany('Post', { via: 'authorId' })` | `rel.hasMany() must declare onDelete: 'cascade', 'restrict' or 'setNull' — a foreign key is never defaulted silently; got undefined` | `{ via: 'authorId', onDelete: 'cascade' }` |
722
+ | `m.rel.hasMany('Post', { via, onDelete, through })` | `rel.hasMany() does not take 'through'` | `through` belongs to `rel.belongsToMany()` |
723
+ | `m.rel.belongsToMany('Label', { via: 'x' })` | `rel.belongsToMany() does not take 'via'` | `{ through: 'post_tags' }`, or no options |
724
+ | `m.rel.hasMany('Post', null)` | `rel.hasMany() takes { via, onDelete }` | an options object |
725
+ | `m.rel.hasMany(42, { … })` | `rel.hasMany() takes a definition name (letters, digits, '_', '.', '-', not starting with a digit), got 42` | an entity name |
726
+ | `m.index(42)`, `m.index([])` | `index() takes a path lambda, a JSONPath string, or a non-empty array of them` | `m.index((p) => p.cell)` |
727
+ | `m.index([42])` | `index()[0] is neither a path lambda nor a JSONPath string` | a lambda or a path string per position |
728
+ | `m.index((p) => p.a, { derived: 'bbox' })` | `index() does not take 'derived' — the options are name, unique, derive, precision, dims, physical` | `{ derive: 'bbox' }` |
729
+ | `m.index((p) => p.a, null)` | `index() takes an options object` | `{}`, or no second argument |
730
+ | `m.index((p) => p.a, { name: '9 x' })` | `index() name takes a definition name (letters, digits, '_', '.', '-', not starting with a digit), got a string` | an identifier-shaped name |
731
+ | `m.collection(m.object({}), { key: 'id' })` | `collection() key is an RFC 6901 pointer, a member path lambda, or null` | `'/id'`, or `(d) => d.id` |
732
+ | `m.collection({}, {})` | `collection() takes a schema builder as its document schema` | a builder |
733
+ | `m.collection(b, { keys: [] })` | `collection() does not take 'keys' — the options are key, identity, indexes, renamedFrom` | `key` |
734
+ | `m.collection(b, { indexes: {} })` | `collection() indexes is an array of index() entries` | an array |
735
+ | `m.collection(b, { indexes: [{ path: '$.a' }] })` | `collection() indexes[0] is not an index() entry` | `m.index('$.a')` |
736
+ | `m.defineModel({})` | `defineModel() needs entities, collections, or both` | declare one of the two |
737
+ | `m.defineModel({ tables: {} })` | `defineModel() does not take 'tables'` | `entities` or `collections` |
738
+ | `m.defineModel({ entities: [] })` | `defineModel() entities is a plain object of declarations` | a plain object |
739
+ | `m.defineModel({ entities: { 'bad name': b } })` | `defineModel() entities names are identifiers, got 'bad name'` | `User` |
740
+ | `m.defineModel({ entities: { A: { schema: {} } } })` | `entities.A is not a schema builder` | a builder |
741
+ | `m.defineModel({ collections: { a: { schema: {} } } })` | `collections.a is not a collection() declaration` | `m.collection(…)` |
742
+
743
+ **The `__proto__` case.** It is the one refusal whose cause is invisible
744
+ in the source text, and this pen has two doors it reaches — the members
745
+ of an `object()`, and the names of `entities`/`collections`:
746
+
747
+ ```js
748
+ m.defineModel({ entities: { __proto__: m.object({ id: m.string().key() }) } })
749
+ // JL0101: defineModel() entities received a map whose prototype was
750
+ // replaced: a '__proto__:' key in an object literal sets the prototype
751
+ // instead of adding a member, so that member is not there to emit —
752
+ // spell it { ['__proto__']: … }, which is an own key
753
+ ```
754
+
755
+ The rule, the reason and the spelling that works are the binder's
756
+ ([LINQ-FORMAT.md](LINQ-FORMAT.md) §1.1, rule 5): a computed key is an own
757
+ property, and the emitted document carries it as an ordinary member
758
+ because `defineModel` writes through `setObjectMember`.
759
+
760
+ ### 4.2 `JL0102` — the construct, and the mapping the store would refuse
761
+
762
+ Raised either by the method (a mapping directive the builder can already
763
+ see is wrong) or by `defineModel` (a shape whose emitted form would mean
764
+ something else). Each of these has a twin in the store's own walk, and
765
+ the pen raises it earlier, at build, with the same meaning.
766
+
767
+ | The spelling that trips it | The message | The spelling that works |
768
+ |---|---|---|
769
+ | `m.string().column('integer')`, `m.integer().column('integer')` | `column('integer') applies to a date-time or date formatted string — the epoch column is derived from the RFC 3339 text; spell the member datetime() or date()` | `m.datetime().column('integer')` |
770
+ | `m.integer().identity('uuid')` | `identity('uuid') allocates a single string key — the member is a integer` | `m.string().identity('uuid')` |
771
+ | `m.string().identity('auto')` | `identity('auto') is allocated by the database for a single integer key only — the member is a string` | `m.integer().identity('auto')` |
772
+ | `m.number().identity('auto')` | `identity('auto') is allocated by the database for a single integer key only — the member is a number; spell it integer()` | `m.integer().identity('auto')` |
773
+ | `m.defineModel({ entities: { A: m.object({ a: m.integer().identity('auto'), b: m.string().key() }) } })` | `identity('auto') on A.a: a store-allocated key is a SINGLE key, and A declares a composite one (a, b)` — `docPath` `/entities/A/schema/properties/a/x-entity/default` | one `key()`, or a caller-supplied composite |
774
+ | `m.defineModel({ entities: { User: m.object({ posts: m.rel.hasMany('Psot', …) }), Post: … } })` | `relation target 'Psot' on User.posts is not a declared entity — the model declares 'User', 'Post'` — `docPath` `/entities/User/schema/properties/posts/x-entity/relation/to` | the declared name |
775
+ | `m.defineModel({ entities: { A: m.collection(b, { key: '/id', indexes: [] }) } })` | `entities.A is a collection() declaration — an entity has no key pointer and no indexes option: its key is key() on a member and its indexes are unique()/index() per member (the vocabulary has no composite or derived entity index)` — `docPath` `/entities/A` | put it under `collections`, or spell the entity as a builder |
776
+ | `m.object({}).key()`, `m.array(m.string()).index()`, `m.rel.hasMany(…).key()` | `key() applies to a member with a column of its own — this one's kind is 'object', and only a top-level scalar (string, number, integer, boolean, null) is column-mapped; everything else lives in the JSON document` | a top-level scalar member |
777
+ | `m.string().version()`, `m.number().version()` | `version() is the optimistic-concurrency token and lives in a plain integer column — this member's kind is 'string'; spell it integer()` | `m.integer().version()` |
778
+ | `m.string().entity({ bogus: true })` | `entity() writes the closed x-entity vocabulary (key, unique, index, default, column, relation, version); 'bogus' is not a member of it, and the store refuses one it cannot read rather than ignoring it (a mapping directive that is silently dropped loses data)` | a member of the vocabulary, or the method that spells it |
779
+ | `m.defineModel({ entities: { A: m.object({ id: m.string().key(), p: m.object({ x: m.string() }).renamedFrom('oldP') }) } })` | `renamedFrom() on entities.A.p is not written — $model 0.1 carries x-rename on an entity or a collection declaration, never on a member, so the hint would be lost and a rename the planner cannot see is a drop plus a create; put it on the declaration's own builder, or rename the member with a migration transform` — `docPath` `/entities/A` | the hint on the entity's own builder; a member is renamed by a migration `transform` |
780
+ | `m.index((p) => p.at)` | `index() answered a function, not a path — a member named like a surface method (\`at\`, \`get\`, \`all\`, …) is read with get('name'): (d) => d.get('at')` | `m.index((p) => p.get('at'))` |
781
+ | `m.index((p) => p.a.upper())` | `index() takes a member path ((d) => d.member); an operator result is not a path` | index the member; compute the value into one |
782
+ | `m.collection(b, { key: (d) => d.a.all() })` | `collection() key must select members by name ((d) => d.id), got the path $.a[*]` | `(d) => d.a` |
783
+
784
+ The surface-method case is worth its own sentence, because it is the one
785
+ that surprises: the capture proxy answers a real object, so a member
786
+ whose name collides with one of its methods (`at`, `get`, `all`, `count`,
787
+ …) reads as that method and the lambda returns a function rather than a
788
+ path. `get('name')` is the escape, and it is what the message names.
789
+
790
+ The column rows name the kinds the pen is CERTAIN about — `object`,
791
+ `record`, `array`, `tuple`, `enum`, `literal`, `any`, `never`, `union`,
792
+ `discriminated`, `when`. `from(json)`, `named()`, `ref()`, `lazy()` and
793
+ `intersection()` are deliberately absent: the store resolves a `$ref` and
794
+ merges an `allOf` before reading the type, so any may still be a scalar
795
+ and refusing one here would be an invention rather than a mirror. The
796
+ `renamedFrom` row's message names a builder-tree PATH at whatever depth
797
+ (`entities.A.p.items.q`) while `docPath` stays the declaration; a
798
+ `lazy()` thunk is never invoked by that walk, so a recursion terminates.
799
+
800
+ ### 4.3 `JL0104` — the keyword, and the external
801
+
802
+ | The spelling that trips it | The message | The spelling that works |
803
+ |---|---|---|
804
+ | `m.string().meta({ 'x-entity': { key: true } })` | `meta() cannot write 'x-entity' — the model pen owns that keyword; spell it through key(), identity(), unique(), index(), column(), now(), updated(), fill(), compute() or rel.*` | the method that emits it |
805
+ | `m.string().compute((d, x) => x.root.eq(1))` | `a compute() rule cannot bind 'root' — its query evaluates with no externals at all; anything else has nothing to bind to — it evaluates over the document being written, which its argument IS; there is nothing else to read` | read the document: `(d) => d.first.concat(d.last)` |
806
+ | `m.collection(User, { key: (d, x) => x.root })` | the same, `a collection() key rule cannot bind 'root'` | `(d) => d.id` |
807
+
808
+ The ownership is local to this pen. The schema pen does NOT own
809
+ `x-entity`, so `s.string().meta({ 'x-entity': { key: true } })` writes
810
+ the annotation happily — which is the honest behaviour there, since the
811
+ schema pen has no vocabulary to contradict. The refusal exists here
812
+ because this pen does, and a caller writing the block by hand would slip
813
+ past every check the methods perform.
814
+
815
+ `compute()`'s "no externals" is stricter than `check()`'s two: a default
816
+ is evaluated over the document being written, with no root and no path to
817
+ bind, so any second parameter names something that cannot exist.
818
+
819
+ ## 5. The types
820
+
821
+ ```ts
822
+ import * as m from '@jarenjs/linq/model';
823
+ import type { InferMeta } from '@jarenjs/linq/model';
824
+ import { openStore } from '@jarenjs/db';
825
+ import { typedStore } from '@jarenjs/db/typed';
826
+ import { nodeDriver } from '@jarenjs/db/node';
827
+
828
+ const model = m.defineModel({ entities: { User, Post } }); // §3's first model
829
+ const store = typedStore<InferMeta<typeof model>>(
830
+ await openStore(model, { driver: nodeDriver() }));
831
+
832
+ const users = await store.entity('User').load({ include: { posts: true } });
833
+ users[0].posts; // Post[] — widened by the include, no generate step
834
+ ```
835
+
836
+ ### 5.1 `InferMeta<>` is emit's map, derived
837
+
838
+ `InferMeta<M>` reads a model document's `__entities` phantom
839
+ (`packages/linq/types/model.d.ts:345-352`) and produces, per entity, the
840
+ four members `@jarenjs/db`'s `typedStore` takes: `doc`, `input`, `key`
841
+ and `relations`. The claim it makes is an equality, not a resemblance:
842
+ for the fixture model, `InferMeta<>` of the model rebuilt through this
843
+ pen is IDENTICAL — by the strict `Equals<>` test, not by assignability —
844
+ to the `EntityMetaMap` that `entityEmitModel` plus `@jarenjs/emit`
845
+ generate for the same document. `test/consumer/linq-model.ts` pins it
846
+ member by member and then as a whole, so a drift names its member.
847
+
848
+ Rule by rule, and each is a rule of emit's the declarations mirror:
849
+
850
+ - **Every entity interface is CLOSED**, nested shapes included, whatever
851
+ `.open()` said. `Unopen<T>` strips every index signature recursively
852
+ (`model.d.ts:285-289`) because `entityEmitModel` asks emit for
853
+ `openObjects: 'closed'`: the runtime validator stays the judge of a
854
+ stored document, and excess-property checking is the whole point of a
855
+ generated type.
856
+ - **A relation member is an optional reference.** `doc` carries
857
+ `to`/`to[]` optionally; `input` drops the to-one and to-many
858
+ projections entirely and types a many-to-many member as
859
+ `Array<key | doc>`, which is what `create()` accepts.
860
+ - **A date-formatted member is `DateTime` on `doc` and a plain `string`
861
+ on `input`** (`model.d.ts:303-305`). The brand discriminates
862
+ expressions; it never blocks a caller's literal.
863
+ - **`key` is the key member's primitive** — `string` or `number`, never
864
+ its literal union — **or the composite object** over every `key()`
865
+ member (`model.d.ts:317-327`).
866
+
867
+ ### 5.2 The two flags this pen adds
868
+
869
+ The schema pen's `Flag` is `'optional' | 'defaulted' | 'generated' |
870
+ 'key'`; the last two belong here (`packages/linq/types/schema.d.ts:37`,
871
+ and §5.1 of [SCHEMA-PEN.md](SCHEMA-PEN.md#51-the-phantoms-and-the-flags)
872
+ for the first two). `key()` and `identity()` set `key`; `identity()`,
873
+ `now()`, `updated()`, `fill()` and `compute()` set `generated`
874
+ (`model.d.ts:27-28`).
875
+
876
+ | | in the document (`doc`) | accepted by `create()` (`input`) |
877
+ |---|---|---|
878
+ | plain | required | required |
879
+ | `.optional()` | absent-able | absent-able |
880
+ | `.now()`, `.updated()`, `.fill()`, `.compute()` | required unless also `optional()` | **optional** — the store writes it |
881
+ | `.identity('uuid' \| 'auto')` | **required** | **optional** — the store allocates it |
882
+
883
+ That last row is §3's fourth example in type form: the key is in the
884
+ emitted `required` and in `EntityDoc`, and optional on `EntityInput`
885
+ alone.
886
+
887
+ The declarations narrow every kind-checked method to a receiver that can
888
+ carry it — `version()` to the integer builder, `column('integer')` to a
889
+ `DateTime`-typed one (`model.d.ts:66`), `identity('uuid')`/`'auto'` to
890
+ the string and integer builders, `key()`/`unique()`/`index()` to the
891
+ classes that can hold a column — and §4.2 is the runtime twin of each. A
892
+ strict consumer is stopped by the declaration, a JavaScript one by
893
+ `JL0102`. `.entity(patch)` is typed by `EntityBlock`, the closed
894
+ vocabulary MODEL-FORMAT §9.2 defines, so a member outside it neither
895
+ compiles nor builds; it carries no flag, because `key()`, `identity()`,
896
+ `fill()` and the rest are what `InferMeta<>` reads.
897
+
898
+ ### 5.3 The exported classes, the constant and the guard
899
+
900
+ Ten exports are surface a caller does not CALL, which is why none of them
901
+ is in §2:
902
+
903
+ | Export | What a caller meets it as |
904
+ |---|---|
905
+ | `EntityBuilder` | the base of every untyped kind; a type annotation, and what `rel.*` members are built from |
906
+ | `EntityStringBuilder`, `EntityNumberBuilder`, `EntityArrayBuilder`, `EntityTupleBuilder`, `EntityObjectBuilder`, `EntityWhenBuilder`, `EntityNeverBuilder` | the seven kinds with their own methods; annotations and `instanceof` narrows — `m.string() instanceof m.EntityStringBuilder` is `true` and `s.string() instanceof m.EntityStringBuilder` is `false` |
907
+ | `SCHEMA_BUILDER` | the brand key, re-exported from the schema pen: a `Symbol.for` registry symbol, so the chain and the store recognise a builder without importing this directory |
908
+ | `isSchemaBuilder(value)` | the guard that reads the brand; `collection()` and `defineModel()` are written on it |
909
+
910
+ All eight are made by `withEntity(Base)` at module scope, which is how
911
+ this pen exists at all: `createFactories()` is called once with the eight
912
+ subclasses, so the factory wiring is written once and no subpath patches
913
+ another's prototype. A consumer subclassing one takes the same route —
914
+ `with()` keeps the subclass through every method.
915
+
916
+ All ten are VALUES, exported at run time and declared as one — including
917
+ `EntityNeverBuilder`, whose declaration says what the entity vocabulary
918
+ does on it: `false` carries no keywords, so `entity()`, `key()` and the
919
+ rest raise `JL0102` (`identity()` raises `JL0101`) and none is declared;
920
+ `renamedFrom()` writes outside the schema and survives. `RelationBuilder`
921
+ is the one TYPE here — a relation member is a plain builder over an `any`
922
+ schema at run time, met through `rel.hasMany()`, `rel.hasOne()` and
923
+ `rel.belongsToMany()`. Importing it as a value does not compile, and
924
+ `test/linq/types.test.js` holds each pen's two export sets equal.
925
+
926
+ ### 5.4 What the pins hold
927
+
928
+ | File | What it proves |
929
+ |---|---|
930
+ | `test/consumer/linq-model.ts` | `InferMeta<>` of the fixture model EQUAL (not merely assignable) to the generated `EntityMetaMap`, member by member and whole; the `DateTime` brand on both sides; `typedStore` binding with no generate step; `load({ include })` widening the result; an index path checked against the collection's shape; and ten negatives |
931
+ | `test/linq/model-corpus.js` + `model-pen.test.js` | the runtime twin: every corpus model emits its hand-written document byte-equal, validates against `jaren-model.schema.json`, normalizes through the store's own model walk, opens under the node driver, round-trips a write through the defaults the pen declared, and hashes equal across two emissions |
932
+
933
+ The negatives are worth reading as a list of what the types forbid, since
934
+ each FAILS the build the day it starts compiling:
935
+
936
+ ```ts
937
+ void m.defineModel({ entities: { User: m.object({
938
+ posts: m.rel.hasMany('Psot', { via: 'authorId', onDelete: 'cascade' }) }) } });
939
+ void m.integer().identity('uuid'); // 'uuid' allocates a string key
940
+ void m.string().identity('auto'); // 'auto' is the database's, on an integer
941
+ void m.string().column('integer'); // the epoch column is a date's
942
+ void m.string().meta({ 'x-entity': { key: true } }); // owned here
943
+ void m.collection(Place, { indexes: [m.index((p) => p.nope)] }); // not a member
944
+ void m.defineModel({ entities: { Place: m.collection(Place, { indexes: [] }) } });
945
+ void (await import('@jarenjs/linq/schema')).string().key(); // no vocabulary there
946
+ ```
947
+
948
+ **An entity handle is a provider**, and that is the one place this pen
949
+ and the chain meet: `from(store.sync.entity('User'))` types its element
950
+ through the model pen's phantoms — the `doc` shape `InferMeta<>` derived
951
+ — so a chain over it reads `u.email` as a member and `u.posts` as a
952
+ relation hop ([QUERY-PEN.md](QUERY-PEN.md) §3, §4).
953
+
954
+ ## 6. What it cannot spell
955
+
956
+ The model pen's own limits are `JL0102`s (§4.2) and one absence. They
957
+ divide into two lists a reader must not conflate, because the two fail at
958
+ different times and a reader looking in the wrong one will hunt the wrong
959
+ error. A third list closes the section: the cases where the honest answer
960
+ is not to reach for this pen at all.
961
+
962
+ ### 6.1 What the format cannot carry — refused here, at build
963
+
964
+ - **A composite or derived index on an entity, or a key pointer.** The
965
+ vocabulary is `unique: true` and `index: true` per member, and nothing
966
+ else: no multi-member entity index, no `derive: 'geohash'`, no
967
+ `physical` choice, and no pointer key — an entity's key is `key()` on
968
+ the members that make it. All four belong to a collection (§2.3), so
969
+ the alternative is a collection, or a stored member the index can be
970
+ single-column over. It is why a `collection()` under `entities` is
971
+ refused by name rather than silently emitted.
972
+ - **An epoch column off a date.** `column('integer')` derives its value
973
+ from RFC 3339 text; there is nothing to derive from an integer or a
974
+ bare string. Spell the member `datetime()` or `date()`.
975
+ - **A store-allocated key beside a composite one.** `'uuid'` and
976
+ `'auto'` allocate ONE value; a composite key has no single member to
977
+ allocate. Supply the composite from the caller.
978
+ - **A relation to an entity the model does not declare.** The check is
979
+ the pen's because it is the one relation rule the pen can see: the
980
+ target is a name in the same document. Everything else about relations
981
+ is the store's.
982
+ - **A mapping directive on a kind that can hold no column.** `key()`,
983
+ `unique()` and `index()` need a column of their own and only a
984
+ top-level scalar gets one (MODEL-FORMAT §9.3); `version()` needs an
985
+ integer one. Put the directive on a scalar member — or, for a
986
+ collection, use an `index()` over the path (§2.3), which is the one
987
+ place a composite or derived index exists.
988
+ - **A `renamedFrom()` anywhere but a declaration's own builder.**
989
+ `$model` 0.1 carries `x-rename` on an entity or a collection and
990
+ nowhere else, so a hint on a member has nothing to be written into and
991
+ a rename the planner cannot see is a drop plus a create. Put it on the
992
+ declaration's builder; rename a member with a migration `transform`
993
+ ([MIGRATION-PEN.md](MIGRATION-PEN.md) §3).
994
+ - **An `x-entity` member outside the vocabulary.** The set is closed
995
+ (MODEL-FORMAT §9.2) because a silently ignored mapping directive is a
996
+ data-loss bug waiting to happen; `entity()` holds the same set.
997
+
998
+ ### 6.2 What the pen does not check, and the store does
999
+
1000
+ These emit happily and fail at `openStore` — the pen would have to
1001
+ re-implement a compile check, or know something a builder cannot see, to
1002
+ catch them:
1003
+
1004
+ - **Inverse agreement.** Two declarations naming the same `via` must be
1005
+ one `many` side and one `one` side and must agree on `onDelete`;
1006
+ contradictions are `JD0031` from `normalizeEntities`, not `JL0102` from
1007
+ here.
1008
+ - **POSITION.** A builder knows its kind, not where it is placed:
1009
+ `m.string().index()` is right as an entity member and wrong inside a
1010
+ nested object, where the store answers `JD0005`. The same holds for a
1011
+ type the pen cannot resolve — `from(json)`, `ref()`, `lazy()`,
1012
+ `named()` and `intersection()` are all accepted here (§4.2).
1013
+ - **COMBINATION.** `column('json')` beside a `key()`, a `version()` that
1014
+ is also the key or is column-mapped, two `version()` members in one
1015
+ entity, an entity with no `key()` at all — each is a fact about several
1016
+ members, and the store's walk is where the whole entity is in view.
1017
+ - **Anything about a `derive`, `precision`, `dims` or `physical`
1018
+ option.** They ride verbatim from `index()`; the model walk decides
1019
+ whether the driver can honour them.
1020
+ - **An `x-entity` block written through the schema pen's `annotate()`.**
1021
+ `entity()` holds the closed vocabulary and `meta()` refuses the keyword
1022
+ outright (§4.3), but `annotate('x-entity', …)` is the low-level
1023
+ primitive both are built on and writes what it is given. The store's
1024
+ `JD0030` is the backstop.
1025
+
1026
+ ### 6.3 When not to reach for this pen
1027
+
1028
+ - **The model is data.** A `$model` read from a file, fetched over the
1029
+ wire or produced by a tool is a value, and `openStore(model, …)` takes
1030
+ it as it stands. Nothing here has to be in the path.
1031
+ - **The database already exists and you are matching it.** Planning runs
1032
+ one way — the model describes what the store should build — and reading
1033
+ a model back out of a live database is open work, tracked in
1034
+ [docs/ROADMAP.md](../../../docs/ROADMAP.md) under the data pair
1035
+ ("Introspection of an existing database"). Until it lands, a model over
1036
+ a database somebody else made is a transcription, and a transcription
1037
+ is as easy to get wrong in code as in JSON.
1038
+ - **Nothing carries `x-entity`.** A collection takes a builder from any
1039
+ pen — `m.collection(s.object({ id: s.string() }), { key: '/id' })` is
1040
+ accepted and emits the same document — so a store of plain document
1041
+ collections with no keys, no relations and no column overrides needs
1042
+ the schema pen and `collection()`, not this whole subpath. §7 is what
1043
+ the difference costs.
1044
+ - **It is one entity in a test.** `{ $model: '0.1', entities: { A: {
1045
+ schema: … } } }` is shorter than the import. The pen earns its place at
1046
+ the point where a relation has two ends to keep in agreement, or a
1047
+ member's mapping and its schema are edited together.
1048
+ - **You want the store's own vocabulary and not the format's.** This pen
1049
+ writes `$model` 0.1 and refuses what MODEL-FORMAT §9.2 does not define.
1050
+ A directive the store would honour but the format has not published has
1051
+ no spelling here and should not get one — it belongs in the format
1052
+ first.
1053
+
1054
+ ## 7. Cost
1055
+
1056
+ `@jarenjs/linq/model` builds to **<!--fact:bundle.model-->40,857<!--/fact--> bytes** as a minified,
1057
+ tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
1058
+ measures and `npm run test:tree-shaking` reports, published rounded
1059
+ (<!--fact:bundle.model.kb-->41<!--/fact--> kB) beside the other nine subpath prices in
1060
+ [docs/CONSUMING.md](../../../docs/CONSUMING.md).
1061
+
1062
+ The probe is a gate, not a report: building a two-member model as a
1063
+ consumer would, it asserts four things and fails the build on any of
1064
+ them:
1065
+
1066
+ - **no chain module** — none of `sequence.js`, `document.js`, `async.js`,
1067
+ `concurrency.js`, `provider.js`, `sources.js` or `schema-of.js`
1068
+ contributes a byte;
1069
+ - **no engine and no store** — not one byte of `@jarenjs/json`,
1070
+ `@jarenjs/validate`, `@jarenjs/emit`, `@jarenjs/db`,
1071
+ `@jarenjs/formats` or `@jarenjs/refs`, the tree-shaken proof of §1;
1072
+ - **a ceiling** of 41,000 bytes;
1073
+ - **and the other direction** — the schema pen's own bundle carries no
1074
+ byte of `packages/linq/src/model/`, because the subclasses are built by
1075
+ this subpath rather than patched onto the base classes.
1076
+
1077
+ The price above the schema pen's <!--fact:bundle.schema-->32,427<!--/fact--> is about 8 kB: the mixin, the
1078
+ three relation factories, `collection()`/`index()` with their capture,
1079
+ `defineModel()` — and the refusal MESSAGES, which are most of what §4
1080
+ costs. That is a deliberate trade: naming the rule and the spelling that
1081
+ works is why a mapping mistake is a `JL0102` at build rather than a
1082
+ `JD0005` at `openStore`, so the ceiling is raised with the reason and the
1083
+ text is not shaved.