@jarenjs/linq 0.49.2 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +227 -0
  2. package/README.md +650 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1221 -0
  5. package/docs/DB-CLIENT.md +882 -0
  6. package/docs/FLOW-PEN.md +1033 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +778 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1092 -0
  12. package/docs/QUERY-PEN.md +1724 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +251 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +255 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +377 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +48 -11
  24. package/src/contract/define.js +282 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +338 -0
  28. package/src/db/handle.js +89 -0
  29. package/src/db/include.js +351 -0
  30. package/src/db/index.js +24 -0
  31. package/src/db/ledger.js +195 -0
  32. package/src/db/live.js +43 -0
  33. package/src/db/membership.js +37 -0
  34. package/src/db/open.js +130 -0
  35. package/src/document.js +143 -13
  36. package/src/effect.js +65 -0
  37. package/src/errors.js +78 -6
  38. package/src/expression.js +463 -36
  39. package/src/federate.js +531 -0
  40. package/src/flow/capture.js +33 -0
  41. package/src/flow/dag.js +316 -0
  42. package/src/flow/fsm.js +323 -0
  43. package/src/flow/index.js +22 -0
  44. package/src/forms/index.js +43 -0
  45. package/src/forms/rules.js +170 -0
  46. package/src/forms/submit.js +177 -0
  47. package/src/index.js +5 -2
  48. package/src/jslt/body.js +226 -0
  49. package/src/jslt/index.js +18 -0
  50. package/src/jslt/rules.js +202 -0
  51. package/src/json-boundary.js +90 -0
  52. package/src/migration/define.js +318 -0
  53. package/src/migration/index.js +15 -0
  54. package/src/migration/steps.js +244 -0
  55. package/src/model/collection.js +273 -0
  56. package/src/model/define.js +125 -0
  57. package/src/model/entity.js +307 -0
  58. package/src/model/index.js +47 -0
  59. package/src/model/relation.js +85 -0
  60. package/src/provider.js +137 -20
  61. package/src/schema/brand.js +31 -0
  62. package/src/schema/builders.js +526 -0
  63. package/src/schema/check.js +29 -0
  64. package/src/schema/emit.js +394 -0
  65. package/src/schema/factories.js +239 -0
  66. package/src/schema/index.js +37 -0
  67. package/src/schema-of.js +24 -0
  68. package/src/sequence.js +233 -103
  69. package/src/sources.js +10 -3
  70. package/types/app.d.ts +293 -0
  71. package/types/contract.d.ts +468 -0
  72. package/types/db.d.ts +359 -0
  73. package/types/flow.d.ts +285 -0
  74. package/types/forms.d.ts +253 -0
  75. package/types/index.d.ts +296 -26
  76. package/types/jslt.d.ts +193 -0
  77. package/types/migration.d.ts +201 -0
  78. package/types/model.d.ts +526 -0
  79. package/types/schema.d.ts +494 -0
@@ -0,0 +1,307 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The `x-entity` vocabulary as builder methods: one mixin,
4
+ * `withEntity(Base)`, applied to every schema-pen class at module
5
+ * scope, so `./model` is a set of NEW classes (never a patched
6
+ * prototype) whose every builder can be a key, an index, a column
7
+ * override, a store-written default or a version token. The methods
8
+ * write into the one `x-entity` annotation; what the store does with
9
+ * it is MODEL-FORMAT §9's business, and the pen refuses only what the
10
+ * store's own model walk would refuse and the builder can already see
11
+ * — `identity('auto')` off an integer, `identity('uuid')` off a string,
12
+ * `column('integer')` off a date, `key()`/`unique()`/`index()` off a
13
+ * kind that can never hold a column, `version()` off an integer, and a
14
+ * member outside the closed vocabulary — with `JL0102` naming the rule.
15
+ */
16
+
17
+ import { LinqBuildError } from '../errors.js';
18
+ import { requireJson, requireName } from '../schema/builders.js';
19
+ import { captureQuery } from '../schema/check.js';
20
+
21
+ /** What a captured model default or member path evaluates over, for the
22
+ * `JL0104` a second callback argument raises: the document being
23
+ * written, which the callback's own first argument already is. The
24
+ * shared capture cannot say this — it holds for this pen and not for the
25
+ * others (`capture-root.js`). */
26
+ export const DOCUMENT_SCOPE = ' — it evaluates over the document being written, '
27
+ + 'which its argument IS; there is nothing else to read';
28
+
29
+ /** The keyword every entity method writes. */
30
+ const KEYWORD = 'x-entity';
31
+
32
+ /** The two storage overrides MODEL-FORMAT §9.2 names. */
33
+ const COLUMNS = new Set(['integer', 'json']);
34
+
35
+ /** The closed set MODEL-FORMAT §9.2 defines. An unknown member is the
36
+ * store's `JD0030` — a silently ignored mapping directive loses data —
37
+ * so `entity()` refuses it here rather than emitting it. */
38
+ const BLOCK = ['key', 'unique', 'index', 'default', 'column', 'relation', 'version'];
39
+
40
+ /**
41
+ * Kinds that can NEVER be a column of their own: the store maps a
42
+ * top-level scalar to a typed column and keeps everything else in the
43
+ * JSONB document (MODEL-FORMAT §9.3), so `key`/`unique`/`index` on one
44
+ * of these is `JD0005` at `openStore`. `raw`, `named`, `ref`, `lazy` and
45
+ * `intersection` are NOT here: the store resolves `$ref` and merges
46
+ * `allOf` before it reads the type, so any of them may still be a
47
+ * scalar and the pen cannot tell.
48
+ */
49
+ const NEVER_COLUMN = new Set([
50
+ 'object', 'record', 'array', 'tuple', 'enum', 'literal',
51
+ 'any', 'never', 'union', 'discriminated', 'when',
52
+ ]);
53
+
54
+ /** Whether a builder is a string with a date or date-time format. */
55
+ function isDateString(builder) {
56
+ const st = builder.state;
57
+ return st.kind === 'string'
58
+ && (st.keywords.format === 'date-time' || st.keywords.format === 'date');
59
+ }
60
+
61
+ /**
62
+ * The mapping directives that need a column: refused on a kind the
63
+ * store could never give one. The position is still the store's
64
+ * question — a scalar nested inside an object takes no column either,
65
+ * and the builder cannot see where it is placed.
66
+ * @param {any} builder
67
+ * @param {string} what - the method's name
68
+ */
69
+ function requireColumnable(builder, what) {
70
+ const kind = builder.state.kind;
71
+ if (NEVER_COLUMN.has(kind)) {
72
+ throw new LinqBuildError('JL0102',
73
+ `${what}() applies to a member with a column of its own — this one's kind is `
74
+ + `'${kind}', and only a top-level scalar (string, number, integer, boolean, null) `
75
+ + 'is column-mapped; everything else lives in the JSON document');
76
+ }
77
+ return builder;
78
+ }
79
+
80
+ /**
81
+ * The mixin: a subclass of `Base` carrying the entity methods.
82
+ * @template {new (state: any) => any} B
83
+ * @param {B} Base - a schema-pen builder class
84
+ * @returns {B} a new class, `Base` plus the vocabulary
85
+ */
86
+ export function withEntity(Base) {
87
+ return class extends Base {
88
+ /**
89
+ * Merge into the `x-entity` block, keeping the order members were
90
+ * first set in. The primitive every method below writes through, and
91
+ * the one door to a member of the vocabulary that has no method of
92
+ * its own — held to the same closed set the store reads.
93
+ * @param {Record<string, any>} patch
94
+ * @returns {this}
95
+ */
96
+ entity(patch) {
97
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
98
+ throw new LinqBuildError('JL0101',
99
+ 'entity() takes a plain object of x-entity members');
100
+ }
101
+ for (const member of Object.keys(patch)) {
102
+ if (!BLOCK.includes(member)) {
103
+ throw new LinqBuildError('JL0102',
104
+ `entity() writes the closed x-entity vocabulary (${BLOCK.join(', ')}); `
105
+ + `'${member}' is not a member of it, and the store refuses one it cannot read `
106
+ + 'rather than ignoring it (a mapping directive that is silently dropped loses data)');
107
+ }
108
+ }
109
+ const current = this.annotation(KEYWORD) ?? {};
110
+ return this.annotate(KEYWORD, Object.freeze({ ...current, ...patch }));
111
+ }
112
+
113
+ /** `key: true` — (part of) the primary key. */
114
+ key() { return requireColumnable(this, 'key').entity({ key: true }); }
115
+
116
+ /**
117
+ * `unique: true` — a unique index over the column. The ARRAY builder
118
+ * already owns this name (`uniqueItems`, a validation keyword), and a
119
+ * mixin must not change what a document asserts: where the base
120
+ * defines it, the base wins, and an array member takes no column of
121
+ * its own anyway.
122
+ */
123
+ unique() {
124
+ return typeof super.unique === 'function'
125
+ ? super.unique()
126
+ : requireColumnable(this, 'unique').entity({ unique: true });
127
+ }
128
+
129
+ /** `index: true` — a non-unique index over the column. */
130
+ index() { return requireColumnable(this, 'index').entity({ index: true }); }
131
+
132
+ /**
133
+ * `version: true` — the optimistic-concurrency token (§11.5): one
134
+ * plain integer column per entity, engine-owned. Whether it is also
135
+ * a key, a relation or column-mapped is a COMBINATION the store
136
+ * judges (`JD0005`); its kind is visible here.
137
+ */
138
+ version() {
139
+ if (this.state.kind !== 'integer') {
140
+ throw new LinqBuildError('JL0102',
141
+ 'version() is the optimistic-concurrency token and lives in a plain integer '
142
+ + `column — this member's kind is '${this.state.kind}'; spell it integer()`);
143
+ }
144
+ return this.entity({ version: true });
145
+ }
146
+
147
+ /**
148
+ * `column`: `'integer'` stores a date-formatted string as epoch
149
+ * milliseconds; `'json'` keeps a scalar in the document.
150
+ * @param {'integer' | 'json'} storage
151
+ */
152
+ column(storage) {
153
+ if (!COLUMNS.has(storage)) {
154
+ throw new LinqBuildError('JL0101',
155
+ `column() takes 'integer' (an epoch column for a date) or 'json' (stay in the `
156
+ + `document), got ${JSON.stringify(storage)}`);
157
+ }
158
+ if (storage === 'integer' && !isDateString(this)) {
159
+ throw new LinqBuildError('JL0102',
160
+ "column('integer') applies to a date-time or date formatted string — the epoch "
161
+ + 'column is derived from the RFC 3339 text; spell the member datetime() or date()');
162
+ }
163
+ return this.entity({ column: storage });
164
+ }
165
+
166
+ /**
167
+ * A store-allocated key (MODEL-FORMAT §9.5): `key: true` with
168
+ * `default: 'uuid'` (`crypto.randomUUID()` on a single string key)
169
+ * or `default: 'auto'` (the database allocates a single integer key).
170
+ * @param {'uuid' | 'auto'} kind
171
+ */
172
+ identity(kind) {
173
+ if (kind === 'uuid') {
174
+ if (this.state.kind !== 'string') {
175
+ throw new LinqBuildError('JL0102',
176
+ `identity('uuid') allocates a single string key — the member is a ${this.state.kind}`);
177
+ }
178
+ }
179
+ else if (kind === 'auto') {
180
+ if (this.state.kind !== 'integer') {
181
+ throw new LinqBuildError('JL0102',
182
+ "identity('auto') is allocated by the database for a single integer key only — the "
183
+ + `member is ${this.state.kind === 'number' ? 'a number; spell it integer()' : `a ${this.state.kind}`}`);
184
+ }
185
+ }
186
+ else {
187
+ throw new LinqBuildError('JL0101',
188
+ `identity() takes 'uuid' or 'auto', got ${JSON.stringify(kind)}`);
189
+ }
190
+ return this.entity({ key: true, default: kind });
191
+ }
192
+
193
+ /** `default: 'now'` — an RFC 3339 stamp on insert, when absent. */
194
+ now() { return this.entity({ default: 'now' }); }
195
+ /** `default: 'updated'` — a stamp on insert and on every update. */
196
+ updated() { return this.entity({ default: 'updated' }); }
197
+
198
+ /** `default: { value }` — a literal, filled when absent. @param {any} value */
199
+ fill(value) { return this.entity({ default: { value: requireJson(value, 'fill()') } }); }
200
+
201
+ /**
202
+ * `default: { query }` — evaluated over the document being written
203
+ * (`$` is that document; no externals): a captured callback, or a
204
+ * query document verbatim.
205
+ * @param {((doc: any, externals: any) => any) | object} rule
206
+ */
207
+ compute(rule) {
208
+ const query = typeof rule === 'function'
209
+ ? captureQuery('compute()', [], rule, { advice: () => DOCUMENT_SCOPE })
210
+ : requireJson(rule, 'compute()');
211
+ return this.entity({ default: { query } });
212
+ }
213
+
214
+ /**
215
+ * `x-rename`: this entity was previously named `name` (a migration
216
+ * hint the planner reads; lifted onto the entity declaration by
217
+ * `defineModel`). @param {string} name
218
+ */
219
+ renamedFrom(name) { return this.with({ renamedFrom: requireName(name, 'renamedFrom()') }); }
220
+
221
+ /** As in the schema pen, but `x-entity` is owned here. @param {Record<string, any>} annotations */
222
+ meta(annotations) {
223
+ if (annotations !== null && typeof annotations === 'object' && KEYWORD in annotations) {
224
+ throw new LinqBuildError('JL0104',
225
+ `meta() cannot write '${KEYWORD}' — the model pen owns that keyword; spell it `
226
+ + 'through key(), identity(), unique(), index(), column(), now(), updated(), '
227
+ + 'fill(), compute() or rel.*');
228
+ }
229
+ return super.meta(annotations);
230
+ }
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The builders one builder holds, as `[segment, child]` pairs. A
236
+ * `lazy()` thunk is NOT invoked: it exists to break a cycle, and calling
237
+ * it here would rebuild the recursion this walk is trying to finish.
238
+ * @param {any} state
239
+ * @returns {[string, any][]}
240
+ */
241
+ function childrenOf(state) {
242
+ switch (state.kind) {
243
+ case 'object': return [
244
+ ...state.props.map(([name, child]) => [name, child]),
245
+ ...state.patterns.map(([pattern, child]) => [`[${pattern}]`, child]),
246
+ ...(state.names === null ? [] : [['propertyNames', state.names]]),
247
+ ];
248
+ case 'array': return [
249
+ ['items', state.items],
250
+ ...(state.contains === null ? [] : [['contains', state.contains]]),
251
+ ];
252
+ case 'tuple': return [
253
+ ...state.items.map((child, i) => [`[${i}]`, child]),
254
+ ...(state.rest === null ? [] : [['rest', state.rest]]),
255
+ ];
256
+ case 'record': return [['values', state.values]];
257
+ case 'union': case 'discriminated': case 'intersection':
258
+ return state.options.map((child, i) => [`[${i}]`, child]);
259
+ case 'when': return [
260
+ ['if', state.cond],
261
+ ...(state.then === null ? [] : [['then', state.then]]),
262
+ ...(state.else === null ? [] : [['else', state.else]]),
263
+ ];
264
+ case 'named': return [[state.name, state.target]];
265
+ default: return [];
266
+ }
267
+ }
268
+
269
+ /**
270
+ * The path of the first DESCENDANT carrying a `renamedFrom` hint, or
271
+ * `null`. `$model` 0.1 puts `x-rename` on an entity or a collection
272
+ * declaration and nowhere else (MIGRATION-FORMAT §3), so a hint anywhere
273
+ * but the declaration's own builder is written by nobody — and a rename
274
+ * the planner never sees is a drop plus a create.
275
+ * @param {any} root - the declaration's builder; its OWN hint is lifted
276
+ * @returns {string | null}
277
+ */
278
+ export function strandedRename(root) {
279
+ const seen = new Set();
280
+ /** @type {[string, any][]} */
281
+ const queue = childrenOf(root.state).map(([segment, child]) => [segment, child]);
282
+ while (queue.length > 0) {
283
+ const [path, builder] = /** @type {any} */ (queue.shift());
284
+ if (builder === null || typeof builder !== 'object' || seen.has(builder)) continue;
285
+ seen.add(builder);
286
+ if (builder.state?.renamedFrom !== undefined) return path;
287
+ for (const [segment, child] of childrenOf(builder.state)) {
288
+ queue.push([segment.startsWith('[') ? `${path}${segment}` : `${path}.${segment}`, child]);
289
+ }
290
+ }
291
+ return null;
292
+ }
293
+
294
+ /**
295
+ * The refusal `defineModel()` and `collection()` share for a stranded
296
+ * hint (see `strandedRename`).
297
+ * @param {any} builder @param {string} what @param {string} [docPath]
298
+ */
299
+ export function refuseStrandedRename(builder, what, docPath = undefined) {
300
+ const path = strandedRename(builder);
301
+ if (path === null) return;
302
+ throw new LinqBuildError('JL0102',
303
+ `renamedFrom() on ${what}.${path} is not written — $model 0.1 carries x-rename on an `
304
+ + 'entity or a collection declaration, never on a member, so the hint would be lost and '
305
+ + "a rename the planner cannot see is a drop plus a create; put it on the declaration's "
306
+ + 'own builder, or rename the member with a migration transform', docPath);
307
+ }
@@ -0,0 +1,47 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `@jarenjs/linq/model` — the database by code. The schema pen's
4
+ * every name, rebuilt from SUBCLASSES that carry the `x-entity`
5
+ * vocabulary (`key()`, `identity()`, `unique()`, `index()`, `column()`,
6
+ * `now()`, `updated()`, `fill()`, `compute()`, `version()`,
7
+ * `renamedFrom()`), the relation members (`rel.*`), collections and
8
+ * their indexes, and `defineModel()` — one `$model` 0.1 document that
9
+ * `openStore` accepts unchanged. Nothing here imports `@jarenjs/db`.
10
+ */
11
+
12
+ import {
13
+ SchemaBuilder, StringBuilder, NumberBuilder, ArrayBuilder, TupleBuilder,
14
+ ObjectBuilder, WhenBuilder, NeverBuilder,
15
+ } from '../schema/builders.js';
16
+ import { createFactories } from '../schema/factories.js';
17
+ import { withEntity } from './entity.js';
18
+ import { createRelations } from './relation.js';
19
+
20
+ /** The entity-aware classes: new classes, one mixin, no patched prototype. */
21
+ export const EntityBuilder = withEntity(SchemaBuilder);
22
+ export const EntityStringBuilder = withEntity(StringBuilder);
23
+ export const EntityNumberBuilder = withEntity(NumberBuilder);
24
+ export const EntityArrayBuilder = withEntity(ArrayBuilder);
25
+ export const EntityTupleBuilder = withEntity(TupleBuilder);
26
+ export const EntityObjectBuilder = withEntity(ObjectBuilder);
27
+ export const EntityWhenBuilder = withEntity(WhenBuilder);
28
+ export const EntityNeverBuilder = withEntity(NeverBuilder);
29
+
30
+ export const {
31
+ string, number, integer, boolean, nil, literal, enumOf,
32
+ object, array, tuple, record, union, discriminated, intersection,
33
+ named, ref, lazy, any, never, when, from, document,
34
+ datetime, date, time, duration,
35
+ } = /** @type {any} */ (createFactories({
36
+ Base: EntityBuilder, String: EntityStringBuilder, Number: EntityNumberBuilder,
37
+ Array: EntityArrayBuilder, Tuple: EntityTupleBuilder, Object: EntityObjectBuilder,
38
+ When: EntityWhenBuilder, Never: EntityNeverBuilder,
39
+ }));
40
+
41
+ /** The relation members: `rel.hasMany`, `rel.hasOne`, `rel.belongsToMany`. */
42
+ export const rel = createRelations(EntityBuilder);
43
+
44
+ export { collection, index, expressionIndex } from './collection.js';
45
+ export { defineModel } from './define.js';
46
+ export { withEntity } from './entity.js';
47
+ export { isSchemaBuilder, schemaOf, SCHEMA_BUILDER } from '../schema/brand.js';
@@ -0,0 +1,85 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Relation members: `rel.hasMany`, `rel.hasOne`, `rel.belongsToMany`
4
+ * — each a builder of no type (`{}`) whose `x-entity.relation` block is
5
+ * the whole member, spelled exactly as MODEL-FORMAT §9.4's three kinds.
6
+ * A relation member is a projection, never stored state, so every
7
+ * relation builder is `optional()` by construction. The target is an
8
+ * entity NAME; whether it is declared is `defineModel`'s question.
9
+ */
10
+
11
+ import { LinqBuildError } from '../errors.js';
12
+ import { initial, requireName } from '../schema/builders.js';
13
+
14
+ const ON_DELETE = new Set(['cascade', 'restrict', 'setNull']);
15
+
16
+ /**
17
+ * @param {any} options
18
+ * @param {string} what
19
+ * @returns {{ via: string, onDelete: string }}
20
+ */
21
+ function foreignKeyOptions(options, what) {
22
+ if (options === null || typeof options !== 'object') {
23
+ throw new LinqBuildError('JL0101', `${what} takes { via, onDelete }`);
24
+ }
25
+ const via = requireName(options.via, `${what} via`);
26
+ if (!ON_DELETE.has(options.onDelete)) {
27
+ throw new LinqBuildError('JL0101',
28
+ `${what} must declare onDelete: 'cascade', 'restrict' or 'setNull' — a foreign key is `
29
+ + `never defaulted silently; got ${JSON.stringify(options.onDelete)}`);
30
+ }
31
+ for (const key of Object.keys(options)) {
32
+ if (key !== 'via' && key !== 'onDelete') {
33
+ throw new LinqBuildError('JL0101', `${what} does not take '${key}'`);
34
+ }
35
+ }
36
+ return { via, onDelete: options.onDelete };
37
+ }
38
+
39
+ /**
40
+ * The relation factories for one builder class.
41
+ * @param {any} Base - the class a relation member is built from
42
+ */
43
+ export function createRelations(Base) {
44
+ const member = (relation) => new Base(initial('any', {}))
45
+ .annotate('x-entity', Object.freeze({ relation: Object.freeze(relation) }))
46
+ .with({ optional: true });
47
+ return Object.freeze({
48
+ /**
49
+ * One-to-many: `{ to, many: true, via, onDelete }` — `via` names the
50
+ * foreign key on the TARGET entity.
51
+ * @param {string} to @param {{ via: string, onDelete: 'cascade' | 'restrict' | 'setNull' }} options
52
+ */
53
+ hasMany(to, options) {
54
+ const { via, onDelete } = foreignKeyOptions(options, 'rel.hasMany()');
55
+ return member({ to: requireName(to, 'rel.hasMany()'), many: true, via, onDelete });
56
+ },
57
+ /**
58
+ * One-to-one (and the many-to-one side): `{ to, via, onDelete }` —
59
+ * `via` names the foreign key on the DECLARING entity.
60
+ * @param {string} to @param {{ via: string, onDelete: 'cascade' | 'restrict' | 'setNull' }} options
61
+ */
62
+ hasOne(to, options) {
63
+ const { via, onDelete } = foreignKeyOptions(options, 'rel.hasOne()');
64
+ return member({ to: requireName(to, 'rel.hasOne()'), via, onDelete });
65
+ },
66
+ /**
67
+ * Many-to-many: `{ to, many: true, through? }` — a join table, named
68
+ * `through` or the sorted `<A>_<B>`.
69
+ * @param {string} to @param {{ through?: string }} [options]
70
+ */
71
+ belongsToMany(to, options = {}) {
72
+ if (options === null || typeof options !== 'object') {
73
+ throw new LinqBuildError('JL0101', 'rel.belongsToMany() takes { through? }');
74
+ }
75
+ for (const key of Object.keys(options)) {
76
+ if (key !== 'through') {
77
+ throw new LinqBuildError('JL0101', `rel.belongsToMany() does not take '${key}'`);
78
+ }
79
+ }
80
+ const relation = { to: requireName(to, 'rel.belongsToMany()'), many: true };
81
+ if (options.through !== undefined) relation.through = requireName(options.through, 'rel.belongsToMany() through');
82
+ return member(relation);
83
+ },
84
+ });
85
+ }
package/src/provider.js CHANGED
@@ -1,31 +1,70 @@
1
1
  //@ts-check
2
2
  /**
3
3
  * @file The provider seam (D2): a provider is any object exposing
4
- * `execute(queryDocument, options)` — contract-level coupling, never an
5
- * import edge. `@jarenjs/db` will implement this interface without
6
- * either package importing the other. The in-memory runner implements
4
+ * `execute(queryDocument, options)` — contract-level coupling: the
5
+ * chain imports no provider. `@jarenjs/db` implements this interface;
6
+ * the package's one runtime edge — the client subpath `./db` runs
7
+ * the other way, toward the store, as declared optional peers, and the
8
+ * store never imports this package. The in-memory runner implements
7
9
  * the SAME interface over an iterable, so it is both the reference
8
10
  * semantics every other provider must match and the proof the seam is
9
11
  * real.
10
12
  *
11
13
  * Compiled documents are shared through a bounded LRU keyed by the
12
- * document's COLLISION-FREE structural identity, one cache per REGISTRY
13
- * identitythe hooks change what compiles, so two different registries
14
- * must not share compiled programs.
15
- * A fingerprint would not do: a 32-bit content hash collides after tens
16
- * of thousands of documents, and a collision here runs one query's
17
- * compiled program for another query's document — silently wrong rows.
14
+ * document's COLLISION-FREE, ORDER-SENSITIVE identity its exact JSON
15
+ * textone cache per REGISTRY identity, because the hooks change what
16
+ * compiles, so two different registries must not share compiled
17
+ * programs. A fingerprint would not do: a 32-bit content hash collides
18
+ * after tens of thousands of documents, and a collision here runs one
19
+ * query's compiled program for another query's document — silently
20
+ * wrong rows. Nor would an order-insensitive identity: a constructor's
21
+ * member order is part of a document's meaning (`{ id, name }` and
22
+ * `{ name, id }` project different objects), and a cache that keyed them
23
+ * as one answered the second projection in the first one's order.
18
24
  */
19
25
 
20
26
  import { compileJsonQuery, JsonQueryCompileError } from '@jarenjs/json/query';
21
- import { createSemanticCache, createWeakCache } from '@jarenjs/core/cache';
27
+ import { createBoundedCache, createWeakCache } from '@jarenjs/core/cache';
22
28
  import { LinqBuildError } from './errors.js';
23
29
 
24
30
  /** Stands in for an absent hook while walking the identity chain. */
25
31
  const NO_HOOK = Object.freeze({});
26
32
 
27
33
  const CACHES = createWeakCache();
28
- const cacheFor = () => createSemanticCache(512);
34
+ const cacheFor = () => createBoundedCache(512);
35
+
36
+ /**
37
+ * The cache identity of one compilation: the document's exact JSON text
38
+ * beside the declared externals and the limits. `null` when the value
39
+ * cannot be keyed injectively — a function, an `undefined`, a symbol, a
40
+ * bigint, a non-finite number, `-0`, or a class instance whose `toJSON`
41
+ * would otherwise stand in for it — which is a permanent miss, never
42
+ * someone else's entry.
43
+ * @param {any} document
44
+ * @param {readonly string[]} externals
45
+ * @param {any} limits
46
+ * @returns {string | null}
47
+ */
48
+ function compilationKey(document, externals, limits) {
49
+ try {
50
+ return JSON.stringify([document, externals, limits ?? null], function (key, value) {
51
+ const raw = this[key];
52
+ const type = typeof raw;
53
+ if (type === 'function' || type === 'undefined' || type === 'symbol' || type === 'bigint'
54
+ || (type === 'number' && (!Number.isFinite(raw) || Object.is(raw, -0)))) {
55
+ throw new TypeError('unkeyable');
56
+ }
57
+ if (raw !== null && type === 'object' && !Array.isArray(raw)) {
58
+ const proto = Object.getPrototypeOf(raw);
59
+ if (proto !== Object.prototype && proto !== null) throw new TypeError('unkeyable');
60
+ }
61
+ return value;
62
+ });
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
29
68
 
30
69
  /** The root of the hook-identity chain. */
31
70
  const REGISTRY_IDS = createWeakCache();
@@ -103,10 +142,7 @@ function registryIdentity(options) {
103
142
  */
104
143
  export function compileDocument(document, options) {
105
144
  const cache = /** @type {any} */ (CACHES.getOrCreate(registryIdentity(options), cacheFor));
106
- // the externals and the limits are part of the identity: the same
107
- // document compiles differently against a different set of declared
108
- // names, and differently again under a step or result bound
109
- return cache.getOrCreate([document, options.externals, options.limits ?? null], () => {
145
+ const compile = () => {
110
146
  try {
111
147
  return compileJsonQuery(document, {
112
148
  ...compileOptionsOf(options),
@@ -123,7 +159,91 @@ export function compileDocument(document, options) {
123
159
  }
124
160
  throw err;
125
161
  }
126
- });
162
+ };
163
+ // the externals and the limits are part of the identity: the same
164
+ // document compiles differently against a different set of declared
165
+ // names, and differently again under a step or result bound
166
+ const key = compilationKey(document, options.externals, options.limits);
167
+ return key === null ? compile() : cache.getOrCreate(key, compile);
168
+ }
169
+
170
+ /**
171
+ * Whether a source is a provider: an `execute` duck. The one dispatch
172
+ * both surfaces share — on `fromAsync` it is asked BEFORE the iterable
173
+ * shapes, so a provider that also happens to be iterable is still a
174
+ * provider.
175
+ * @param {any} source
176
+ * @returns {boolean}
177
+ */
178
+ export function isProviderSource(source) {
179
+ return source !== null && typeof source === 'object'
180
+ && typeof (/** @type {any} */ (source).execute) === 'function';
181
+ }
182
+
183
+ /**
184
+ * The root expression a provider's items are bound through (QUERY-PEN
185
+ * §8): its `root` (`'$.Post[*]'` for an entity set), or `'$[*]'` when it
186
+ * names none — the whole input, a collection. A provider that serves
187
+ * SEVERAL roots and none of its own (a store with entities: `roots`) has
188
+ * nothing a chain could iterate — `$[*]` over the entity map would
189
+ * answer the rows of every entity, mixed, or count the SETS — so it is
190
+ * refused here, at `from()` time, naming the roots to chain over.
191
+ * @param {any} provider
192
+ * @returns {string}
193
+ */
194
+ export function providerRoot(provider) {
195
+ const root = provider.root;
196
+ if (root === undefined || root === null) {
197
+ const roots = provider.roots;
198
+ if (Array.isArray(roots) && roots.length > 0) {
199
+ throw new LinqBuildError('JL0007',
200
+ `this provider serves entity roots ${roots.join(', ')} and has no root of its own — `
201
+ + 'chain over one of them: from(store.entity(name)) (QUERY-PEN.md §8)');
202
+ }
203
+ return '$[*]';
204
+ }
205
+ if (typeof root !== 'string' || root === '') {
206
+ throw new LinqBuildError('JL0005',
207
+ `a provider's root is a path expression string ('$.Post[*]'), got ${typeof root}`);
208
+ }
209
+ return root;
210
+ }
211
+
212
+ /**
213
+ * The relation context a provider offers (QUERY-PEN §8): its own
214
+ * relation table — `relations`, keyed by member (MODEL-FORMAT §10.1) —
215
+ * and a resolver for the tables of the other roots of its scope
216
+ * (`scope.relations`, keyed by root name: one store's entity sets), so
217
+ * a hop can continue into another root. `null` when the provider
218
+ * carries no table: a relation name is then an ordinary member.
219
+ * @param {any} provider
220
+ * @returns {{ table: any, resolve: (name: string) => any } | null}
221
+ */
222
+ export function providerRelations(provider) {
223
+ const table = provider.relations;
224
+ if (table === null || typeof table !== 'object') return null;
225
+ const scope = provider.scope;
226
+ const scoped = scope !== null && typeof scope === 'object' ? scope.relations : undefined;
227
+ return {
228
+ table,
229
+ resolve: (name) => (scoped !== null && typeof scoped === 'object'
230
+ && Object.hasOwn(scoped, name) ? scoped[name] : undefined),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Whether two sources may share one document: the same object, or two
236
+ * providers carrying one `scope` — one store's entity sets, which are
237
+ * two roots of ONE multi-entity input (QUERY-PEN §8).
238
+ * @param {any} a
239
+ * @param {any} b
240
+ * @returns {boolean}
241
+ */
242
+ export function sharesScope(a, b) {
243
+ if (a === b) return true;
244
+ if (!isProviderSource(a) || !isProviderSource(b)) return false;
245
+ const scope = a.scope;
246
+ return scope !== undefined && scope !== null && scope === b.scope;
127
247
  }
128
248
 
129
249
  /**
@@ -135,10 +255,7 @@ export function compileDocument(document, options) {
135
255
  * @returns {'provider' | 'iterable'}
136
256
  */
137
257
  export function classifySource(source) {
138
- if (source !== null && typeof source === 'object'
139
- && typeof (/** @type {any} */ (source).execute) === 'function') {
140
- return 'provider';
141
- }
258
+ if (isProviderSource(source)) return 'provider';
142
259
  if (source != null && (typeof source === 'string'
143
260
  || typeof (/** @type {any} */ (source))[Symbol.iterator] === 'function')) {
144
261
  return 'iterable';
@@ -0,0 +1,31 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The schema-builder brand: how anything outside the pen tells a
4
+ * builder from the document it writes. The brand is a registry symbol
5
+ * (`Symbol.for`), so the chain — which imports nothing from this
6
+ * directory, by the tree-shaking rule — recognises a builder handed to
7
+ * `ofType`/`cast` by looking the same symbol up; a data object that
8
+ * merely carries a `toJSON` member is a schema, never a builder.
9
+ */
10
+
11
+ /** The brand key every builder answers `true` under. */
12
+ export const SCHEMA_BUILDER = Symbol.for('@jarenjs/linq/schema-builder');
13
+
14
+ /**
15
+ * Whether `value` is a schema builder (carries the brand).
16
+ * @param {any} value
17
+ * @returns {boolean}
18
+ */
19
+ export function isSchemaBuilder(value) {
20
+ return value !== null && typeof value === 'object' && value[SCHEMA_BUILDER] === true;
21
+ }
22
+
23
+ /**
24
+ * A builder's document, or the value as given: the one call a consumer
25
+ * needs to accept "a schema, by hand or by pen".
26
+ * @param {any} value
27
+ * @returns {any} the JSON Schema document
28
+ */
29
+ export function schemaOf(value) {
30
+ return isSchemaBuilder(value) ? value.schema : value;
31
+ }