@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,244 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The step spellings of a `$migration` 0.1 document
4
+ * (MIGRATION-FORMAT §2): each function writes one step as plain JSON in
5
+ * the member order the format's examples use, and refuses only what it
6
+ * cannot spell (`JL0101`) or what the runner's own structural check would
7
+ * refuse later and the pen can see now (`JL0101`, mirrored from the
8
+ * runner's `JD0023` rules: a `sql` step without text, a `derive` without
9
+ * columns, a `rebuild` without its rendered parts, an unknown kind). A
10
+ * transform's callback is captured through the JSLT pen's `body()` — the
11
+ * capture the format's `jslt` step runs — as one root rule; an
12
+ * assertion's predicate through the chain's recording proxy, rooted at
13
+ * `$it` inside the `$for` the format's own example writes.
14
+ */
15
+
16
+ import { isJsonObject } from '@jarenjs/core/object';
17
+ import { captureExpression } from '../expression.js';
18
+ import { body } from '../jslt/body.js';
19
+ import { describeValue, requireJson } from '../json-boundary.js';
20
+ import { LinqBuildError } from '../errors.js';
21
+
22
+ /** The kinds the runner accepts, in the artifact's order. */
23
+ const STEP_KINDS = ['ddl', 'jslt', 'query', 'derive', 'sql', 'rebuild'];
24
+ const EXPECTS = ['empty', 'ebv'];
25
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
26
+ /** An assertion evaluates with no externals: `p.x` cannot appear. */
27
+ const NO_PARAMS = new Set();
28
+
29
+ /** A JSON value, copied: the document is a value of its own. @param {any} v */
30
+ const copy = (v) => JSON.parse(JSON.stringify(v));
31
+
32
+ /**
33
+ * A table name: an identifier, as the artifact's pattern admits.
34
+ * @param {any} name
35
+ * @param {string} what
36
+ * @returns {string}
37
+ */
38
+ export function requireName(name, what) {
39
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
40
+ throw new LinqBuildError('JL0101',
41
+ `${what} names an entity or collection by identifier ('users'), got ${describeValue(name)}`);
42
+ }
43
+ return name;
44
+ }
45
+
46
+ /** @param {any} sql @param {string} what */
47
+ function requireSql(sql, what) {
48
+ if (typeof sql !== 'string' || sql.trim() === '') {
49
+ throw new LinqBuildError('JL0101',
50
+ `${what} takes one rendered SQL statement as a non-empty string, got ${describeValue(sql)}`);
51
+ }
52
+ return sql;
53
+ }
54
+
55
+ /** @param {any} note @param {string} what */
56
+ function readNote(note, what) {
57
+ if (note === undefined) return undefined;
58
+ if (typeof note !== 'string') {
59
+ throw new LinqBuildError('JL0101', `${what} note is a string, got ${describeValue(note)}`);
60
+ }
61
+ return note;
62
+ }
63
+
64
+ /** The externals proxy an assertion's predicate sees: nothing. */
65
+ const NO_EXTERNALS = new Proxy(Object.freeze({}), {
66
+ get(_target, prop) {
67
+ if (typeof prop === 'symbol') return undefined;
68
+ throw new LinqBuildError('JL0104',
69
+ `an assert() predicate cannot bind '${String(prop)}' — a query assertion runs over the `
70
+ + "table's rows with no externals (MIGRATION-FORMAT §2)");
71
+ },
72
+ });
73
+
74
+ /**
75
+ * `{ kind: 'ddl', sql, note? }` — one rendered DDL statement.
76
+ * @param {string} sql @param {string} [note]
77
+ */
78
+ export function ddlStep(sql, note = undefined) {
79
+ const out = { kind: 'ddl', sql: requireSql(sql, 'ddl()') };
80
+ const n = readNote(note, 'ddl()');
81
+ if (n !== undefined) out.note = n;
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * `{ kind: 'sql', sql, note? }` — one DATA statement spelled directly
87
+ * (§9.4); a dry run always prints it with its note.
88
+ * @param {string} sql @param {string} [note]
89
+ */
90
+ export function sqlStep(sql, note = undefined) {
91
+ const out = { kind: 'sql', sql: requireSql(sql, 'sql()') };
92
+ const n = readNote(note, 'sql()');
93
+ if (n !== undefined) out.note = n;
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * `{ kind: 'jslt', collection, stylesheet }` — the transform of one
99
+ * table's rows. The spelling is a callback (one root rule, `match: '$'`,
100
+ * its body captured over the whole row with `root`/`path` as the
101
+ * externals the engine binds), a `stylesheet(…)` envelope (its rules —
102
+ * the step carries the rules array, so a disposition or a mode table
103
+ * has no place in it, `JL0102`), or a rules array verbatim.
104
+ * @param {string} name
105
+ * @param {any} spelling
106
+ */
107
+ export function transformStep(name, spelling) {
108
+ requireName(name, 'transform()');
109
+ let stylesheet;
110
+ if (typeof spelling === 'function') {
111
+ stylesheet = [{ match: '$', body: body(spelling) }];
112
+ }
113
+ else if (Array.isArray(spelling)) {
114
+ stylesheet = copy(requireJson(spelling, 'transform() rules'));
115
+ }
116
+ else if (isJsonObject(spelling) && spelling.$jslt === '0.1') {
117
+ for (const key of Object.keys(spelling)) {
118
+ if (key !== '$jslt' && key !== 'rules') {
119
+ throw new LinqBuildError('JL0102',
120
+ `transform() takes a stylesheet's rules — a jslt step carries the rules array `
121
+ + `(MIGRATION-FORMAT §2), so '${key}' has no place in it; write the rules without it`,
122
+ `/${key}`);
123
+ }
124
+ }
125
+ stylesheet = copy(requireJson(spelling.rules, 'transform() stylesheet rules'));
126
+ if (!Array.isArray(stylesheet)) {
127
+ throw new LinqBuildError('JL0101',
128
+ `transform() stylesheet rules are an array, got ${describeValue(spelling.rules)}`, '/rules');
129
+ }
130
+ }
131
+ else {
132
+ throw new LinqBuildError('JL0101',
133
+ 'transform() takes a callback (row, x) => …, a stylesheet(…) document or a rules array, '
134
+ + `got ${describeValue(spelling)}`);
135
+ }
136
+ return { kind: 'jslt', collection: name, stylesheet };
137
+ }
138
+
139
+ /**
140
+ * `{ kind: 'query', collection, assert, expect? }` — an assertion over
141
+ * the table's rows. A callback names a predicate over one row, spelled
142
+ * as the format's own `$for` over the rows: with `expect: 'empty'` (the
143
+ * default, absent from the document) no row may satisfy it — the
144
+ * predicate names the VIOLATION; with `expect: 'ebv'` the matching rows
145
+ * are the witness. A document is taken verbatim.
146
+ * @param {string} name
147
+ * @param {any} spelling
148
+ * @param {{ expect?: 'empty' | 'ebv' }} [options]
149
+ */
150
+ export function assertStep(name, spelling, options = undefined) {
151
+ requireName(name, 'assert()');
152
+ let expect;
153
+ if (options !== undefined) {
154
+ if (!isJsonObject(options)) {
155
+ throw new LinqBuildError('JL0101', `assert() options are { expect? }, got ${describeValue(options)}`);
156
+ }
157
+ for (const key of Object.keys(options)) {
158
+ if (key !== 'expect') throw new LinqBuildError('JL0101', `assert() does not take '${key}'`);
159
+ }
160
+ if (options.expect !== undefined) {
161
+ if (!EXPECTS.includes(options.expect)) {
162
+ throw new LinqBuildError('JL0101',
163
+ `assert() expect is 'empty' or 'ebv' (MIGRATION-FORMAT §2), got ${describeValue(options.expect)}`,
164
+ '/expect');
165
+ }
166
+ expect = options.expect;
167
+ }
168
+ }
169
+ let query;
170
+ if (typeof spelling === 'function') {
171
+ const predicate = captureExpression((it) => spelling(it, NO_EXTERNALS), ['it'], NO_PARAMS);
172
+ query = { $for: { it: '$[*]' }, $where: predicate, $return: '$it' };
173
+ }
174
+ else if (spelling !== undefined) {
175
+ query = copy(requireJson(spelling, 'assert() query'));
176
+ }
177
+ else {
178
+ throw new LinqBuildError('JL0101',
179
+ 'assert() takes a predicate (row) => … or a query document over the rows');
180
+ }
181
+ const out = { kind: 'query', collection: name, assert: query };
182
+ if (expect === 'ebv') out.expect = 'ebv';
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * `{ kind: 'derive', collection, columns }` — recompute stored derived
188
+ * columns (§2.1); the columns ride verbatim, a non-empty array.
189
+ * @param {string} name
190
+ * @param {any} columns
191
+ */
192
+ export function deriveStep(name, columns) {
193
+ requireName(name, 'derive()');
194
+ if (!Array.isArray(columns) || columns.length === 0) {
195
+ throw new LinqBuildError('JL0101',
196
+ `derive() takes a non-empty array of derived-column records ({ name, derive, segments }), got ${describeValue(columns)}`);
197
+ }
198
+ return { kind: 'derive', collection: name, columns: copy(requireJson(columns, 'derive() columns')) };
199
+ }
200
+
201
+ /**
202
+ * Any planner-emitted step, verbatim — the escape that keeps `rebuild`
203
+ * authorable without the pen re-implementing §10. The structural rules
204
+ * are the runner's own (`JD0023`), seen here: a recognised kind and the
205
+ * members that kind requires. A `draft` flag rides untouched.
206
+ * @param {any} step
207
+ */
208
+ export function rawStep(step) {
209
+ const raw = copy(requireJson(step, 'step()'));
210
+ if (!isJsonObject(raw) || !STEP_KINDS.includes(raw.kind)) {
211
+ throw new LinqBuildError('JL0101',
212
+ `step() takes a migration step with a recognised kind (${STEP_KINDS.join(', ')}), got `
213
+ + `${isJsonObject(raw) ? `kind ${describeValue(raw.kind)}` : describeValue(step)}`);
214
+ }
215
+ const need = (member, ok) => {
216
+ if (!ok) {
217
+ throw new LinqBuildError('JL0101',
218
+ `step() '${raw.kind}' needs '${member}' (MIGRATION-FORMAT §2)`, `/${member}`);
219
+ }
220
+ };
221
+ switch (raw.kind) {
222
+ case 'ddl': case 'sql':
223
+ need('sql', typeof raw.sql === 'string' && raw.sql !== '');
224
+ break;
225
+ case 'jslt':
226
+ need('collection', typeof raw.collection === 'string');
227
+ need('stylesheet', Array.isArray(raw.stylesheet));
228
+ break;
229
+ case 'query':
230
+ need('collection', typeof raw.collection === 'string');
231
+ need('assert', raw.assert !== undefined);
232
+ break;
233
+ case 'derive':
234
+ need('collection', typeof raw.collection === 'string');
235
+ need('columns', Array.isArray(raw.columns) && raw.columns.length > 0);
236
+ break;
237
+ default: // rebuild
238
+ need('table', typeof raw.table === 'string');
239
+ need('create', Array.isArray(raw.create) && raw.create.length > 0);
240
+ need('copy', typeof raw.copy === 'string');
241
+ need('indexes', Array.isArray(raw.indexes));
242
+ }
243
+ return raw;
244
+ }
@@ -0,0 +1,273 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Collections and their indexes: `collection(builder, { key,
4
+ * indexes })` and `index(path, options)`. A key is an RFC 6901 pointer
5
+ * or a captured member path (`(d) => d.id` → `/id`); an index path is a
6
+ * captured lambda (`(p) => p.embedding` → `$.embedding`), a composite
7
+ * array of them, or a JSONPath string; the options ride verbatim, the
8
+ * store's model walk being the judge of `derive`/`precision`/`dims`/
9
+ * `physical`. The default index name is `by_<segments>`.
10
+ */
11
+
12
+ import { LinqBuildError } from '../errors.js';
13
+ import { isSchemaBuilder } from '../schema/brand.js';
14
+ import { requireJson, requireName } from '../schema/builders.js';
15
+ import { captureQuery } from '../schema/check.js';
16
+ import { DOCUMENT_SCOPE, refuseStrandedRename } from './entity.js';
17
+
18
+ /** The index options the grammar names beside `name` and `path`. */
19
+ const INDEX_OPTIONS = new Set(['name', 'unique', 'derive', 'precision', 'dims', 'physical']);
20
+
21
+ /** The options an EXPRESSION index takes: it names the members it reads
22
+ * itself, so nothing that describes a member's storage belongs here. */
23
+ const EXPRESSION_OPTIONS = new Set(['name', 'unique']);
24
+
25
+ /** The marker a collection spec carries so `defineModel` can tell it apart. */
26
+ export const COLLECTION = Symbol.for('@jarenjs/linq/model-collection');
27
+
28
+ /** A shorthand member path (`$.a.b`), as the capture spells one. */
29
+ const MEMBER_PATH = /^\$(\.[A-Za-z_][A-Za-z0-9_]*)+$/;
30
+
31
+ /**
32
+ * Capture one path lambda into a JSONPath string.
33
+ * @param {(doc: any) => any} lambda
34
+ * @param {string} what
35
+ * @returns {string}
36
+ */
37
+ function capturePath(lambda, what) {
38
+ let captured;
39
+ try {
40
+ captured = captureQuery(what, [], lambda, { advice: () => DOCUMENT_SCOPE });
41
+ }
42
+ catch (error) {
43
+ // a member named like a surface method (`at`, `get`, …) reads as the
44
+ // method, so the lambda answers a function; name the escape
45
+ if (error instanceof LinqBuildError && error.code === 'JL0005' && /function/.test(error.message)) {
46
+ throw new LinqBuildError('JL0102',
47
+ `${what} answered a function, not a path — a member named like a surface method `
48
+ + "(`at`, `get`, `all`, …) is read with get('name'): (d) => d.get('at')", undefined, error);
49
+ }
50
+ throw error;
51
+ }
52
+ if (typeof captured !== 'string' || captured.charCodeAt(0) !== 0x24) {
53
+ throw new LinqBuildError('JL0102',
54
+ `${what} takes a member path ((d) => d.member); an operator result is not a path`);
55
+ }
56
+ return captured;
57
+ }
58
+
59
+ /**
60
+ * A path argument: a captured lambda, a JSONPath string, or an array of
61
+ * either (a composite).
62
+ * @param {any} path
63
+ * @param {string} what
64
+ * @returns {string | string[]}
65
+ */
66
+ function indexPath(path, what) {
67
+ if (typeof path === 'function') return capturePath(path, what);
68
+ if (typeof path === 'string' && path.length > 0) return path;
69
+ if (Array.isArray(path) && path.length > 0) {
70
+ return path.map((one, i) => {
71
+ if (typeof one === 'function') return capturePath(one, `${what}[${i}]`);
72
+ if (typeof one === 'string' && one.length > 0) return one;
73
+ throw new LinqBuildError('JL0101', `${what}[${i}] is neither a path lambda nor a JSONPath string`);
74
+ });
75
+ }
76
+ throw new LinqBuildError('JL0101',
77
+ `${what} takes a path lambda, a JSONPath string, or a non-empty array of them`);
78
+ }
79
+
80
+ /** The default name: `by_` + the member segments, identifier-safe. @param {string | string[]} path */
81
+ function defaultName(path) {
82
+ const segments = (Array.isArray(path) ? path : [path]).flatMap((one) =>
83
+ one.replace(/^\$\.?/, '').split(/[.[\]'"]+/).filter((s) => s !== '' && s !== '*'));
84
+ return 'by_' + segments.map((s) => s.replace(/[^A-Za-z0-9_]/g, '_')).join('_');
85
+ }
86
+
87
+ /**
88
+ * One index declaration.
89
+ * @param {((doc: any) => any) | string | readonly (((doc: any) => any) | string)[]} path
90
+ * @param {{ name?: string, unique?: boolean, derive?: 'geohash' | 'bbox' | 'vector',
91
+ * precision?: number, dims?: number, physical?: 'columns' | 'rtree' }} [options]
92
+ * @returns {any} the frozen index document
93
+ */
94
+ export function index(path, options = {}) {
95
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
96
+ throw new LinqBuildError('JL0101', 'index() takes an options object');
97
+ }
98
+ for (const key of Object.keys(options)) {
99
+ if (!INDEX_OPTIONS.has(key)) {
100
+ throw new LinqBuildError('JL0101',
101
+ `index() does not take '${key}' — the options are name, unique, derive, precision, dims, physical`);
102
+ }
103
+ }
104
+ const resolved = indexPath(path, 'index()');
105
+ const out = {
106
+ name: options.name === undefined ? defaultName(resolved) : requireName(options.name, 'index() name'),
107
+ path: resolved,
108
+ };
109
+ for (const key of ['unique', 'derive', 'precision', 'dims', 'physical']) {
110
+ if (options[key] !== undefined) out[key] = requireJson(options[key], `index() ${key}`);
111
+ }
112
+ return Object.freeze(out);
113
+ }
114
+
115
+ /**
116
+ * One node of an index expression, checked here so a pen mistake is a
117
+ * build error rather than a store refusal at open. The vocabulary is the
118
+ * model's, closed: a member path (a lambda or a JSONPath string), a JSON
119
+ * scalar, or a call to a function the HOST declares — this pen resolves
120
+ * no name, because arity and determinism are the store's to check
121
+ * against the declarations it was given.
122
+ * @param {any} node
123
+ * @param {string} what
124
+ * @param {number} depth
125
+ * @returns {any}
126
+ */
127
+ function expressionNode(node, what, depth) {
128
+ if (depth > 8)
129
+ throw new LinqBuildError('JL0101', `${what} nests deeper than 8`);
130
+ if (typeof node === 'function') return { member: capturePath(node, what) };
131
+ if (typeof node === 'string' && node.charCodeAt(0) === 0x24) return { member: node };
132
+ if (typeof node === 'number' || typeof node === 'boolean') return { value: node };
133
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
134
+ throw new LinqBuildError('JL0101',
135
+ `${what} takes a path lambda, a JSONPath string, a JSON scalar, or `
136
+ + '{ call, args } — an index expression is never SQL text');
137
+ }
138
+ if (node.member !== undefined) {
139
+ if (typeof node.member === 'function')
140
+ return { member: capturePath(node.member, `${what}.member`) };
141
+ if (typeof node.member !== 'string' || node.member.length === 0)
142
+ throw new LinqBuildError('JL0101', `${what}.member takes a path lambda or a JSONPath string`);
143
+ return { member: node.member };
144
+ }
145
+ if (node.value !== undefined) {
146
+ if (typeof node.value !== 'string' && typeof node.value !== 'number'
147
+ && typeof node.value !== 'boolean') {
148
+ throw new LinqBuildError('JL0101',
149
+ `${what}.value takes a JSON string, number or boolean`);
150
+ }
151
+ return { value: node.value };
152
+ }
153
+ if (typeof node.call !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(node.call))
154
+ throw new LinqBuildError('JL0101', `${what}.call names a function by identifier`);
155
+ const args = node.args ?? [];
156
+ if (!Array.isArray(args))
157
+ throw new LinqBuildError('JL0101', `${what}.args is an array`);
158
+ return {
159
+ call: node.call,
160
+ args: args.map((argument, i) => expressionNode(argument, `${what}.args[${i}]`, depth + 1)),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * An index over a computed value: a closed expression over declared
166
+ * members, JSON scalars and functions the HOST declares deterministic.
167
+ *
168
+ * The function is resolved where the declarations are — at
169
+ * `openStore({ expressions })` — so a name this pen has never heard of
170
+ * is not an error here; a wrong ARITY and a missing declaration are
171
+ * `JD0004` at open, before any DDL. What this pen decides is the shape.
172
+ * @param {any} expression - a `{ call, args }` node, a path lambda, a
173
+ * JSONPath string, or a JSON scalar
174
+ * @param {{ name?: string, unique?: boolean }} [options]
175
+ * @returns {any} the frozen index document
176
+ */
177
+ export function expressionIndex(expression, options = {}) {
178
+ if (options === null || typeof options !== 'object' || Array.isArray(options))
179
+ throw new LinqBuildError('JL0101', 'expressionIndex() takes an options object');
180
+ for (const key of Object.keys(options)) {
181
+ if (!EXPRESSION_OPTIONS.has(key)) {
182
+ throw new LinqBuildError('JL0101',
183
+ `expressionIndex() does not take '${key}' — the options are name, unique`);
184
+ }
185
+ }
186
+ const resolved = expressionNode(expression, 'expressionIndex()', 0);
187
+ const out = {
188
+ name: options.name === undefined
189
+ ? expressionName(resolved)
190
+ : requireName(options.name, 'expressionIndex() name'),
191
+ expression: resolved,
192
+ };
193
+ if (options.unique !== undefined)
194
+ out.unique = requireJson(options.unique, 'expressionIndex() unique');
195
+ return Object.freeze(out);
196
+ }
197
+
198
+ /** The default name: `by_` + the outermost call and the members it
199
+ * reads, identifier-safe — the same stem the store's column takes.
200
+ * @param {any} node */
201
+ function expressionName(node) {
202
+ const members = [];
203
+ const walk = (current) => {
204
+ if (current.member !== undefined) members.push(current.member);
205
+ else if (current.call !== undefined) current.args.forEach(walk);
206
+ };
207
+ walk(node);
208
+ const parts = [node.call ?? 'x', ...members.map((path) =>
209
+ path.replace(/^\$\.?/, '').replace(/[^A-Za-z0-9]+/g, '_'))];
210
+ return `by_${parts.join('_').replace(/^_+|_+$/g, '')}`;
211
+ }
212
+
213
+ /**
214
+ * The key declaration: a pointer string, a captured member path, or
215
+ * `null` (the store allocates; `identity` says how).
216
+ * @param {any} key
217
+ * @returns {string | null}
218
+ */
219
+ function keyPointer(key) {
220
+ if (key === null) return null;
221
+ if (typeof key === 'function') {
222
+ const path = capturePath(key, 'collection() key');
223
+ if (!MEMBER_PATH.test(path)) {
224
+ throw new LinqBuildError('JL0102',
225
+ `collection() key must select members by name ((d) => d.id), got the path ${path}`);
226
+ }
227
+ return '/' + path.slice(2).split('.').map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/');
228
+ }
229
+ if (typeof key === 'string' && key.startsWith('/')) return key;
230
+ throw new LinqBuildError('JL0101',
231
+ 'collection() key is an RFC 6901 pointer, a member path lambda, or null');
232
+ }
233
+
234
+ /**
235
+ * One collection declaration for `defineModel`.
236
+ * @param {any} builder - the document schema
237
+ * @param {{ key?: string | ((doc: any) => any) | null, identity?: 'caller' | 'uuid' | 'integer',
238
+ * indexes?: readonly any[], renamedFrom?: string }} [options]
239
+ * @returns {any} the frozen collection document
240
+ */
241
+ export function collection(builder, options = {}) {
242
+ if (!isSchemaBuilder(builder)) {
243
+ throw new LinqBuildError('JL0101', 'collection() takes a schema builder as its document schema');
244
+ }
245
+ refuseStrandedRename(builder, 'collection()');
246
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
247
+ throw new LinqBuildError('JL0101', 'collection() takes an options object');
248
+ }
249
+ for (const key of Object.keys(options)) {
250
+ if (!['key', 'identity', 'indexes', 'renamedFrom'].includes(key)) {
251
+ throw new LinqBuildError('JL0101',
252
+ `collection() does not take '${key}' — the options are key, identity, indexes, renamedFrom`);
253
+ }
254
+ }
255
+ const out = { schema: builder.schema };
256
+ if (options.key !== undefined) out.key = keyPointer(options.key);
257
+ if (options.identity !== undefined) out.identity = requireJson(options.identity, 'collection() identity');
258
+ if (options.indexes !== undefined) {
259
+ if (!Array.isArray(options.indexes)) {
260
+ throw new LinqBuildError('JL0101', 'collection() indexes is an array of index() entries');
261
+ }
262
+ out.indexes = options.indexes.map((one, i) => {
263
+ if (one === null || typeof one !== 'object' || typeof one.name !== 'string') {
264
+ throw new LinqBuildError('JL0101', `collection() indexes[${i}] is not an index() entry`);
265
+ }
266
+ return requireJson(one, `collection() indexes[${i}]`);
267
+ });
268
+ }
269
+ const renamed = options.renamedFrom ?? builder.state.renamedFrom;
270
+ if (renamed !== undefined) out['x-rename'] = requireName(renamed, 'collection() renamedFrom');
271
+ Object.defineProperty(out, COLLECTION, { value: true, enumerable: false });
272
+ return Object.freeze(out);
273
+ }
@@ -0,0 +1,125 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `defineModel({ entities, collections })` — one `$model` 0.1
4
+ * document, deep-frozen, that `openStore` accepts unchanged. The pen
5
+ * refuses here only what it can see and the store's model walk would
6
+ * refuse anyway: a relation whose target is not a declared entity, a
7
+ * store-allocated default on a composite key, a collection spec where
8
+ * an entity was expected, and a `renamedFrom()` hint on a member, which
9
+ * the document has no place for. Inverse agreement, foreign-key types
10
+ * and the rest stay the engine's (`JD00xx`), never re-implemented.
11
+ */
12
+
13
+ import { deepFreeze, setObjectMember } from '@jarenjs/core/object';
14
+
15
+ import { LinqBuildError } from '../errors.js';
16
+ import { requireNameMap } from '../json-boundary.js';
17
+ import { isSchemaBuilder } from '../schema/brand.js';
18
+ import { COLLECTION } from './collection.js';
19
+ import { refuseStrandedRename } from './entity.js';
20
+
21
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
22
+
23
+ /** @param {any} value @param {string} what @returns {Record<string, any>} */
24
+ function requireMap(value, what) {
25
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
26
+ throw new LinqBuildError('JL0101', `defineModel() ${what} is a plain object of declarations`);
27
+ }
28
+ requireNameMap(value, `defineModel() ${what}`, `/${what}`);
29
+ for (const name of Object.keys(value)) {
30
+ if (!NAME.test(name)) {
31
+ throw new LinqBuildError('JL0101',
32
+ `defineModel() ${what} names are identifiers, got '${name}'`, `/${what}`);
33
+ }
34
+ }
35
+ return value;
36
+ }
37
+
38
+ /**
39
+ * The `x-entity` blocks an entity declares, by member, read from the
40
+ * builder before its document is assembled.
41
+ * @param {any} builder
42
+ * @returns {[string, any][]}
43
+ */
44
+ function entityBlocks(builder) {
45
+ const st = builder.state;
46
+ if (st.kind !== 'object') return [];
47
+ return st.props.map(([name, member]) => [name, member.annotation('x-entity') ?? {}]);
48
+ }
49
+
50
+ /**
51
+ * Build the model document.
52
+ * @param {{ entities?: Record<string, any>, collections?: Record<string, any> }} spec
53
+ * @returns {any} the deep-frozen `$model` 0.1 document
54
+ */
55
+ export function defineModel(spec) {
56
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
57
+ throw new LinqBuildError('JL0101', 'defineModel() takes { entities?, collections? }');
58
+ }
59
+ for (const key of Object.keys(spec)) {
60
+ if (key !== 'entities' && key !== 'collections') {
61
+ throw new LinqBuildError('JL0101', `defineModel() does not take '${key}'`);
62
+ }
63
+ }
64
+ if (spec.entities === undefined && spec.collections === undefined) {
65
+ throw new LinqBuildError('JL0101', 'defineModel() needs entities, collections, or both');
66
+ }
67
+ const out = { $model: '0.1' };
68
+
69
+ if (spec.collections !== undefined) {
70
+ const collections = requireMap(spec.collections, 'collections');
71
+ const emitted = {};
72
+ for (const name of Object.keys(collections)) {
73
+ const declared = collections[name];
74
+ if (declared === null || typeof declared !== 'object' || declared[COLLECTION] !== true) {
75
+ throw new LinqBuildError('JL0101',
76
+ `collections.${name} is not a collection() declaration`, `/collections/${name}`);
77
+ }
78
+ setObjectMember(emitted, name, declared);
79
+ }
80
+ out.collections = emitted;
81
+ }
82
+
83
+ if (spec.entities !== undefined) {
84
+ const entities = requireMap(spec.entities, 'entities');
85
+ const names = Object.keys(entities);
86
+ const emitted = {};
87
+ for (const name of names) {
88
+ const at = `/entities/${name}`;
89
+ const builder = entities[name];
90
+ if (builder !== null && typeof builder === 'object' && builder[COLLECTION] === true) {
91
+ throw new LinqBuildError('JL0102',
92
+ `entities.${name} is a collection() declaration — an entity has no key pointer and no `
93
+ + 'indexes option: its key is key() on a member and its indexes are unique()/index() '
94
+ + 'per member (the vocabulary has no composite or derived entity index)', at);
95
+ }
96
+ if (!isSchemaBuilder(builder)) {
97
+ throw new LinqBuildError('JL0101', `entities.${name} is not a schema builder`, at);
98
+ }
99
+ refuseStrandedRename(builder, `entities.${name}`, at);
100
+ const blocks = entityBlocks(builder);
101
+ const keys = blocks.filter(([, block]) => block.key === true);
102
+ for (const [member, block] of blocks) {
103
+ const memberAt = `${at}/schema/properties/${member}/x-entity`;
104
+ const relation = block.relation;
105
+ if (relation !== undefined && !names.includes(relation.to)) {
106
+ throw new LinqBuildError('JL0102',
107
+ `relation target '${relation.to}' on ${name}.${member} is not a declared entity — `
108
+ + `the model declares ${names.map((n) => `'${n}'`).join(', ')}`,
109
+ `${memberAt}/relation/to`);
110
+ }
111
+ if ((block.default === 'auto' || block.default === 'uuid') && keys.length > 1) {
112
+ throw new LinqBuildError('JL0102',
113
+ `identity('${block.default}') on ${name}.${member}: a store-allocated key is a SINGLE `
114
+ + `key, and ${name} declares a composite one (${keys.map(([k]) => k).join(', ')})`,
115
+ `${memberAt}/default`);
116
+ }
117
+ }
118
+ const entity = { schema: builder.schema };
119
+ if (builder.state.renamedFrom !== undefined) entity['x-rename'] = builder.state.renamedFrom;
120
+ setObjectMember(emitted, name, entity);
121
+ }
122
+ out.entities = emitted;
123
+ }
124
+ return deepFreeze(out);
125
+ }