@jarenjs/linq 0.49.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +217 -0
- package/README.md +559 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +771 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +437 -36
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +231 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The graph builder: `include(pick, spec)` and the root clauses
|
|
4
|
+
* accumulate a `load` specification (MODEL-FORMAT §10.4, §10.5) — the
|
|
5
|
+
* client EMITS the spec the store already runs and runs nothing itself.
|
|
6
|
+
* The relation member is captured to its NAME; every callback is
|
|
7
|
+
* captured to a query expression over `$it` through the chain's own
|
|
8
|
+
* recording proxy — with no parameters, since a load clause binds no
|
|
9
|
+
* externals — and `toArray()` is `load(spec)`, `explain()` is
|
|
10
|
+
* `explainLoad(spec)`: the one-statement guarantee is the store's. A
|
|
11
|
+
* spec is plain JSON in a fixed member order (`where, orderBy, take,
|
|
12
|
+
* skip, after, maxDepth, include` at the root; `where, orderBy, take,
|
|
13
|
+
* skip, count, include` in an include — a keyset cursor paginates the
|
|
14
|
+
* root alone), so one graph is one document; `toJSON()` is that
|
|
15
|
+
* document, as a pen's is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { deepFreeze, setObjectMember } from '@jarenjs/core/object';
|
|
19
|
+
|
|
20
|
+
import { captureExpression } from '../expression.js';
|
|
21
|
+
import { LinqBuildError } from '../errors.js';
|
|
22
|
+
import { requireJson, describeValue } from '../json-boundary.js';
|
|
23
|
+
|
|
24
|
+
const NO_PARAMS = new Set();
|
|
25
|
+
const ROOT_KEYS = ['where', 'orderBy', 'take', 'skip', 'after', 'maxDepth', 'include'];
|
|
26
|
+
const INCLUDE_KEYS = ['where', 'orderBy', 'take', 'skip', 'count', 'include'];
|
|
27
|
+
/** A member path over the row: the shorthand or the bracketed spelling. */
|
|
28
|
+
const MEMBER_PATH = /^\$it(?:\.([A-Za-z_$][\w$]*)|\['((?:[^'\\]|\\.)*)'\])$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Capture one callback over the row bound as `it`.
|
|
32
|
+
* @param {any} fn
|
|
33
|
+
* @param {string} what - for the message
|
|
34
|
+
* @returns {any} the query expression
|
|
35
|
+
*/
|
|
36
|
+
function captureOver(fn, what) {
|
|
37
|
+
if (typeof fn !== 'function') {
|
|
38
|
+
throw new LinqBuildError('JL0101', `${what} takes a callback over the row, got ${describeValue(fn)}`);
|
|
39
|
+
}
|
|
40
|
+
return captureExpression(fn, ['it'], NO_PARAMS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The relation entry a member names, or the refusal naming the table.
|
|
45
|
+
* @param {Record<string, any>} relations
|
|
46
|
+
* @param {string} entityName
|
|
47
|
+
* @param {string} member
|
|
48
|
+
* @param {string} what
|
|
49
|
+
* @returns {{ member: string, entry: any }}
|
|
50
|
+
*/
|
|
51
|
+
function requireRelation(relations, entityName, member, what) {
|
|
52
|
+
const entry = relations[member];
|
|
53
|
+
if (entry === undefined) {
|
|
54
|
+
const declared = Object.keys(relations);
|
|
55
|
+
throw new LinqBuildError('JL0107',
|
|
56
|
+
`'${member}' is not a relation member of '${entityName}' — ${what} loads a declared relation`
|
|
57
|
+
+ (declared.length === 0 ? `, and '${entityName}' declares none`
|
|
58
|
+
: ` (${declared.map((name) => `'${name}'`).join(', ')})`));
|
|
59
|
+
}
|
|
60
|
+
return { member, entry };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The relation member a pick names: `(u) => u.posts` — or `u.get('posts')`
|
|
65
|
+
* for a name that collides with a method — captured to `$it.posts` and
|
|
66
|
+
* looked up in the relation table.
|
|
67
|
+
* @param {any} pick
|
|
68
|
+
* @param {Record<string, any>} relations
|
|
69
|
+
* @param {string} entityName
|
|
70
|
+
* @returns {{ member: string, entry: any }}
|
|
71
|
+
*/
|
|
72
|
+
function pickMember(pick, relations, entityName) {
|
|
73
|
+
const captured = captureOver(pick, 'include()');
|
|
74
|
+
const match = typeof captured === 'string' ? MEMBER_PATH.exec(captured) : null;
|
|
75
|
+
if (match === null) {
|
|
76
|
+
throw new LinqBuildError('JL0107',
|
|
77
|
+
`include() picks one relation member of '${entityName}' by name ((u) => u.posts); got `
|
|
78
|
+
+ (typeof captured === 'string' ? captured : 'an operator result'));
|
|
79
|
+
}
|
|
80
|
+
const member = match[1] ?? match[2].replace(/\\(.)/g, '$1');
|
|
81
|
+
return requireRelation(relations, entityName, member, 'include()');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One `$orderby` term: a bare key for a plain ascending order, else the
|
|
86
|
+
* `{ $key, $dir, $empty, $collation }` spec — as the chain spells it.
|
|
87
|
+
* @param {any} key - the key callback
|
|
88
|
+
* @param {boolean} desc
|
|
89
|
+
* @param {{ empty?: string, collation?: string } | undefined} options
|
|
90
|
+
* @param {string} what
|
|
91
|
+
* @returns {any}
|
|
92
|
+
*/
|
|
93
|
+
function orderTerm(key, desc, options, what) {
|
|
94
|
+
const expression = captureOver(key, what);
|
|
95
|
+
const spec = { $key: expression };
|
|
96
|
+
if (desc) spec.$dir = 'desc';
|
|
97
|
+
if (options !== undefined && options !== null) {
|
|
98
|
+
if (options.empty !== undefined) spec.$empty = requireJson(options.empty, `${what} empty`);
|
|
99
|
+
if (options.collation !== undefined) spec.$collation = requireJson(options.collation, `${what} collation`);
|
|
100
|
+
}
|
|
101
|
+
return Object.keys(spec).length === 1 ? expression : spec;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An include spec's `orderBy`: a key callback, `{ key, desc?, empty?,
|
|
106
|
+
* collation? }`, or an array of either.
|
|
107
|
+
* @param {any} orderBy
|
|
108
|
+
* @param {string} what
|
|
109
|
+
* @returns {any}
|
|
110
|
+
*/
|
|
111
|
+
function lowerOrder(orderBy, what) {
|
|
112
|
+
const one = (term, at) => {
|
|
113
|
+
if (typeof term === 'function') return orderTerm(term, false, undefined, at);
|
|
114
|
+
if (term !== null && typeof term === 'object' && !Array.isArray(term) && typeof term.key === 'function') {
|
|
115
|
+
return orderTerm(term.key, term.desc === true, term, at);
|
|
116
|
+
}
|
|
117
|
+
throw new LinqBuildError('JL0101',
|
|
118
|
+
`${at} takes a key callback ((p) => p.stars) or { key, desc?, empty?, collation? }, got ${describeValue(term)}`);
|
|
119
|
+
};
|
|
120
|
+
return Array.isArray(orderBy) ? orderBy.map((term, i) => one(term, `${what}[${i}]`)) : one(orderBy, what);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Lower one include: `true` (or nothing) includes the rows; an object
|
|
125
|
+
* carries the clauses the store's `load` reads, each callback captured,
|
|
126
|
+
* nested includes resolved against the TARGET's relation table.
|
|
127
|
+
* @param {any} spec
|
|
128
|
+
* @param {string} entityName - the target entity
|
|
129
|
+
* @param {(name: string) => Record<string, any> | undefined} relationsOf
|
|
130
|
+
* @param {string[]} path
|
|
131
|
+
* @returns {any}
|
|
132
|
+
*/
|
|
133
|
+
function lowerInclude(spec, entityName, relationsOf, path) {
|
|
134
|
+
if (spec === undefined || spec === true) return true;
|
|
135
|
+
const at = path.join('.');
|
|
136
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
137
|
+
// the member list is DERIVED from the same constant the check below
|
|
138
|
+
// reads: typed out, it named `after` — a member that check refuses —
|
|
139
|
+
// and a reader who believed the first message met the second
|
|
140
|
+
throw new LinqBuildError('JL0101',
|
|
141
|
+
`the include spec at ${at} is true or `
|
|
142
|
+
+ `{ ${INCLUDE_KEYS.map((key) => `${key}?`).join(', ')} }, got ${describeValue(spec)}`);
|
|
143
|
+
}
|
|
144
|
+
for (const key of Object.keys(spec)) {
|
|
145
|
+
if (!INCLUDE_KEYS.includes(key)) {
|
|
146
|
+
throw new LinqBuildError('JL0101',
|
|
147
|
+
`the include spec at ${at} does not take '${key}' — the members are ${INCLUDE_KEYS.join(', ')}`
|
|
148
|
+
+ (key === 'after' ? '; a keyset cursor paginates the root: after() on the graph' : ''));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const out = {};
|
|
152
|
+
if (spec.where !== undefined) out.where = captureOver(spec.where, `${at} where`);
|
|
153
|
+
if (spec.orderBy !== undefined) out.orderBy = lowerOrder(spec.orderBy, `${at} orderBy`);
|
|
154
|
+
for (const key of ['take', 'skip']) {
|
|
155
|
+
if (spec[key] !== undefined) out[key] = requireJson(spec[key], `${at} ${key}`);
|
|
156
|
+
}
|
|
157
|
+
if (spec.count !== undefined) {
|
|
158
|
+
if (spec.count !== true) throw new LinqBuildError('JL0101', `the include spec at ${at}: count takes true`);
|
|
159
|
+
out.count = true;
|
|
160
|
+
}
|
|
161
|
+
if (spec.include !== undefined) out.include = lowerIncludes(spec.include, entityName, relationsOf, path);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A nested include record — member → `true` | spec — over the target's
|
|
167
|
+
* relation table.
|
|
168
|
+
* @param {any} includes
|
|
169
|
+
* @param {string} entityName
|
|
170
|
+
* @param {(name: string) => Record<string, any> | undefined} relationsOf
|
|
171
|
+
* @param {string[]} path
|
|
172
|
+
* @returns {Record<string, any>}
|
|
173
|
+
*/
|
|
174
|
+
function lowerIncludes(includes, entityName, relationsOf, path) {
|
|
175
|
+
if (includes === null || typeof includes !== 'object' || Array.isArray(includes)) {
|
|
176
|
+
throw new LinqBuildError('JL0101',
|
|
177
|
+
`include at ${path.join('.')} is a record of relation members, got ${describeValue(includes)}`);
|
|
178
|
+
}
|
|
179
|
+
const relations = relationsOf(entityName) ?? {};
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const member of Object.keys(includes)) {
|
|
182
|
+
const { entry } = requireRelation(relations, entityName, member, 'a nested include');
|
|
183
|
+
setObjectMember(out, member, lowerInclude(includes[member], entry.to, relationsOf, [...path, member]));
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The graph over one entity set: an immutable builder of a `load` spec. */
|
|
189
|
+
export class Graph {
|
|
190
|
+
#set;
|
|
191
|
+
#entity;
|
|
192
|
+
#spec;
|
|
193
|
+
#tracking;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {any} set - the store's entity set
|
|
197
|
+
* @param {string} entity - its name
|
|
198
|
+
* @param {Record<string, any>} [spec] - the spec so far (`orderBy` as a list)
|
|
199
|
+
* @param {boolean} [tracking] - whether `toArray()` registers snapshots
|
|
200
|
+
*/
|
|
201
|
+
constructor(set, entity, spec = {}, tracking = true) {
|
|
202
|
+
this.#set = set;
|
|
203
|
+
this.#entity = entity;
|
|
204
|
+
this.#spec = spec;
|
|
205
|
+
this.#tracking = tracking;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param {Record<string, any>} patch */
|
|
209
|
+
#with(patch) {
|
|
210
|
+
return new Graph(this.#set, this.#entity, { ...this.#spec, ...patch }, this.#tracking);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The other roots' tables, where a nested include finds its target's. */
|
|
214
|
+
#relationsOf() {
|
|
215
|
+
const scoped = this.#set.scope?.relations;
|
|
216
|
+
return (name) => (scoped !== null && typeof scoped === 'object' ? scoped[name] : undefined);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Include one relation member, with an optional spec over its rows.
|
|
221
|
+
* @param {(u: any) => any} pick - `(u) => u.posts`
|
|
222
|
+
* @param {any} [spec] - `{ where?, orderBy?, take?, skip?, after?, count?, include? }`
|
|
223
|
+
* @returns {Graph}
|
|
224
|
+
*/
|
|
225
|
+
include(pick, spec) {
|
|
226
|
+
const { member, entry } = pickMember(pick, this.#set.relations, this.#entity);
|
|
227
|
+
const lowered = lowerInclude(spec, entry.to, this.#relationsOf(), [member]);
|
|
228
|
+
const include = { ...(this.#spec.include ?? {}) };
|
|
229
|
+
setObjectMember(include, member, lowered);
|
|
230
|
+
return this.#with({ include });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Filter the root rows; consecutive calls conjoin. @param {(it: any) => any} predicate */
|
|
234
|
+
where(predicate) {
|
|
235
|
+
const expression = captureOver(predicate, 'where()');
|
|
236
|
+
const previous = this.#spec.where;
|
|
237
|
+
if (previous === undefined) return this.#with({ where: expression });
|
|
238
|
+
const conjoined = previous !== null && typeof previous === 'object'
|
|
239
|
+
&& Object.keys(previous).length === 1 && Array.isArray(previous.$and)
|
|
240
|
+
? [...previous.$and, expression] : [previous, expression];
|
|
241
|
+
return this.#with({ where: { $and: conjoined } });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
245
|
+
orderBy(key, options) {
|
|
246
|
+
return this.#with({ orderBy: [orderTerm(key, false, options, 'orderBy()')] });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
250
|
+
orderByDescending(key, options) {
|
|
251
|
+
return this.#with({ orderBy: [orderTerm(key, true, options, 'orderByDescending()')] });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
255
|
+
thenBy(key, options) {
|
|
256
|
+
return this.#then(orderTerm(key, false, options, 'thenBy()'), 'thenBy');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
260
|
+
thenByDescending(key, options) {
|
|
261
|
+
return this.#then(orderTerm(key, true, options, 'thenByDescending()'), 'thenByDescending');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @param {any} term @param {string} what */
|
|
265
|
+
#then(term, what) {
|
|
266
|
+
if (this.#spec.orderBy === undefined) {
|
|
267
|
+
throw new LinqBuildError('JL0005',
|
|
268
|
+
`${what}() extends an orderBy()/orderByDescending() — none precedes it`);
|
|
269
|
+
}
|
|
270
|
+
return this.#with({ orderBy: [...this.#spec.orderBy, term] });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {number} count */
|
|
274
|
+
take(count) { return this.#with({ take: requireJson(count, 'take()') }); }
|
|
275
|
+
|
|
276
|
+
/** @param {number} count */
|
|
277
|
+
skip(count) { return this.#with({ skip: requireJson(count, 'skip()') }); }
|
|
278
|
+
|
|
279
|
+
/** The keyset cursor (§10.5). @param {string | number} cursor */
|
|
280
|
+
after(cursor) { return this.#with({ after: requireJson(cursor, 'after()') }); }
|
|
281
|
+
|
|
282
|
+
/** The include depth bound (§10.4). @param {number} depth */
|
|
283
|
+
maxDepth(depth) { return this.#with({ maxDepth: requireJson(depth, 'maxDepth()') }); }
|
|
284
|
+
|
|
285
|
+
/** The same graph, loaded without registering snapshots. */
|
|
286
|
+
asNoTracking() {
|
|
287
|
+
return new Graph(this.#set, this.#entity, this.#spec, false);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The document, as every pen answers it: the emitted spec. */
|
|
291
|
+
toJSON() {
|
|
292
|
+
return this.toSpec();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The emitted `load` spec: plain frozen JSON, a snapshot. */
|
|
296
|
+
toSpec() {
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const key of ROOT_KEYS) {
|
|
299
|
+
const value = this.#spec[key];
|
|
300
|
+
if (value === undefined) continue;
|
|
301
|
+
out[key] = key === 'orderBy' && value.length === 1 ? value[0] : value;
|
|
302
|
+
}
|
|
303
|
+
return deepFreeze(structuredClone(out));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `load(spec)` — the store's one statement. */
|
|
307
|
+
toArray() {
|
|
308
|
+
const spec = this.toSpec();
|
|
309
|
+
return this.#tracking ? this.#set.load(spec) : this.#set.asNoTracking().load(spec);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** `explainLoad(spec)` — the SQL, the includes, the pagination strategy. */
|
|
313
|
+
explain() {
|
|
314
|
+
return this.#set.explainLoad(this.toSpec());
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/db` — the store's front door. `open(model,
|
|
4
|
+
* options)` returns a client typed from the model pen whose entity
|
|
5
|
+
* handles chain (`client.entities.Post.where(…)` is `fromAsync(handle)
|
|
6
|
+
* .where(…)`, so every read is the chain's document and pushes down),
|
|
7
|
+
* load graphs from an EMITTED `load` spec (`include`), track and save
|
|
8
|
+
* changes, link and unlink many-to-many members and maintain live
|
|
9
|
+
* results — over exactly one runtime edge: this subpath imports
|
|
10
|
+
* `@jarenjs/db`, `@jarenjs/validate` and `@jarenjs/formats`, declared as
|
|
11
|
+
* optional peers; no other subpath of the package does, and the store
|
|
12
|
+
* never imports this package (the edge suite in the tests holds both).
|
|
13
|
+
* The store stays the engine: the client adds no storage semantics and
|
|
14
|
+
* duplicates no algorithm — `include` emits the spec the store runs,
|
|
15
|
+
* membership is the store's own `link`/`unlink`, `live` is the store's
|
|
16
|
+
* registration — and every read is one an `explain()` can name.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { open, defaultValidator } from './open.js';
|
package/src/db/live.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `live(source, options)`: a chain's document and bound externals —
|
|
4
|
+
* or a hand-written document — handed to the store's own registration
|
|
5
|
+
* (`store.live` for an entity-root chain, `collection.live` for a
|
|
6
|
+
* collection's), so the mode, the reason and the maintenance are the
|
|
7
|
+
* store's (LIVE-FORMAT §7): an entity document re-runs on invalidation,
|
|
8
|
+
* declared, and a store opened without capture refuses (`JD0050`)
|
|
9
|
+
* exactly as it does for a document. The chain's `params()` bindings are
|
|
10
|
+
* the externals, fixed at registration; `options.externals` adds to them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Whether a source is a chain: a document and an explanation to give. */
|
|
14
|
+
const isChain = (source) => source !== null && typeof source === 'object'
|
|
15
|
+
&& typeof source.toDocument === 'function' && typeof source.explain === 'function';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The document a source stands for, with the values it bound.
|
|
19
|
+
* @param {any} source - a chain (either surface) or a query document
|
|
20
|
+
* @returns {{ document: any, bindings: Record<string, unknown> }}
|
|
21
|
+
*/
|
|
22
|
+
function documentOf(source) {
|
|
23
|
+
if (!isChain(source)) return { document: source, bindings: {} };
|
|
24
|
+
const explained = source.explain();
|
|
25
|
+
// a chain split by a host callback has no document; its own refusal
|
|
26
|
+
// (`JL0005`) names why
|
|
27
|
+
if (explained.document === undefined) source.toDocument();
|
|
28
|
+
return { document: explained.document, bindings: explained.bindings };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register a live query through the store's own registration.
|
|
33
|
+
* @param {(document: any, options: any) => Promise<any>} register - the
|
|
34
|
+
* store's `live` (entity roots) or a collection's
|
|
35
|
+
* @param {any} source - a chain or a document
|
|
36
|
+
* @param {any} [options] - LIVE-FORMAT §7 options; `externals` merge over
|
|
37
|
+
* the chain's bindings
|
|
38
|
+
* @returns {Promise<any>} the store's live query
|
|
39
|
+
*/
|
|
40
|
+
export function registerLive(register, source, options = {}) {
|
|
41
|
+
const { document, bindings } = documentOf(source);
|
|
42
|
+
return register(document, { ...options, externals: { ...bindings, ...(options.externals ?? {}) } });
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `link`/`unlink` on a handle: the relation table decides. The
|
|
4
|
+
* member must be a many-to-many relation of the entity — `JL0107`
|
|
5
|
+
* otherwise, naming the kind it is or the members that are declared —
|
|
6
|
+
* and then the store's own `link`/`unlink` record the change
|
|
7
|
+
* (MODEL-FORMAT §11.7) for `saveChanges()` to write. The store would
|
|
8
|
+
* refuse the same members itself (`JD2003`); the client sees it earlier
|
|
9
|
+
* from the table it already reads, mirrored, never invented.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { LinqBuildError } from '../errors.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The many-to-many relation entry a membership operation names.
|
|
16
|
+
* @param {Record<string, any>} relations - the handle's relation table
|
|
17
|
+
* @param {string} entityName
|
|
18
|
+
* @param {string} member
|
|
19
|
+
* @param {string} verb - `link` or `unlink`, for the message
|
|
20
|
+
* @returns {any} the relation entry
|
|
21
|
+
*/
|
|
22
|
+
export function requireMembership(relations, entityName, member, verb) {
|
|
23
|
+
const entry = relations[member];
|
|
24
|
+
if (entry === undefined) {
|
|
25
|
+
const declared = Object.keys(relations).filter((name) => relations[name].kind === 'manyToMany');
|
|
26
|
+
throw new LinqBuildError('JL0107',
|
|
27
|
+
`'${member}' is not a relation member of '${entityName}' — ${verb}() attaches a many-to-many `
|
|
28
|
+
+ `membership${declared.length === 0 ? `, and '${entityName}' declares none`
|
|
29
|
+
: ` (${declared.map((name) => `'${name}'`).join(', ')})`}`);
|
|
30
|
+
}
|
|
31
|
+
if (entry.kind !== 'manyToMany') {
|
|
32
|
+
throw new LinqBuildError('JL0107',
|
|
33
|
+
`'${member}' is a ${entry.kind} relation of '${entityName}' — ${verb}() attaches many-to-many `
|
|
34
|
+
+ "memberships only; write the related entity's foreign key instead");
|
|
35
|
+
}
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
package/src/db/open.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `open(model, options)`: `openStore` with every option forwarded
|
|
4
|
+
* verbatim, plus `validator` — a `JarenValidator` wired as the store's
|
|
5
|
+
* `compileSchema`. The default validator reproduces the configuration
|
|
6
|
+
* MIGRATING-FROM-ZOD's recipe uses (`collectErrors`, the string and
|
|
7
|
+
* date-time formats registered), so `s.string().email()` asserts out
|
|
8
|
+
* of the box; a host that already has a `compileSchema` passes it and
|
|
9
|
+
* wins; `validator: null` opens the store unvalidated — the store's own
|
|
10
|
+
* declared downgrade (`capabilities.validated === false`), chosen by
|
|
11
|
+
* name, never by omission. The client is a frozen record of handles built ONCE at open
|
|
12
|
+
* from the names the store declares — no Proxy anywhere: an unknown
|
|
13
|
+
* name is `undefined`, and for a pen model a compile error.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { openStore } from '@jarenjs/db';
|
|
17
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
18
|
+
import { stringFormats, dateTimeFormats } from '@jarenjs/formats';
|
|
19
|
+
import { setObjectMember } from '@jarenjs/core/object';
|
|
20
|
+
|
|
21
|
+
import { createEntityHandle, createCollectionHandle } from './handle.js';
|
|
22
|
+
import { registerLive } from './live.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The validator the client compiles entity and collection schemas with
|
|
26
|
+
* when none is given: every issue collected, formats asserting.
|
|
27
|
+
* @returns {JarenValidator}
|
|
28
|
+
*/
|
|
29
|
+
export function defaultValidator() {
|
|
30
|
+
return new JarenValidator({ collectErrors: true })
|
|
31
|
+
.addFormats(stringFormats)
|
|
32
|
+
.addFormats(dateTimeFormats);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Open a store and front it.
|
|
37
|
+
* @param {any} model - a `$model` document: the model pen's, or JSON
|
|
38
|
+
* @param {any} options - `openStore`'s options (`driver` required), plus
|
|
39
|
+
* `validator?` (a `JarenValidator`, wired as `compileSchema` unless an
|
|
40
|
+
* explicit `compileSchema` is given; `null` for an unvalidated store)
|
|
41
|
+
* @returns {Promise<any>} the client
|
|
42
|
+
*/
|
|
43
|
+
export async function open(model, options) {
|
|
44
|
+
if (options === null || typeof options !== 'object') {
|
|
45
|
+
throw new TypeError('open needs { driver } from @jarenjs/db/node, /bun or /wasm');
|
|
46
|
+
}
|
|
47
|
+
const { validator, ...storeOptions } = options;
|
|
48
|
+
if (validator !== undefined && validator !== null
|
|
49
|
+
&& (typeof validator !== 'object' || typeof validator.compile !== 'function')) {
|
|
50
|
+
throw new TypeError('open: validator must be a JarenValidator (an object with compile(schema)), '
|
|
51
|
+
+ 'or null for an unvalidated store');
|
|
52
|
+
}
|
|
53
|
+
if (storeOptions.compileSchema === undefined && validator !== null) {
|
|
54
|
+
const jaren = validator ?? defaultValidator();
|
|
55
|
+
storeOptions.compileSchema = (schema) => jaren.compile(schema);
|
|
56
|
+
}
|
|
57
|
+
const store = await openStore(model, storeOptions);
|
|
58
|
+
const entities = {};
|
|
59
|
+
for (const name of store.roots ?? []) {
|
|
60
|
+
setObjectMember(entities, name, createEntityHandle(store, name));
|
|
61
|
+
}
|
|
62
|
+
const collections = {};
|
|
63
|
+
for (const name of Object.keys(model.collections ?? {})) {
|
|
64
|
+
setObjectMember(collections, name, createCollectionHandle(store, name));
|
|
65
|
+
}
|
|
66
|
+
/** @type {Record<string, any>} */
|
|
67
|
+
const client = {
|
|
68
|
+
store,
|
|
69
|
+
capabilities: store.capabilities,
|
|
70
|
+
entities: Object.freeze(entities),
|
|
71
|
+
collections: Object.freeze(collections),
|
|
72
|
+
transaction: (fn) => store.transaction(fn),
|
|
73
|
+
close: (closeOptions) => store.close(closeOptions),
|
|
74
|
+
};
|
|
75
|
+
// the unit of work and entity live queries exist exactly when the
|
|
76
|
+
// model declares entities — as on the store
|
|
77
|
+
if (store.saveChanges !== undefined) {
|
|
78
|
+
client.saveChanges = () => store.saveChanges();
|
|
79
|
+
client.live = (source, liveOptions) => registerLive(store.live, source, liveOptions);
|
|
80
|
+
}
|
|
81
|
+
return Object.freeze(client);
|
|
82
|
+
}
|