@jarenjs/db 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 +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +18 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The document-step kernel: what a migration's `jslt` transform
|
|
4
|
+
* and `query` assertion steps MEAN, written once for every host that
|
|
5
|
+
* owns documents — the Store's tables and an in-memory or streamed
|
|
6
|
+
* collection alike.
|
|
7
|
+
*
|
|
8
|
+
* A host supplies the compilers and the key members a row carries; the
|
|
9
|
+
* kernel owns the semantics: what a transform may produce, which key
|
|
10
|
+
* members it may not move, whether an assertion answers per document
|
|
11
|
+
* or needs the collection whole, and the exact refusal each violation
|
|
12
|
+
* raises. Iteration stays the host's — a Store walks its table in
|
|
13
|
+
* keyset batches under a savepoint per step, a storeless run makes one
|
|
14
|
+
* pass over a source it can read only once. Sharing the meaning and
|
|
15
|
+
* not the loop is what lets the two hosts differ in cost without ever
|
|
16
|
+
* differing in answer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
20
|
+
import { utf8Length } from './cursor.js';
|
|
21
|
+
|
|
22
|
+
/** The migration format version. */
|
|
23
|
+
export const MIGRATION_VERSION = '0.1';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The step kinds that act on DOCUMENTS, and so run on any host.
|
|
27
|
+
* @type {ReadonlySet<string>}
|
|
28
|
+
*/
|
|
29
|
+
export const DOCUMENT_STEP_KINDS = new Set(['jslt', 'query']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The step kinds that act on a PHYSICAL database — rendered DDL, a
|
|
33
|
+
* data statement spelled as SQL, the table-rebuild procedure, and the
|
|
34
|
+
* backfill that recomputes stored derived COLUMNS. A host without
|
|
35
|
+
* tables cannot honour any of them, and silently skipping one would
|
|
36
|
+
* leave a migration half-applied, so it refuses instead.
|
|
37
|
+
* @type {ReadonlySet<string>}
|
|
38
|
+
*/
|
|
39
|
+
export const PHYSICAL_STEP_KINDS = new Set(['ddl', 'sql', 'rebuild', 'derive']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The refusal a failing step raises, spelled the one way — a reader
|
|
43
|
+
* who has seen it from a Store recognises it from a file.
|
|
44
|
+
* @param {string} migrationId
|
|
45
|
+
* @param {number} index
|
|
46
|
+
* @param {string} kind
|
|
47
|
+
* @param {string} reason
|
|
48
|
+
* @param {Error} [cause]
|
|
49
|
+
* @returns {DbCompileError}
|
|
50
|
+
*/
|
|
51
|
+
export function stepFailure(migrationId, index, kind, reason, cause) {
|
|
52
|
+
return new DbCompileError('JD0023',
|
|
53
|
+
`migration '${migrationId}' step ${index} (${kind}) failed: ${reason}`,
|
|
54
|
+
undefined, cause);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether an assertion is a PER-DOCUMENT predicate — a FLWOR over the
|
|
59
|
+
* collection's documents whose `$where` and `$return` read only the
|
|
60
|
+
* binding — so evaluating it over each batch of documents answers
|
|
61
|
+
* exactly what evaluating it over the whole collection would. Anything
|
|
62
|
+
* that reads the root (`$count: '$[*]'`, a `$let`, a `$distinct`, a
|
|
63
|
+
* nested `$for`) is cross-document and keeps its whole-collection read.
|
|
64
|
+
* @param {any} query
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function isPerDocumentAssertion(query) {
|
|
68
|
+
if (query === null || typeof query !== 'object' || Array.isArray(query)) return false;
|
|
69
|
+
const keys = Object.keys(query);
|
|
70
|
+
if (!keys.includes('$for') || !keys.includes('$return')
|
|
71
|
+
|| keys.some((key) => !['$for', '$where', '$return'].includes(key))) return false;
|
|
72
|
+
const bindings = query.$for;
|
|
73
|
+
if (bindings === null || typeof bindings !== 'object' || Array.isArray(bindings)) return false;
|
|
74
|
+
const names = Object.keys(bindings);
|
|
75
|
+
if (names.length !== 1 || bindings[names[0]] !== '$[*]') return false;
|
|
76
|
+
// a root reference anywhere in the body is a cross-document read
|
|
77
|
+
const body = JSON.stringify({ $where: query.$where ?? null, $return: query.$return });
|
|
78
|
+
return !/"\$(?:[[.]|")/.test(body.replace(/"\$[A-Za-z_][A-Za-z0-9_]*/g, '"'));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The aggregate shapes whose answer over a whole collection is the
|
|
83
|
+
* COMBINATION of its answers over any partition of that collection —
|
|
84
|
+
* which is what lets a host compute them one batch at a time and never
|
|
85
|
+
* hold the collection.
|
|
86
|
+
*
|
|
87
|
+
* Each entry says only how two partial answers combine. What the
|
|
88
|
+
* operator MEANS over a batch — its null handling, its empty-sequence
|
|
89
|
+
* answer, its type coercions — is the engine's, because every batch is
|
|
90
|
+
* answered by the engine's own compiled query. Nothing here reimplements
|
|
91
|
+
* an operator; a fold that disagreed with the engine on any input would
|
|
92
|
+
* be caught by the parity suite, which runs every shape both ways.
|
|
93
|
+
*
|
|
94
|
+
* `$distinct` is here only under an explicit cardinality bound: its
|
|
95
|
+
* partial answer grows with the data, so without one it is not a fold
|
|
96
|
+
* at all.
|
|
97
|
+
* @type {Record<string, { start: any, combine: (accumulated: any, partial: any) => any,
|
|
98
|
+
* growing?: boolean }>}
|
|
99
|
+
*/
|
|
100
|
+
const ASSOCIATIVE_AGGREGATES = {
|
|
101
|
+
// fn:count of a partition sums; the empty collection answers 0
|
|
102
|
+
$count: { start: 0, combine: (accumulated, partial) => accumulated + (partial ?? 0) },
|
|
103
|
+
// fn:sum of the empty sequence is 0, so the identity is 0
|
|
104
|
+
$sum: { start: 0, combine: (accumulated, partial) => accumulated + (partial ?? 0) },
|
|
105
|
+
// fn:min/fn:max of the empty sequence is the empty sequence, so the
|
|
106
|
+
// identity is `undefined` and a partial that is empty contributes
|
|
107
|
+
// nothing
|
|
108
|
+
$max: {
|
|
109
|
+
start: undefined,
|
|
110
|
+
combine: (accumulated, partial) => (partial === undefined ? accumulated
|
|
111
|
+
: (accumulated === undefined || partial > accumulated ? partial : accumulated)),
|
|
112
|
+
},
|
|
113
|
+
$min: {
|
|
114
|
+
start: undefined,
|
|
115
|
+
combine: (accumulated, partial) => (partial === undefined ? accumulated
|
|
116
|
+
: (accumulated === undefined || partial < accumulated ? partial : accumulated)),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** An assertion that is exactly one aggregate over the root. */
|
|
121
|
+
function aggregateShapeOf(query) {
|
|
122
|
+
if (query === null || typeof query !== 'object' || Array.isArray(query)) return null;
|
|
123
|
+
const keys = Object.keys(query);
|
|
124
|
+
if (keys.length !== 1) return null;
|
|
125
|
+
const operator = keys[0];
|
|
126
|
+
return Object.hasOwn(ASSOCIATIVE_AGGREGATES, operator) ? operator : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How a host must run an assertion, and why.
|
|
131
|
+
*
|
|
132
|
+
* - `perDocument` — its answer over each batch is its answer over the
|
|
133
|
+
* whole, so it walks in batches and fails at the first that violates.
|
|
134
|
+
* - `fold` — it is one associative aggregate over the root, so each
|
|
135
|
+
* batch is answered by the engine and the partial answers combine.
|
|
136
|
+
* - `materialize` — nothing above holds: it needs every document at
|
|
137
|
+
* once, which is a cost the host must be told about and bound.
|
|
138
|
+
*
|
|
139
|
+
* The one classifier every host shares, so the Store and a file cannot
|
|
140
|
+
* disagree about what an assertion costs.
|
|
141
|
+
* @param {any} query
|
|
142
|
+
* @returns {{ strategy: 'perDocument' | 'fold' | 'materialize', shape: string | null, reason: string }}
|
|
143
|
+
*/
|
|
144
|
+
export function classifyAssertion(query) {
|
|
145
|
+
if (isPerDocumentAssertion(query)) {
|
|
146
|
+
return {
|
|
147
|
+
strategy: 'perDocument',
|
|
148
|
+
shape: null,
|
|
149
|
+
reason: 'a predicate over each document, whose answer per batch is its answer over the whole',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const aggregate = aggregateShapeOf(query);
|
|
153
|
+
if (aggregate !== null) {
|
|
154
|
+
return {
|
|
155
|
+
strategy: 'fold',
|
|
156
|
+
shape: aggregate,
|
|
157
|
+
reason: `${aggregate} over the root is associative, so batch answers combine`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
strategy: 'materialize',
|
|
162
|
+
shape: null,
|
|
163
|
+
reason: 'the assertion reads the whole root in a way that does not decompose '
|
|
164
|
+
+ 'into independent batches, so every document must be held at once',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compile one document step into the operation both hosts run.
|
|
170
|
+
*
|
|
171
|
+
* A `jslt` step answers `{ apply }`: one document in, its replacement
|
|
172
|
+
* out, refusing a non-document and any move of a declared key member.
|
|
173
|
+
* A `query` step answers `{ assert, perDocument }`: `assert` takes the
|
|
174
|
+
* documents the host has in hand — every document of the collection
|
|
175
|
+
* when `perDocument` is false, any batch of them when it is true.
|
|
176
|
+
*
|
|
177
|
+
* @param {any} step - the migration step
|
|
178
|
+
* @param {number} index - its position, which its refusals name
|
|
179
|
+
* @param {{
|
|
180
|
+
* migrationId: string,
|
|
181
|
+
* compileJslt: (stylesheet: any) => (document: any) => any,
|
|
182
|
+
* compileQuery: (query: any) => any,
|
|
183
|
+
* keys?: string[],
|
|
184
|
+
* }} context - the host's compilers and the key members a document of
|
|
185
|
+
* this collection carries, which a transform may not move
|
|
186
|
+
* @returns {any} the compiled operation
|
|
187
|
+
*/
|
|
188
|
+
export function compileDocumentStep(step, index, context) {
|
|
189
|
+
const { migrationId, compileJslt, compileQuery } = context;
|
|
190
|
+
const keys = context.keys ?? [];
|
|
191
|
+
/** @type {(reason: string, cause?: Error) => never} */
|
|
192
|
+
const fail = (reason, cause) => {
|
|
193
|
+
throw stepFailure(migrationId, index, step.kind, reason, cause);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (step.kind === 'jslt') {
|
|
197
|
+
let transform;
|
|
198
|
+
try {
|
|
199
|
+
transform = compileJslt(step.stylesheet);
|
|
200
|
+
}
|
|
201
|
+
catch (cause) {
|
|
202
|
+
fail(`the stylesheet does not compile: ${/** @type {Error} */ (cause).message}`,
|
|
203
|
+
/** @type {Error} */ (cause));
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
kind: 'jslt',
|
|
207
|
+
collection: step.collection,
|
|
208
|
+
// a transform has no assertion strategy and nothing to fold; both
|
|
209
|
+
// are spelled so every host can read an operation the same way
|
|
210
|
+
strategy: null,
|
|
211
|
+
shape: null,
|
|
212
|
+
fold: null,
|
|
213
|
+
fail,
|
|
214
|
+
/**
|
|
215
|
+
* @param {any} document - read whole, mapped columns folded in
|
|
216
|
+
* @param {any} identity - the host's name for this row, which a
|
|
217
|
+
* refusal quotes so the operator can find it
|
|
218
|
+
* @returns {any} the replacement document
|
|
219
|
+
*/
|
|
220
|
+
apply: (document, identity) => {
|
|
221
|
+
const next = transform(document);
|
|
222
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next))
|
|
223
|
+
fail(`the transform produced a non-document for row ${identity}`);
|
|
224
|
+
// the key is the row's identity, not the document's to change:
|
|
225
|
+
// a body that leaves it out keeps it, a body that rewrites it
|
|
226
|
+
// is refused, as a collection's key is
|
|
227
|
+
for (const key of keys) {
|
|
228
|
+
if (next[key] === undefined) next[key] = document[key];
|
|
229
|
+
else if (next[key] !== document[key]) {
|
|
230
|
+
fail(`the transform changed the key member '${key}' of row ${identity} — `
|
|
231
|
+
+ `key changes are not supported in ${MIGRATION_VERSION}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return next;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let compiled;
|
|
240
|
+
try {
|
|
241
|
+
compiled = compileQuery(step.assert);
|
|
242
|
+
}
|
|
243
|
+
catch (cause) {
|
|
244
|
+
fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
|
|
245
|
+
/** @type {Error} */ (cause));
|
|
246
|
+
}
|
|
247
|
+
const classification = classifyAssertion(step.assert);
|
|
248
|
+
// the engine's own effective-boolean-value, reached through a query
|
|
249
|
+
// that answers its input unchanged: a fold checks the value it
|
|
250
|
+
// computed with exactly the rule the whole-collection path uses
|
|
251
|
+
const identity = compileQuery('$');
|
|
252
|
+
// and the engine's verdict on the EMPTY SEQUENCE, which `$max`/`$min`
|
|
253
|
+
// answer over a collection that offered them nothing. It cannot be
|
|
254
|
+
// asked of `identity` — a query's input may not be `undefined` — so it
|
|
255
|
+
// is evaluated once, by the engine, over a wildcard that selects
|
|
256
|
+
// nothing. The fold never decides this itself.
|
|
257
|
+
const emptySequenceEbv = compileQuery('$[*]').ebv([]);
|
|
258
|
+
|
|
259
|
+
/** The verdict on a computed result, whichever way it was computed. */
|
|
260
|
+
const check = (result, ebvOf) => {
|
|
261
|
+
if (step.expect === 'ebv') {
|
|
262
|
+
if (!ebvOf()) fail('the EBV assertion answered false');
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
if (result !== undefined) {
|
|
266
|
+
const count = Array.isArray(result) ? result.length : 1;
|
|
267
|
+
fail(`the assertion expected an empty sequence, got ${count} item(s)`);
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
kind: 'query',
|
|
274
|
+
collection: step.collection,
|
|
275
|
+
perDocument: classification.strategy === 'perDocument',
|
|
276
|
+
strategy: classification.strategy,
|
|
277
|
+
shape: classification.shape,
|
|
278
|
+
reason: classification.reason,
|
|
279
|
+
fail,
|
|
280
|
+
/**
|
|
281
|
+
* The whole-collection answer: every document at once, which is what
|
|
282
|
+
* `perDocument` batches and `materialize` holds.
|
|
283
|
+
* @param {any[]} documents
|
|
284
|
+
* @returns {null}
|
|
285
|
+
*/
|
|
286
|
+
assert: (documents) => check(compiled(documents), () => compiled.ebv(documents)),
|
|
287
|
+
/**
|
|
288
|
+
* The same answer, one batch at a time. `start` is the aggregate's
|
|
289
|
+
* identity, `combine` folds the engine's answer for a batch into
|
|
290
|
+
* what earlier batches answered, and `finish` applies the step's own
|
|
291
|
+
* verdict to the total — the same verdict `assert` applies.
|
|
292
|
+
* Null when this assertion does not fold.
|
|
293
|
+
*/
|
|
294
|
+
fold: classification.strategy !== 'fold' ? null : {
|
|
295
|
+
start: () => ASSOCIATIVE_AGGREGATES[/** @type {string} */ (classification.shape)].start,
|
|
296
|
+
combine: (accumulated, documents) => ASSOCIATIVE_AGGREGATES[
|
|
297
|
+
/** @type {string} */ (classification.shape)].combine(accumulated, compiled(documents)),
|
|
298
|
+
finish: (accumulated) => check(accumulated,
|
|
299
|
+
() => (accumulated === undefined ? emptySequenceEbv : identity.ebv(accumulated))),
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild', 'derive']);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Structural validation of one migration document, including the
|
|
308
|
+
* draft refusal (`JD0021`).
|
|
309
|
+
* @param {any} migration
|
|
310
|
+
*/
|
|
311
|
+
export function checkMigrationDocument(migration) {
|
|
312
|
+
if (migration === null || typeof migration !== 'object'
|
|
313
|
+
|| migration.$migration !== MIGRATION_VERSION
|
|
314
|
+
|| typeof migration.id !== 'string' || migration.id === ''
|
|
315
|
+
|| typeof migration.from !== 'string' || typeof migration.to !== 'string'
|
|
316
|
+
|| !Array.isArray(migration.steps)) {
|
|
317
|
+
throw new DbCompileError('JD0023',
|
|
318
|
+
`migration '${migration?.id ?? '<unknown>'}' is not a valid ${MIGRATION_VERSION} migration document`);
|
|
319
|
+
}
|
|
320
|
+
for (let i = 0; i < migration.steps.length; i++) {
|
|
321
|
+
const step = migration.steps[i];
|
|
322
|
+
if (step === null || typeof step !== 'object' || !STEP_KINDS.has(step.kind)) {
|
|
323
|
+
throw new DbCompileError('JD0023',
|
|
324
|
+
`migration '${migration.id}' step ${i} has no recognised kind`);
|
|
325
|
+
}
|
|
326
|
+
if (step.kind === 'rebuild'
|
|
327
|
+
&& (typeof step.table !== 'string' || !Array.isArray(step.create)
|
|
328
|
+
|| typeof step.copy !== 'string' || !Array.isArray(step.indexes))) {
|
|
329
|
+
throw new DbCompileError('JD0023',
|
|
330
|
+
`migration '${migration.id}' step ${i} is a rebuild without its rendered `
|
|
331
|
+
+ 'table/create/copy/indexes');
|
|
332
|
+
}
|
|
333
|
+
if (step.kind === 'sql' && typeof step.sql !== 'string') {
|
|
334
|
+
throw new DbCompileError('JD0023',
|
|
335
|
+
`migration '${migration.id}' step ${i} is a sql step without sql text`);
|
|
336
|
+
}
|
|
337
|
+
if (step.kind === 'derive'
|
|
338
|
+
&& (typeof step.collection !== 'string' || !Array.isArray(step.columns)
|
|
339
|
+
|| step.columns.length === 0)) {
|
|
340
|
+
throw new DbCompileError('JD0023',
|
|
341
|
+
`migration '${migration.id}' step ${i} is a derive backfill without its columns`);
|
|
342
|
+
}
|
|
343
|
+
if (step.kind === 'jslt' && step.draft === true) {
|
|
344
|
+
throw new DbCompileError('JD0021',
|
|
345
|
+
`migration '${migration.id}' step ${i} is a DRAFT transform for collection `
|
|
346
|
+
+ `'${step.collection}' — the planner cannot infer a data transform; fill in `
|
|
347
|
+
+ 'the stylesheet (or delete the step for a pure widening) and remove "draft"');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What a MATERIALIZING assertion — one that is neither a per-document
|
|
354
|
+
* predicate nor a single associative aggregate — is allowed to hold.
|
|
355
|
+
*
|
|
356
|
+
* The defaults are deliberately generous and deliberately finite: a
|
|
357
|
+
* migration that used to read a large collection whole now refuses
|
|
358
|
+
* instead, naming the bound and the two assertion shapes that are
|
|
359
|
+
* answered in batches. An unbounded read that nobody declared is the
|
|
360
|
+
* thing this campaign exists to remove, so `null` must be asked for.
|
|
361
|
+
*/
|
|
362
|
+
export const ASSERTION_BOUNDS_DEFAULT = Object.freeze({
|
|
363
|
+
maxRows: 100_000,
|
|
364
|
+
maxBytes: 64 * 1024 * 1024,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/** The declared bounds, or the defaults; `null` means no bound. */
|
|
368
|
+
export function normalizeAssertionBounds(declared) {
|
|
369
|
+
if (declared === undefined) return ASSERTION_BOUNDS_DEFAULT;
|
|
370
|
+
if (declared === null || typeof declared !== 'object')
|
|
371
|
+
throw new TypeError('assertionBounds must be an object of { maxRows, maxBytes }');
|
|
372
|
+
const read = (name) => {
|
|
373
|
+
const value = declared[name];
|
|
374
|
+
if (value === undefined) return ASSERTION_BOUNDS_DEFAULT[name];
|
|
375
|
+
if (value === null || value === Infinity) return null;
|
|
376
|
+
if (!(Number.isSafeInteger(value) && value >= 1))
|
|
377
|
+
throw new TypeError(`assertionBounds.${name} must be a positive integer, or null for no bound`);
|
|
378
|
+
return value;
|
|
379
|
+
};
|
|
380
|
+
return Object.freeze({ maxRows: read('maxRows'), maxBytes: read('maxBytes') });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The guard a MATERIALIZING assertion admits its documents through.
|
|
385
|
+
*
|
|
386
|
+
* One rule, one wording, for every host: a Store admits row by row as it
|
|
387
|
+
* walks, an array host admits the array it was handed. Either way the
|
|
388
|
+
* bound is crossed at the document that would break it, and never after
|
|
389
|
+
* the excess is already held.
|
|
390
|
+
*
|
|
391
|
+
* @param {{ maxRows: number | null, maxBytes: number | null }} bounds
|
|
392
|
+
* @param {string} collection - named by the refusal
|
|
393
|
+
* @param {string} where - what is asserting, as the refusal should say it
|
|
394
|
+
* @returns {{ admit: (document: any, serialized?: string) => void }}
|
|
395
|
+
*/
|
|
396
|
+
export function createAssertionBoundGuard(bounds, collection, where) {
|
|
397
|
+
let rows = 0;
|
|
398
|
+
let bytes = 0;
|
|
399
|
+
const advice = 'Raise the bound, or write the assertion as a per-document predicate or a '
|
|
400
|
+
+ 'single aggregate over the root, which are answered one batch at a time';
|
|
401
|
+
return {
|
|
402
|
+
admit: (document, serialized) => {
|
|
403
|
+
if (bounds.maxRows !== null && rows + 1 > bounds.maxRows) {
|
|
404
|
+
throw new DbRuntimeError('JD2007',
|
|
405
|
+
`${where} must hold every document of '${collection}' at once, and the collection `
|
|
406
|
+
+ `crossed its maxRows bound of ${bounds.maxRows}. ${advice}`,
|
|
407
|
+
{ collection });
|
|
408
|
+
}
|
|
409
|
+
rows++;
|
|
410
|
+
if (bounds.maxBytes === null) return;
|
|
411
|
+
const size = utf8Length(serialized ?? JSON.stringify(document));
|
|
412
|
+
if (bytes + size > bounds.maxBytes) {
|
|
413
|
+
throw new DbRuntimeError('JD2076',
|
|
414
|
+
`${where} must hold every document of '${collection}' at once; the document that `
|
|
415
|
+
+ `would make ${bytes + size} serialised bytes crosses its maxBytes bound of `
|
|
416
|
+
+ `${bounds.maxBytes}. ${advice}`,
|
|
417
|
+
{ collection });
|
|
418
|
+
}
|
|
419
|
+
bytes += size;
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|