@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,202 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `rule()` and `stylesheet()` — the rule object of JSLT-FORMAT
4
+ * §2.2 and the envelope of §2.1, emitted as plain deep-frozen JSON in
5
+ * one member order (`mode`, `match`, `priority`, `body`; `$jslt`,
6
+ * `unmatched`, `modes`, `rules` — the order Appendix A writes). A
7
+ * `match` is a JSONPath string, `{ path?, schema? }` — the schema a
8
+ * schema-pen builder or a document — or absent for the unconditional
9
+ * rule; a body is `body()`'s document, a callback captured as one, or a
10
+ * query document verbatim. What the pen refuses is what the compiler
11
+ * would refuse and the pen can already see (`JT0001`–`JT0003`,
12
+ * mirrored as `JL0101`/`JL0102`); everything else — path syntax, the
13
+ * body's operators, the schema — is the compiler's.
14
+ */
15
+
16
+ import { deepFreeze, setObjectMember, isJsonObject } from '@jarenjs/core/object';
17
+ import { LinqBuildError } from '../errors.js';
18
+ import { isSchemaBuilder, schemaOf } from '../schema/brand.js';
19
+ import { describeValue, requireJson, requireNameMap } from '../json-boundary.js';
20
+ import { body } from './body.js';
21
+
22
+ const DISPOSITIONS = ['share', 'fresh', 'error'];
23
+
24
+ /** A JSON value, copied: the document is a value of its own. @param {any} v */
25
+ const copy = (v) => JSON.parse(JSON.stringify(v));
26
+
27
+ /**
28
+ * The `match` member (§3.1), or `undefined` for the unconditional rule.
29
+ * @param {any} match
30
+ * @returns {any}
31
+ */
32
+ function readMatch(match) {
33
+ if (match === undefined || match === null) return undefined;
34
+ if (typeof match === 'string') return match;
35
+ if (!isJsonObject(match)) {
36
+ throw new LinqBuildError('JL0101',
37
+ `rule() match is a JSONPath string or { path?, schema? }, got ${describeValue(match)}`,
38
+ '/match');
39
+ }
40
+ const keys = Object.keys(match);
41
+ if (keys.length === 0) {
42
+ throw new LinqBuildError('JL0102',
43
+ 'rule() match {} would match nothing — write no match for the unconditional rule '
44
+ + '(JSLT-FORMAT §3.1, the compiler\'s JT0003)', '/match');
45
+ }
46
+ for (const key of keys) {
47
+ if (key !== 'path' && key !== 'schema') {
48
+ throw new LinqBuildError('JL0101',
49
+ `rule() match takes 'path' and/or 'schema', not '${key}' (JSLT-FORMAT §3.1)`,
50
+ `/match/${key}`);
51
+ }
52
+ }
53
+ const out = {};
54
+ if (match.path !== undefined) {
55
+ if (typeof match.path !== 'string') {
56
+ throw new LinqBuildError('JL0101',
57
+ `rule() match.path is an RFC 9535 query string, got ${describeValue(match.path)}`,
58
+ '/match/path');
59
+ }
60
+ out.path = match.path;
61
+ }
62
+ if (match.schema !== undefined) {
63
+ out.schema = isSchemaBuilder(match.schema)
64
+ ? schemaOf(match.schema)
65
+ : copy(requireJson(match.schema, 'rule() match.schema'));
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * The `body` member: a callback captured as `body(fn)`, or a document
72
+ * verbatim (a `body()` result, or a hand-written query document).
73
+ * @param {any} value
74
+ */
75
+ function readBody(value) {
76
+ if (typeof value === 'function') return body(value);
77
+ if (value === undefined) {
78
+ throw new LinqBuildError('JL0101',
79
+ 'rule() takes a body: a callback (value, x) => …, body(…), or a query document',
80
+ '/body');
81
+ }
82
+ return copy(requireJson(value, 'rule() body'));
83
+ }
84
+
85
+ /**
86
+ * One template rule (§2.2): `{ mode?, match?, priority?, body }`.
87
+ * @param {any} match - a JSONPath string, `{ path?, schema? }`, or `null`
88
+ * @param {any} bodyOrFn - `body(…)`, a callback, or a query document
89
+ * @param {{ mode?: string, priority?: number }} [options]
90
+ * @returns {any} the rule document, deep-frozen
91
+ */
92
+ export function rule(match, bodyOrFn, options = undefined) {
93
+ const out = {};
94
+ if (options !== undefined) {
95
+ if (!isJsonObject(options)) {
96
+ throw new LinqBuildError('JL0101',
97
+ `rule() options are { mode?, priority? }, got ${describeValue(options)}`);
98
+ }
99
+ for (const key of Object.keys(options)) {
100
+ if (key !== 'mode' && key !== 'priority') {
101
+ throw new LinqBuildError('JL0101', `rule() does not take '${key}' (JSLT-FORMAT §2.2)`);
102
+ }
103
+ }
104
+ if (options.mode !== undefined) {
105
+ if (typeof options.mode !== 'string') {
106
+ throw new LinqBuildError('JL0101',
107
+ `rule() mode is a string naming the rule's mode, got ${describeValue(options.mode)}`,
108
+ '/mode');
109
+ }
110
+ out.mode = options.mode;
111
+ }
112
+ }
113
+ const m = readMatch(match);
114
+ if (m !== undefined) out.match = m;
115
+ if (options !== undefined && options.priority !== undefined) {
116
+ const p = options.priority;
117
+ if (typeof p !== 'number' || !Number.isFinite(p) || Object.is(p, -0)) {
118
+ throw new LinqBuildError('JL0101',
119
+ `rule() priority is a finite JSON number, got ${describeValue(p)}`, '/priority');
120
+ }
121
+ out.priority = p;
122
+ }
123
+ out.body = readBody(bodyOrFn);
124
+ return deepFreeze(out);
125
+ }
126
+
127
+ /**
128
+ * A disposition (§5): one of the three, or `JL0101`.
129
+ * @param {any} value
130
+ * @param {string} where
131
+ */
132
+ function readDisposition(value, where) {
133
+ if (DISPOSITIONS.includes(value)) return value;
134
+ throw new LinqBuildError('JL0101',
135
+ `${where} is one of 'share', 'fresh' or 'error' (JSLT-FORMAT §5), got `
136
+ + `${typeof value === 'string' ? `'${value}'` : describeValue(value)}`, `/${where}`);
137
+ }
138
+
139
+ /**
140
+ * The stylesheet envelope (§2.1): `{ $jslt: '0.1', unmatched?, modes?,
141
+ * rules }`, deep-frozen. (The bare-array form is the rules array
142
+ * itself; the envelope says what the document is.)
143
+ * @param {readonly any[]} rules - rule documents, from `rule()` or by hand
144
+ * @param {{ unmatched?: string, modes?: Record<string, { unmatched: string }> }} [options]
145
+ * @returns {any} the stylesheet document
146
+ */
147
+ export function stylesheet(rules, options = undefined) {
148
+ if (!Array.isArray(rules)) {
149
+ throw new LinqBuildError('JL0101',
150
+ `stylesheet() takes an array of rules, got ${describeValue(rules)}`, '/rules');
151
+ }
152
+ const out = { $jslt: '0.1' };
153
+ if (options !== undefined) {
154
+ if (!isJsonObject(options)) {
155
+ throw new LinqBuildError('JL0101',
156
+ `stylesheet() options are { unmatched?, modes? }, got ${describeValue(options)}`);
157
+ }
158
+ for (const key of Object.keys(options)) {
159
+ if (key !== 'unmatched' && key !== 'modes') {
160
+ throw new LinqBuildError('JL0101',
161
+ `stylesheet() does not take '${key}' (JSLT-FORMAT §2.1)`);
162
+ }
163
+ }
164
+ if (options.unmatched !== undefined) {
165
+ out.unmatched = readDisposition(options.unmatched, 'unmatched');
166
+ }
167
+ if (options.modes !== undefined) {
168
+ if (!isJsonObject(options.modes)) {
169
+ throw new LinqBuildError('JL0101',
170
+ `stylesheet() modes is { name: { unmatched } }, got ${describeValue(options.modes)}`,
171
+ '/modes');
172
+ }
173
+ requireNameMap(options.modes, 'stylesheet() modes', '/modes');
174
+ const modes = {};
175
+ for (const name of Object.keys(options.modes)) {
176
+ const mode = options.modes[name];
177
+ if (!isJsonObject(mode) || Object.keys(mode).length !== 1 || mode.unmatched === undefined) {
178
+ throw new LinqBuildError('JL0101',
179
+ `stylesheet() mode '${name}' is { unmatched } and nothing else (JSLT-FORMAT §2.1)`,
180
+ `/modes/${name}`);
181
+ }
182
+ setObjectMember(modes, name,
183
+ { unmatched: readDisposition(mode.unmatched, `modes/${name}/unmatched`) });
184
+ }
185
+ out.modes = modes;
186
+ }
187
+ }
188
+ out.rules = rules.map((r, i) => {
189
+ if (!isJsonObject(r)) {
190
+ throw new LinqBuildError('JL0101',
191
+ `stylesheet() rule ${i} is an object — rule(match, body) — got ${describeValue(r)}`,
192
+ `/rules/${i}`);
193
+ }
194
+ if (r.body === undefined) {
195
+ throw new LinqBuildError('JL0101',
196
+ `stylesheet() rule ${i} has no body (JSLT-FORMAT §2.2, the compiler's JT0002)`,
197
+ `/rules/${i}/body`);
198
+ }
199
+ return copy(requireJson(r, `stylesheet() rule ${i}`));
200
+ });
201
+ return deepFreeze(out);
202
+ }
@@ -0,0 +1,90 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The JSON boundary every value entering a pen's document crosses
4
+ * (`JL0101`): a default, a literal, an annotation, a hand-written
5
+ * schema or query document, a mode name, a priority. One predicate for
6
+ * every pen — the constant rule of QUERY-PEN §5 ('a captured constant
7
+ * crosses a real JSON boundary'), applied at the door:
8
+ * null, booleans, finite numbers (never `-0`), strings, arrays and plain
9
+ * objects, and nothing else — a function, symbol, bigint, `NaN`,
10
+ * `±Infinity`, a class instance or a cycle is refused by name.
11
+ *
12
+ * Beside it, one predicate for the OTHER door a pen has: the name → value
13
+ * map it reads its members out of (`requireNameMap`). There the hazard is
14
+ * not a value the document cannot carry but a member that never arrived —
15
+ * `{ __proto__: builder }` sets the object's prototype instead of adding
16
+ * a key — and the prototype is the only trace of it left to refuse by.
17
+ */
18
+
19
+ import { isJsonValue } from '@jarenjs/core/object';
20
+ import { LinqBuildError } from './errors.js';
21
+ import { isExpression } from './expression.js';
22
+
23
+ /**
24
+ * `-0` is JSON-representable by text and not by value: it shares its
25
+ * text with `0` while dividing to the opposite infinity, so a document
26
+ * holding it cannot be keyed, stored or compared faithfully.
27
+ * @param {any} value
28
+ * @returns {boolean}
29
+ */
30
+ function hasNegativeZero(value) {
31
+ if (typeof value === 'number') return Object.is(value, -0);
32
+ if (value === null || typeof value !== 'object') return false;
33
+ if (Array.isArray(value)) return value.some(hasNegativeZero);
34
+ return Object.keys(value).some((key) => hasNegativeZero(value[key]));
35
+ }
36
+
37
+ /** What a refused value is, for a message. `-0` is spelled out: it is
38
+ * the one value whose `String()` names a DIFFERENT value (`'0'`), so a
39
+ * reader who greps their source for the literal the message names would
40
+ * find the wrong one — and it is exactly the value the tail of the
41
+ * message goes on to forbid by name. @param {any} value */
42
+ export function describeValue(value) {
43
+ if (value === null) return 'null';
44
+ if (typeof value === 'number') return Object.is(value, -0) ? '-0' : String(value);
45
+ if (isExpression(value)) return 'an expression'; // a proxy: reading `.constructor` would record a path
46
+ if (typeof value === 'object') return `a ${value.constructor?.name ?? 'non-plain'} instance`;
47
+ return `a ${typeof value}`;
48
+ }
49
+
50
+ /**
51
+ * The value, when it is JSON; `JL0101` naming what it was otherwise.
52
+ * @template T
53
+ * @param {T} value
54
+ * @param {string} what - the keyword or method, for the message
55
+ * @returns {T}
56
+ */
57
+ export function requireJson(value, what) {
58
+ if (isJsonValue(value) && !hasNegativeZero(value)) return value;
59
+ throw new LinqBuildError('JL0101',
60
+ `${what} received ${describeValue(value)}, which is not JSON — a document carries `
61
+ + 'null, booleans, finite numbers (never -0), strings, arrays and plain objects, '
62
+ + 'and nothing else');
63
+ }
64
+
65
+ /**
66
+ * A name → value map a pen reads by its OWN keys — an object of
67
+ * builders, operations, nodes, actions or modes.
68
+ *
69
+ * `{ __proto__: x }` in an object LITERAL sets the object's prototype
70
+ * instead of adding a member, so that member never reaches the pen:
71
+ * there is nothing to emit and nothing to see. The one thing that IS
72
+ * visible is the prototype, and no plain map has one — so the map is
73
+ * refused by it rather than emitted a member short. A computed key
74
+ * (`{ ['__proto__']: x }`) is an ordinary own property and passes, as
75
+ * does `Object.create(null)`.
76
+ *
77
+ * @template T
78
+ * @param {T} value - a non-null, non-array object
79
+ * @param {string} what - the method, for the message
80
+ * @param {string} [at] - the docPath of the node being assembled
81
+ * @returns {T}
82
+ */
83
+ export function requireNameMap(value, what, at) {
84
+ const proto = Object.getPrototypeOf(value);
85
+ if (proto === null || proto === Object.prototype) return value;
86
+ throw new LinqBuildError('JL0101',
87
+ `${what} received a map whose prototype was replaced: a '__proto__:' key in an object `
88
+ + 'literal sets the prototype instead of adding a member, so that member is not there to '
89
+ + "emit — spell it { ['__proto__']: … }, which is an own key", at);
90
+ }
@@ -0,0 +1,318 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `defineMigration()` and `fromPlanned()` — a `$migration` 0.1
4
+ * document by code (MIGRATION-FORMAT §2). Identity stays the shape hash:
5
+ * `from`/`to` are `hashContent(canonicalizeJson(model))` with the
6
+ * `x-rename` planning hints stripped — the store's own rule, pinned equal
7
+ * to its `shapeHash` by a test over every corpus model. Steps are
8
+ * appended in the order they are called; a `transform` over a planned
9
+ * document REPLACES the draft the planner left for that name, in place,
10
+ * and nothing here ever clears a `draft` flag: a draft left in place
11
+ * still refuses to run (`JD0021`, the runner's rule). The pen refuses
12
+ * what it cannot spell and what the runner would refuse later and the
13
+ * pen can see now — a step over a table the target model does not
14
+ * declare (`JL0106`).
15
+ */
16
+
17
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
18
+ import { hashContent } from '@jarenjs/core/string';
19
+ import { deepFreeze, setObjectMember, isJsonObject } from '@jarenjs/core/object';
20
+
21
+ import { LinqBuildError } from '../errors.js';
22
+ import { describeValue, requireJson } from '../json-boundary.js';
23
+ import {
24
+ ddlStep, sqlStep, transformStep, assertStep, deriveStep, rawStep,
25
+ } from './steps.js';
26
+
27
+ const MIGRATION_VERSION = '0.1';
28
+ const HEAD_MEMBERS = ['$migration', 'id', 'from', 'to', 'note', 'steps'];
29
+
30
+ /** A JSON value, copied: the document is a value of its own. @param {any} v */
31
+ const copy = (v) => JSON.parse(JSON.stringify(v));
32
+
33
+ /**
34
+ * The model without its `x-rename` hints. A hint is a PLANNING
35
+ * instruction, not shape (MIGRATION-FORMAT §3): two models that differ
36
+ * only by it describe one database and hash the same.
37
+ * @param {any} model
38
+ * @returns {any}
39
+ */
40
+ function withoutRenameHints(model) {
41
+ const out = {};
42
+ for (const key of Object.keys(model)) setObjectMember(out, key, model[key]);
43
+ for (const member of ['collections', 'entities']) {
44
+ const declared = model[member];
45
+ if (!isJsonObject(declared)) continue;
46
+ const stripped = {};
47
+ for (const name of Object.keys(declared)) {
48
+ const spec = declared[name];
49
+ if (isJsonObject(spec) && Object.hasOwn(spec, 'x-rename')) {
50
+ const { 'x-rename': _hint, ...rest } = spec;
51
+ setObjectMember(stripped, name, rest);
52
+ }
53
+ else {
54
+ setObjectMember(stripped, name, spec);
55
+ }
56
+ }
57
+ out[member] = stripped;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * The signature-grade identity of a model SHAPE — what a migration's
64
+ * `from`/`to` name, and what a database records.
65
+ * @param {any} model
66
+ * @returns {string}
67
+ */
68
+ function shapeHashOf(model) {
69
+ return hashContent(canonicalizeJson(withoutRenameHints(model)));
70
+ }
71
+
72
+ /**
73
+ * A `$model` 0.1 document — the model pen's, or its JSON — or `JL0101`.
74
+ * @param {any} model
75
+ * @param {string} what
76
+ * @returns {any}
77
+ */
78
+ function requireModel(model, what) {
79
+ const doc = requireJson(model, what);
80
+ if (!isJsonObject(doc) || doc.$model !== '0.1') {
81
+ throw new LinqBuildError('JL0101',
82
+ `${what} is a $model 0.1 document (defineModel(…), or its JSON), got `
83
+ + `${isJsonObject(doc) ? 'an object without $model: \'0.1\'' : describeValue(model)}`);
84
+ }
85
+ return doc;
86
+ }
87
+
88
+ /**
89
+ * The tables a model declares: its entities and its collections.
90
+ * @param {any} model
91
+ * @returns {string[]}
92
+ */
93
+ function declaredNames(model) {
94
+ const names = [];
95
+ for (const member of ['entities', 'collections']) {
96
+ if (isJsonObject(model[member])) names.push(...Object.keys(model[member]));
97
+ }
98
+ return names;
99
+ }
100
+
101
+ /**
102
+ * The migration under construction. Immutable: every step method answers
103
+ * a new builder; `.document` (memoized) and `toJSON()` are the deep-frozen
104
+ * `$migration` 0.1 document.
105
+ */
106
+ export class Migration {
107
+ #head;
108
+ #steps;
109
+ #names;
110
+ #document;
111
+
112
+ /**
113
+ * @param {any} head - `$migration`, `id`, `from`, `to`, `note?`
114
+ * @param {readonly any[]} steps
115
+ * @param {readonly string[] | null} names - the target model's tables,
116
+ * or `null` when no target model is known (a planned document alone)
117
+ */
118
+ constructor(head, steps, names) {
119
+ this.#head = head;
120
+ this.#steps = steps;
121
+ this.#names = names;
122
+ this.#document = null;
123
+ }
124
+
125
+ /** @param {readonly any[]} steps */
126
+ #with(steps) {
127
+ return new Migration(this.#head, steps, this.#names);
128
+ }
129
+
130
+ /** @param {any} step */
131
+ #append(step) {
132
+ return this.#with([...this.#steps, step]);
133
+ }
134
+
135
+ /** A step's table must be one the target model declares — when the
136
+ * target is known; the runner would fail the statement on a table
137
+ * that does not exist, and the pen can say so first. */
138
+ #requireDeclared(name, what) {
139
+ if (this.#names !== null && !this.#names.includes(name)) {
140
+ throw new LinqBuildError('JL0106',
141
+ `${what} names '${name}', which the target model does not declare — it declares `
142
+ + (this.#names.length === 0 ? 'nothing' : this.#names.map((n) => `'${n}'`).join(', ')));
143
+ }
144
+ }
145
+
146
+ /** One rendered DDL statement. @param {string} sql @param {string} [note] */
147
+ ddl(sql, note = undefined) {
148
+ return this.#append(ddlStep(sql, note));
149
+ }
150
+
151
+ /** One data statement spelled directly (§9.4). @param {string} sql @param {string} [note] */
152
+ sql(sql, note = undefined) {
153
+ return this.#append(sqlStep(sql, note));
154
+ }
155
+
156
+ /**
157
+ * The transform of one table's rows (a `jslt` step). Over a planned
158
+ * document it REPLACES the draft the planner left for that table, in
159
+ * place; otherwise it is appended, for a table the target model
160
+ * declares. Two drafts for one name, a draft-less planned document with
161
+ * no target model, or an undeclared name are `JL0106`.
162
+ * @param {string} name
163
+ * @param {any} spelling - a callback `(row, x) => …`, a `stylesheet(…)`
164
+ * document, or a rules array
165
+ */
166
+ transform(name, spelling) {
167
+ const step = transformStep(name, spelling);
168
+ const drafts = [];
169
+ this.#steps.forEach((s, i) => {
170
+ if (s.kind === 'jslt' && s.draft === true && s.collection === name) drafts.push(i);
171
+ });
172
+ if (drafts.length > 1) {
173
+ throw new LinqBuildError('JL0106',
174
+ `transform() cannot tell which draft to replace: the planned migration carries `
175
+ + `${drafts.length} draft transforms for '${name}'`);
176
+ }
177
+ if (drafts.length === 1) {
178
+ const next = [...this.#steps];
179
+ next[drafts[0]] = step;
180
+ return this.#with(next);
181
+ }
182
+ if (this.#names === null) {
183
+ throw new LinqBuildError('JL0106',
184
+ `transform() over '${name}': the planned migration drafts no transform for it and no `
185
+ + 'target model was given — pass { to } to fromPlanned(), or spell the step with step()');
186
+ }
187
+ this.#requireDeclared(name, 'transform()');
188
+ return this.#append(step);
189
+ }
190
+
191
+ /**
192
+ * An assertion over one table's rows (a `query` step).
193
+ * @param {string} name
194
+ * @param {any} spelling - a predicate `(row) => …`, or a query document
195
+ * @param {{ expect?: 'empty' | 'ebv' }} [options]
196
+ */
197
+ assert(name, spelling, options = undefined) {
198
+ const step = assertStep(name, spelling, options);
199
+ this.#requireDeclared(name, 'assert()');
200
+ return this.#append(step);
201
+ }
202
+
203
+ /**
204
+ * A backfill of stored derived columns (§2.1).
205
+ * @param {string} name
206
+ * @param {readonly any[]} columns
207
+ */
208
+ derive(name, columns) {
209
+ const step = deriveStep(name, columns);
210
+ this.#requireDeclared(name, 'derive()');
211
+ return this.#append(step);
212
+ }
213
+
214
+ /** Any planner-emitted step, verbatim. @param {any} raw */
215
+ step(raw) {
216
+ return this.#append(rawStep(raw));
217
+ }
218
+
219
+ /** The `$migration` 0.1 document, deep-frozen. */
220
+ get document() {
221
+ if (this.#document === null) {
222
+ this.#document = deepFreeze({ ...this.#head, steps: this.#steps.map(copy) });
223
+ }
224
+ return this.#document;
225
+ }
226
+
227
+ toJSON() {
228
+ return this.document;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * A migration between two model documents: `from`/`to` are their shape
234
+ * hashes, the steps what the methods append.
235
+ * @param {{ id: string, from: any, to: any, note?: string }} spec
236
+ * @returns {Migration}
237
+ */
238
+ export function defineMigration(spec) {
239
+ if (!isJsonObject(spec)) {
240
+ throw new LinqBuildError('JL0101', 'defineMigration() takes { id, from, to, note? }');
241
+ }
242
+ for (const key of Object.keys(spec)) {
243
+ if (!['id', 'from', 'to', 'note'].includes(key)) {
244
+ throw new LinqBuildError('JL0101', `defineMigration() does not take '${key}'`);
245
+ }
246
+ }
247
+ if (typeof spec.id !== 'string' || spec.id === '') {
248
+ throw new LinqBuildError('JL0101',
249
+ `defineMigration() id is a non-empty string, got ${describeValue(spec.id)}`, '/id');
250
+ }
251
+ const from = requireModel(spec.from, 'defineMigration() from');
252
+ const to = requireModel(spec.to, 'defineMigration() to');
253
+ const head = { $migration: MIGRATION_VERSION, id: spec.id, from: shapeHashOf(from), to: shapeHashOf(to) };
254
+ if (spec.note !== undefined) {
255
+ if (typeof spec.note !== 'string') {
256
+ throw new LinqBuildError('JL0101',
257
+ `defineMigration() note is a string, got ${describeValue(spec.note)}`, '/note');
258
+ }
259
+ head.note = spec.note;
260
+ }
261
+ return new Migration(head, [], declaredNames(to));
262
+ }
263
+
264
+ /**
265
+ * A planner's document, taken up so its draft steps can be replaced by
266
+ * typed transforms. `from`/`to` model documents type the transforms and
267
+ * are checked against the document's hashes — a model that is not the
268
+ * one the planner planned from is refused (`JL0102`).
269
+ * @param {any} document - a `$migration` 0.1 document, as `jaren-db plan` writes it
270
+ * @param {{ from?: any, to?: any }} [options]
271
+ * @returns {Migration}
272
+ */
273
+ export function fromPlanned(document, options = undefined) {
274
+ const doc = copy(requireJson(document, 'fromPlanned() document'));
275
+ if (!isJsonObject(doc) || doc.$migration !== MIGRATION_VERSION
276
+ || typeof doc.id !== 'string' || doc.id === ''
277
+ || typeof doc.from !== 'string' || typeof doc.to !== 'string' || !Array.isArray(doc.steps)) {
278
+ throw new LinqBuildError('JL0101',
279
+ 'fromPlanned() takes a $migration 0.1 document — { $migration, id, from, to, steps } — '
280
+ + 'as jaren-db plan writes it');
281
+ }
282
+ for (const key of Object.keys(doc)) {
283
+ if (!HEAD_MEMBERS.includes(key)) {
284
+ throw new LinqBuildError('JL0101',
285
+ `fromPlanned() document carries '${key}', which the migration format does not declare`,
286
+ `/${key}`);
287
+ }
288
+ }
289
+ let names = null;
290
+ if (options !== undefined) {
291
+ if (!isJsonObject(options)) {
292
+ throw new LinqBuildError('JL0101', `fromPlanned() options are { from?, to? }, got ${describeValue(options)}`);
293
+ }
294
+ for (const key of Object.keys(options)) {
295
+ if (key !== 'from' && key !== 'to') throw new LinqBuildError('JL0101', `fromPlanned() does not take '${key}'`);
296
+ }
297
+ for (const side of ['from', 'to']) {
298
+ if (options[side] === undefined) continue;
299
+ const model = requireModel(options[side], `fromPlanned() ${side}`);
300
+ const hash = shapeHashOf(model);
301
+ if (hash !== doc[side]) {
302
+ throw new LinqBuildError('JL0102',
303
+ `fromPlanned() ${side} model has shape '${hash}', but the planned migration's ${side} is `
304
+ + `'${doc[side]}' — the model given is not the one the planner planned ${side === 'from' ? 'from' : 'to'}`,
305
+ `/${side}`);
306
+ }
307
+ if (side === 'to') names = declaredNames(model);
308
+ }
309
+ }
310
+ const head = { $migration: MIGRATION_VERSION, id: doc.id, from: doc.from, to: doc.to };
311
+ if (doc.note !== undefined) {
312
+ if (typeof doc.note !== 'string') {
313
+ throw new LinqBuildError('JL0101', `fromPlanned() document note is a string, got ${describeValue(doc.note)}`, '/note');
314
+ }
315
+ head.note = doc.note;
316
+ }
317
+ return new Migration(head, doc.steps.map((step) => rawStep(step)), names);
318
+ }
@@ -0,0 +1,15 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `@jarenjs/linq/migration` — `$migration` 0.1 documents by code.
4
+ * `defineMigration({ id, from, to })` names two model documents and
5
+ * hashes their shapes as the store does; the steps follow in the order
6
+ * they are called — `ddl`, `sql`, `transform` (a `jslt` step whose
7
+ * callback is captured over the old row shape and typed to the new one),
8
+ * `assert` (a `query` step), `derive`, and `step` for any planner-emitted
9
+ * step verbatim. `fromPlanned(document)` takes a planner's document up so
10
+ * a typed `transform` replaces the draft it left. The document is the
11
+ * deliverable: plain, deep-frozen JSON the migration runner takes
12
+ * unchanged; nothing here imports the store or an engine.
13
+ */
14
+
15
+ export { defineMigration, fromPlanned, Migration } from './define.js';