@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.
- package/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- 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.
|