@jarenjs/linq 0.49.2 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,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';
|