@jarenjs/linq 0.49.2 → 0.66.1

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