@jarenjs/db 0.46.5 → 0.56.0
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 +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- 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 -149
- 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 -167
- 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.d.ts +0 -62
- 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 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- 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
package/src/dialects/sqlite.js
CHANGED
|
@@ -33,7 +33,8 @@ function jsonPathText(segments) {
|
|
|
33
33
|
let text = '$';
|
|
34
34
|
for (const segment of segments) {
|
|
35
35
|
if ('index' in segment) {
|
|
36
|
-
|
|
36
|
+
// SQLite counts from the end as `[#-1]`; a bare `[-1]` is a bad path
|
|
37
|
+
text += segment.index < 0 ? `[#${segment.index}]` : `[${segment.index}]`;
|
|
37
38
|
continue;
|
|
38
39
|
}
|
|
39
40
|
// eslint-disable-next-line no-control-regex
|
|
@@ -82,6 +83,9 @@ export const sqliteDialect = createDialect({
|
|
|
82
83
|
upsert: true,
|
|
83
84
|
savepoints: true,
|
|
84
85
|
alterTableFull: false,
|
|
86
|
+
// a GROUP BY / ORDER BY term may name a result alias, so a bucket
|
|
87
|
+
// ladder is written once rather than three times
|
|
88
|
+
groupByAlias: true,
|
|
85
89
|
},
|
|
86
90
|
tableSuffix: ' STRICT',
|
|
87
91
|
// RFC 3339 text → epoch milliseconds, in SQL: the migration planner
|
|
@@ -152,6 +156,22 @@ export const sqliteDialect = createDialect({
|
|
|
152
156
|
`(length(${patternA}) = 0 OR substr(${valueSql}, -length(${patternB})) = ${patternC})`,
|
|
153
157
|
strContains: (valueSql, patternSql) => `instr(${valueSql}, ${patternSql}) > 0`,
|
|
154
158
|
orderNulls: (nullsFirst) => (nullsFirst ? ' NULLS FIRST' : ' NULLS LAST'),
|
|
159
|
+
// the fixed bucket ladder, in integer arithmetic all the way down.
|
|
160
|
+
// `origin + floor((at - origin) / every) * every` is `at` less the
|
|
161
|
+
// NON-NEGATIVE remainder, and `((x % m) + m) % m` is how a language
|
|
162
|
+
// whose `%` truncates towards zero (C's, and SQLite's) spells one —
|
|
163
|
+
// which is the whole of why an instant before 1970 lands in its own
|
|
164
|
+
// bucket rather than the one after it. The column is declared
|
|
165
|
+
// INTEGER, so nothing here converts and nothing rounds.
|
|
166
|
+
timeBucket: (instantSql, originSql, everyA, everyB, everyC) =>
|
|
167
|
+
`(${instantSql} - (((${instantSql} - ${originSql}) % ${everyA} + ${everyB}) % ${everyC}))`,
|
|
168
|
+
// `rows` is COUNT(*) — the D5 count of SOURCE rows, which is not
|
|
169
|
+
// COUNT(value): a measured gap is a row that reported nothing, and
|
|
170
|
+
// the difference between "nobody reported" and "everybody reported a
|
|
171
|
+
// gap" is exactly what the count is for
|
|
172
|
+
groupAggregate: (fn, valueSql) => (valueSql === null
|
|
173
|
+
? 'COUNT(*)'
|
|
174
|
+
: `${{ sum: 'SUM', avg: 'AVG', min: 'MIN', max: 'MAX' }[fn]}(${valueSql})`),
|
|
155
175
|
rowIdentity: () => '"rowid"',
|
|
156
176
|
// membership of the row identity in a bound list — the fetch of a
|
|
157
177
|
// k-nearest plan's candidates. `IN` over the rowid is a primary-key
|
package/src/driver.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { isThenable, chain, toPromise } from '@jarenjs/core/function';
|
|
34
34
|
|
|
35
|
-
import { DbCompileError } from './errors.js';
|
|
35
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
36
36
|
|
|
37
37
|
/** The minimum SQLite the store accepts, asserted at open. */
|
|
38
38
|
export const SQLITE_FLOOR = '3.45.0';
|
|
@@ -96,18 +96,43 @@ export function lazyOpen(specifier, reason, use, args) {
|
|
|
96
96
|
* @returns {{ run: Function, get: Function, all: Function,
|
|
97
97
|
* iterate: Function }}
|
|
98
98
|
*/
|
|
99
|
-
export function wrapStatement(statement) {
|
|
99
|
+
export function wrapStatement(statement, guard = undefined) {
|
|
100
|
+
const before = guard ?? (() => {});
|
|
100
101
|
return {
|
|
101
|
-
run: (params = []) => statement.run(params),
|
|
102
|
-
get: (params = []) => statement.get(params),
|
|
103
|
-
all: (params = []) => statement.all(params),
|
|
102
|
+
run: (params = []) => { before(); return statement.run(params); },
|
|
103
|
+
get: (params = []) => { before(); return statement.get(params); },
|
|
104
|
+
all: (params = []) => { before(); return statement.all(params); },
|
|
104
105
|
iterate: typeof statement.iterate === 'function'
|
|
105
|
-
? (params = []) => /** @type {Function} */ (statement.iterate)(params)
|
|
106
|
-
: (params = []) => chain(statement.all(params),
|
|
107
|
-
(rows) => rows[Symbol.iterator]()),
|
|
106
|
+
? (params = []) => { before(); return /** @type {Function} */ (statement.iterate)(params); }
|
|
107
|
+
: (params = []) => { before(); return chain(statement.all(params),
|
|
108
|
+
(rows) => rows[Symbol.iterator]()); },
|
|
108
109
|
};
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Run a driver call and map its failure — thrown OR rejected — through
|
|
114
|
+
* `wrap`. A `try/catch` around a value-or-promise call saw only the
|
|
115
|
+
* synchronous throw: on an asynchronous driver the failure arrived as a
|
|
116
|
+
* rejection nothing handled, so a rejected write resolved as a success
|
|
117
|
+
* and surfaced later as an unhandled rejection.
|
|
118
|
+
* @template T
|
|
119
|
+
* @param {() => T | Promise<T>} call
|
|
120
|
+
* @param {(error: any) => Error} wrap
|
|
121
|
+
* @returns {T | Promise<T>}
|
|
122
|
+
*/
|
|
123
|
+
export function attempt(call, wrap) {
|
|
124
|
+
let out;
|
|
125
|
+
try {
|
|
126
|
+
out = call();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw wrap(error);
|
|
130
|
+
}
|
|
131
|
+
return isThenable(out)
|
|
132
|
+
? /** @type {Promise<T>} */ (out).then(undefined, (error) => { throw wrap(error); })
|
|
133
|
+
: out;
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
/**
|
|
112
137
|
* Finish a raw binding into the connection contract: probe the library
|
|
113
138
|
* once, assert the version floor, freeze the capability table, and
|
|
@@ -223,6 +248,16 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
|
|
|
223
248
|
let onStack = false;
|
|
224
249
|
/** @type {Array<() => void>} FIFO of work waiting for the owner. */
|
|
225
250
|
const waiting = [];
|
|
251
|
+
/** Set by `close()`: every later call is refused by name rather than
|
|
252
|
+
* leaking the binding's own error (or, on a build that tolerates it,
|
|
253
|
+
* running against a closed handle). */
|
|
254
|
+
let closed = false;
|
|
255
|
+
const requireOpen = () => {
|
|
256
|
+
if (closed) {
|
|
257
|
+
throw new DbRuntimeError('JD2063',
|
|
258
|
+
'the store is closed — a call after close() has no connection to run on');
|
|
259
|
+
}
|
|
260
|
+
};
|
|
226
261
|
|
|
227
262
|
/** Hand the connection to the next waiter, in arrival order. */
|
|
228
263
|
const release = () => {
|
|
@@ -315,9 +350,9 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
|
|
|
315
350
|
capabilities,
|
|
316
351
|
dialect,
|
|
317
352
|
/** @param {string} sql */
|
|
318
|
-
exec: (sql) => raw.exec(sql),
|
|
353
|
+
exec: (sql) => { requireOpen(); return raw.exec(sql); },
|
|
319
354
|
/** @param {string} sql */
|
|
320
|
-
prepare: (sql) => chain(raw.prepare(sql), wrapStatement),
|
|
355
|
+
prepare: (sql) => { requireOpen(); return chain(raw.prepare(sql), (s) => wrapStatement(s, requireOpen)); },
|
|
321
356
|
/** A nested savepoint inside this transaction.
|
|
322
357
|
* @param {(scope: any) => any} fn */
|
|
323
358
|
transaction: (fn) => savepointAround(fn),
|
|
@@ -338,9 +373,14 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
|
|
|
338
373
|
// two apart — give each concurrent writer its own store to separate
|
|
339
374
|
// them). What the gate below does guarantee is that two TRANSACTIONS
|
|
340
375
|
// never interleave, which is what made commits report failure.
|
|
341
|
-
exec: (sql) => raw.exec(sql),
|
|
376
|
+
exec: (sql) => { requireOpen(); return raw.exec(sql); },
|
|
342
377
|
/** @param {string} sql */
|
|
343
|
-
prepare: (sql) => chain(raw.prepare(sql), wrapStatement),
|
|
378
|
+
prepare: (sql) => { requireOpen(); return chain(raw.prepare(sql), (s) => wrapStatement(s, requireOpen)); },
|
|
379
|
+
/** Whether a transaction issued NOW would have to queue: an owner
|
|
380
|
+
* holds the connection and no owning callback is on the stack (a
|
|
381
|
+
* synchronous call from inside the callback nests instead). What a
|
|
382
|
+
* synchronous surface must know before it would hand back a Promise. */
|
|
383
|
+
get mustQueue() { return owned && !onStack; },
|
|
344
384
|
/**
|
|
345
385
|
* A transaction. `fn`'s value is returned; a throw rolls back exactly
|
|
346
386
|
* this level and rethrows. No implicit retry.
|
|
@@ -363,6 +403,7 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
|
|
|
363
403
|
* @param {(scope: any) => any} fn
|
|
364
404
|
*/
|
|
365
405
|
transaction(fn) {
|
|
406
|
+
requireOpen();
|
|
366
407
|
if (onStack) return savepointAround(fn);
|
|
367
408
|
return whenFree(() => {
|
|
368
409
|
owned = true;
|
|
@@ -383,15 +424,21 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
|
|
|
383
424
|
(error) => { release(); throw error; });
|
|
384
425
|
}, 'a transaction');
|
|
385
426
|
},
|
|
386
|
-
|
|
427
|
+
// idempotent: the second close is a no-op on every driver, not a
|
|
428
|
+
// raw error on one and a resolved promise on another
|
|
429
|
+
close: () => {
|
|
430
|
+
if (closed) return undefined;
|
|
431
|
+
closed = true;
|
|
432
|
+
return raw.close();
|
|
433
|
+
},
|
|
387
434
|
registerFunction: typeof raw.registerFunction === 'function'
|
|
388
|
-
? (name, functionOptions, fn) => raw.registerFunction(name, functionOptions, fn)
|
|
435
|
+
? (name, functionOptions, fn) => { requireOpen(); return raw.registerFunction(name, functionOptions, fn); }
|
|
389
436
|
: null,
|
|
390
437
|
registerAggregate: typeof raw.registerAggregate === 'function'
|
|
391
|
-
? (name, spec) => raw.registerAggregate(name, spec)
|
|
438
|
+
? (name, spec) => { requireOpen(); return raw.registerAggregate(name, spec); }
|
|
392
439
|
: null,
|
|
393
440
|
session: typeof raw.session === 'function'
|
|
394
|
-
? (table) => raw.session(table)
|
|
441
|
+
? (table) => { requireOpen(); return raw.session(table); }
|
|
395
442
|
: null,
|
|
396
443
|
});
|
|
397
444
|
}
|
package/src/drivers/wasm.js
CHANGED
|
@@ -166,6 +166,7 @@ export function wasmDriver(handle) {
|
|
|
166
166
|
openConnection(raw, {
|
|
167
167
|
dialect: sqliteDialect,
|
|
168
168
|
synchronous: handle.synchronous === true,
|
|
169
|
+
queueTimeout: options?.queueTimeout,
|
|
169
170
|
declared: {
|
|
170
171
|
sessions: handle.declares?.sessions === true,
|
|
171
172
|
userFunctions: handle.declares?.userFunctions === true,
|
package/src/emit-model.js
CHANGED
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { normalizeEntities } from './model.js';
|
|
35
|
+
import { DbCompileError } from './errors.js';
|
|
36
|
+
|
|
37
|
+
/** The fixed declarations every artifact carries; an entity of one of
|
|
38
|
+
* these names would be emitted twice. */
|
|
39
|
+
const FIXED_DECLARATIONS = new Set(['DateTime', 'Entities', 'EntityInputs', 'EntityMetaMap']);
|
|
35
40
|
|
|
36
41
|
const primitive = (name) => ({ kind: 'primitive', primitive: name });
|
|
37
42
|
const ref = (name) => ({ kind: 'ref', ref: name });
|
|
@@ -75,6 +80,15 @@ export function entityEmitModel(model, options) {
|
|
|
75
80
|
throw new TypeError("entityEmitModel needs { compile: compileEmitModel } injected");
|
|
76
81
|
const entities = normalizeEntities(model);
|
|
77
82
|
const entityNames = [...entities.keys()];
|
|
83
|
+
for (const name of entityNames) {
|
|
84
|
+
if (FIXED_DECLARATIONS.has(name)
|
|
85
|
+
|| (name.endsWith('Input') && entityNames.includes(name.slice(0, -'Input'.length)))) {
|
|
86
|
+
throw new DbCompileError('JD0005',
|
|
87
|
+
`entity '${name}' collides with a fixed declaration of the emitted artifact `
|
|
88
|
+
+ `(${[...FIXED_DECLARATIONS].join(', ')}, and <Entity>Input for every entity)`,
|
|
89
|
+
`/entities/${name}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
78
92
|
|
|
79
93
|
// every entity name, input name and the brand are reserved up front
|
|
80
94
|
// so nested-shape hints can never steal them
|
package/src/emit.js
CHANGED
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
|
|
19
19
|
import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A promoted path the dialect's JSON path grammar cannot spell (a
|
|
23
|
+
* member name holding a double quote or a control character). The
|
|
24
|
+
* planner promotes by SCHEMA, not by grammar, so the query engine
|
|
25
|
+
* catches this and runs the document in the set residual instead.
|
|
26
|
+
*/
|
|
27
|
+
export class UnrepresentablePath extends Error {}
|
|
28
|
+
|
|
21
29
|
/**
|
|
22
30
|
* @typedef {{ external: string } | { literal: unknown } |
|
|
23
31
|
* { derived: { kind: 'bboxAxis', external: string,
|
|
@@ -117,9 +125,8 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
117
125
|
const pathTextOf = (ref) => {
|
|
118
126
|
const text = dialect.jsonPathText(ref.segments);
|
|
119
127
|
if (text === null) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
throw new Error('emit: a promoted path is not representable in the dialect JSON path grammar');
|
|
128
|
+
throw new UnrepresentablePath('a member name the dialect\'s JSON path grammar cannot '
|
|
129
|
+
+ 'carry (a double quote or a control character) runs in the residual');
|
|
123
130
|
}
|
|
124
131
|
return text;
|
|
125
132
|
};
|
|
@@ -273,6 +280,17 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
273
280
|
}
|
|
274
281
|
};
|
|
275
282
|
|
|
283
|
+
// The temporal bucket's ladder, written ONCE and named: the SELECT
|
|
284
|
+
// list carries it, and the grouping and the ordering name the alias.
|
|
285
|
+
// Writing it three times would triple its parameters, and a bucket
|
|
286
|
+
// start is exactly the kind of value the caller wants to read back.
|
|
287
|
+
const bucketSql = plan.bucket === null ? null
|
|
288
|
+
: dialect.timeBucket(valueOf(plan.bucket.ref),
|
|
289
|
+
param({ literal: plan.bucket.origin }),
|
|
290
|
+
param({ literal: plan.bucket.every }),
|
|
291
|
+
param({ literal: plan.bucket.every }),
|
|
292
|
+
param({ literal: plan.bucket.every }));
|
|
293
|
+
|
|
276
294
|
const selection = plan.rank !== null
|
|
277
295
|
// the k-nearest fetch: the row identity and the packed column
|
|
278
296
|
// under the pushed WHERE, and nothing that orders or limits — the
|
|
@@ -280,15 +298,30 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
280
298
|
// the rank loses to fetching the column and ranking in the engine,
|
|
281
299
|
// and none of them runs where no function can be registered)
|
|
282
300
|
? `${dialect.rowIdentity()} AS ${q('rid')}, ${q(plan.rank.column)} AS ${q('vec')}`
|
|
283
|
-
: plan.
|
|
284
|
-
? `${
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
: plan.bucket !== null
|
|
302
|
+
? [`${bucketSql} AS ${q(plan.bucket.as)}`,
|
|
303
|
+
...plan.bucket.aggregates.map((entry) =>
|
|
304
|
+
`${dialect.groupAggregate(entry.fn,
|
|
305
|
+
entry.ref === null ? null : valueOf(entry.ref))} AS ${q(entry.as)}`)].join(', ')
|
|
306
|
+
: plan.aggregate === null
|
|
307
|
+
? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
|
|
308
|
+
: plan.aggregate.fn === 'count'
|
|
309
|
+
? `COUNT(*) AS ${q('value')}`
|
|
310
|
+
: `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
|
|
288
311
|
|
|
289
312
|
let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
|
|
290
313
|
if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
|
|
291
|
-
if (plan.
|
|
314
|
+
if (plan.bucket !== null) {
|
|
315
|
+
// `first-seen` is the engine's own group order (§6.5, first
|
|
316
|
+
// appearance), which over a collection is the group's earliest row
|
|
317
|
+
// identity — the same tiebreaker the ungrouped fetch appends
|
|
318
|
+
const alias = q(plan.bucket.as);
|
|
319
|
+
const order = plan.bucket.order === 'first-seen'
|
|
320
|
+
? dialect.groupAggregate('min', dialect.rowIdentity())
|
|
321
|
+
: `${alias} ${plan.bucket.order === 'desc' ? 'DESC' : 'ASC'}`;
|
|
322
|
+
sql += ` GROUP BY ${alias} ORDER BY ${order}`;
|
|
323
|
+
}
|
|
324
|
+
if (plan.aggregate === null && plan.rank === null && plan.bucket === null) {
|
|
292
325
|
const terms = (plan.order ?? []).map((term) => {
|
|
293
326
|
// Jaren's default sorts an empty key least: NULLS FIRST when
|
|
294
327
|
// ascending, NULLS LAST when descending — and mirrored for
|
package/src/entity.js
CHANGED
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
|
|
20
20
|
} from '@jarenjs/core/dates/rfc3339';
|
|
21
21
|
|
|
22
|
-
import { DbRuntimeError } from './errors.js';
|
|
23
|
-
import { chain } from './driver.js';
|
|
22
|
+
import { DbRuntimeError, isDuplicateKeyError } from './errors.js';
|
|
23
|
+
import { chain, attempt } from './driver.js';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* The write/read machinery for one entity, prepared once.
|
|
@@ -132,6 +132,28 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
132
132
|
return doc;
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
/** The document as a read will answer it. For a column-mapped scalar,
|
|
136
|
+
* JSON `null` and absence both store as SQL NULL and read back ABSENT
|
|
137
|
+
* (§9.3) — so a value a write RETURNS drops it too, and the promise
|
|
138
|
+
* this file opens with holds: the value the application sees IS the
|
|
139
|
+
* value stored. A property that needs present-`null` declares
|
|
140
|
+
* `column: "json"` and stays in the document, where it survives; an
|
|
141
|
+
* epoch column keeps its string in the document for the same reason. */
|
|
142
|
+
const asStored = (doc) => {
|
|
143
|
+
let out = doc;
|
|
144
|
+
const drop = (name) => {
|
|
145
|
+
if (!(name in out) || (out[name] !== null && out[name] !== undefined)) return;
|
|
146
|
+
if (out === doc) out = { ...doc };
|
|
147
|
+
delete out[name];
|
|
148
|
+
};
|
|
149
|
+
for (const column of scalarColumns) {
|
|
150
|
+
if (column.epoch) continue; // the string is in the document, null and all
|
|
151
|
+
drop(column.name);
|
|
152
|
+
}
|
|
153
|
+
for (const fk of fkColumns) drop(fk);
|
|
154
|
+
return out;
|
|
155
|
+
};
|
|
156
|
+
|
|
135
157
|
// defaults, compiled once
|
|
136
158
|
const defaulters = [];
|
|
137
159
|
const updateStamps = [];
|
|
@@ -139,9 +161,13 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
139
161
|
const declared = property.default;
|
|
140
162
|
if (declared === undefined || property.relation !== undefined) continue;
|
|
141
163
|
if (declared === 'now' || declared === 'updated') {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
164
|
+
// a `date` property takes the calendar date; a date-time stamp on
|
|
165
|
+
// it was invalid under its own format and refused by an epoch column
|
|
166
|
+
const stamp = property.format === 'date'
|
|
167
|
+
? () => new Date().toISOString().slice(0, 10)
|
|
168
|
+
: () => new Date().toISOString();
|
|
169
|
+
defaulters.push({ name: property.name, fill: stamp });
|
|
170
|
+
if (declared === 'updated') updateStamps.push({ name: property.name, fill: stamp });
|
|
145
171
|
continue;
|
|
146
172
|
}
|
|
147
173
|
if (declared === 'uuid') {
|
|
@@ -162,12 +188,42 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
162
188
|
for (const { name, fill } of defaulters) {
|
|
163
189
|
if (out[name] === undefined) out[name] = fill(out);
|
|
164
190
|
}
|
|
191
|
+
// the version token starts at 0 on insert: a row written with SQL
|
|
192
|
+
// NULL never matched the tracker's `WHERE ver = 0` and could not be
|
|
193
|
+
// saved through the unit of work at all
|
|
194
|
+
if (!updating && entity.version !== null && entity.version !== undefined
|
|
195
|
+
&& out[entity.version] === undefined) {
|
|
196
|
+
out[entity.version] = 0;
|
|
197
|
+
}
|
|
165
198
|
if (updating) {
|
|
166
199
|
for (const { name, fill } of updateStamps) out[name] = fill(out);
|
|
167
200
|
}
|
|
168
201
|
return out;
|
|
169
202
|
};
|
|
170
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Refuse relation members a write cannot store: every relation member
|
|
206
|
+
* is a projection (§10.1), except a many-to-many MEMBERSHIP array,
|
|
207
|
+
* which `create()`/`add()` attach through the join table.
|
|
208
|
+
* @param {any} doc
|
|
209
|
+
* @param {string} verb
|
|
210
|
+
* @param {boolean} memberships - whether membership arrays are taken
|
|
211
|
+
*/
|
|
212
|
+
const refuseProjections = (doc, verb, memberships) => {
|
|
213
|
+
for (const name of relationNames) {
|
|
214
|
+
const value = doc?.[name];
|
|
215
|
+
if (value === undefined || (Array.isArray(value) && value.length === 0)) continue;
|
|
216
|
+
const relation = entity.properties.get(name).relation;
|
|
217
|
+
if (memberships && relation.kind === 'manyToMany' && Array.isArray(value)) continue;
|
|
218
|
+
throw new DbRuntimeError('JD2003',
|
|
219
|
+
`'${name}' is a relation member — ${verb}() stores no projections; `
|
|
220
|
+
+ (relation.kind === 'manyToMany'
|
|
221
|
+
? 'membership changes through the unit of work (put + saveChanges)'
|
|
222
|
+
: 'write the related entities themselves'),
|
|
223
|
+
{ docPath, collection: entity.name });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
171
227
|
const checkValid = (doc) => {
|
|
172
228
|
if (validate === null) return;
|
|
173
229
|
const outcome = validate(doc);
|
|
@@ -233,7 +289,13 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
233
289
|
};
|
|
234
290
|
|
|
235
291
|
const wrapWrite = (error, key) => {
|
|
236
|
-
|
|
292
|
+
const code = /** @type {any} */ (error)?.code;
|
|
293
|
+
if (typeof code === 'string' && code.startsWith('JD')) return error;
|
|
294
|
+
if (keys.length === 1 && isDuplicateKeyError(error, table, keys[0])) {
|
|
295
|
+
return new DbRuntimeError('JD2001',
|
|
296
|
+
`a '${entity.name}' already exists under key ${JSON.stringify(key)}`,
|
|
297
|
+
{ docPath, collection: entity.name, key, cause: error });
|
|
298
|
+
}
|
|
237
299
|
return new DbRuntimeError('JD2005',
|
|
238
300
|
`the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
|
|
239
301
|
key === undefined
|
|
@@ -269,7 +331,7 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
269
331
|
complete: (doc, { updating }) => {
|
|
270
332
|
const completed = applyDefaults(doc, { updating });
|
|
271
333
|
checkValid(completed);
|
|
272
|
-
return completed;
|
|
334
|
+
return asStored(completed);
|
|
273
335
|
},
|
|
274
336
|
validateOnly: (doc) => checkValid(doc),
|
|
275
337
|
stampUpdated: (doc) => {
|
|
@@ -280,15 +342,7 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
280
342
|
},
|
|
281
343
|
normalizeKey: (key) => normalizeKeyArg(key),
|
|
282
344
|
create(doc) {
|
|
283
|
-
|
|
284
|
-
const value = doc?.[name];
|
|
285
|
-
if (value !== undefined && (!Array.isArray(value) || value.length > 0)) {
|
|
286
|
-
throw new DbRuntimeError('JD2003',
|
|
287
|
-
`'${name}' is a relation member — create() stores no `
|
|
288
|
-
+ 'projections; use the unit of work for membership',
|
|
289
|
-
{ docPath, collection: entity.name });
|
|
290
|
-
}
|
|
291
|
-
}
|
|
345
|
+
refuseProjections(doc, 'create', true);
|
|
292
346
|
const completed = applyDefaults(doc, { updating: false });
|
|
293
347
|
checkValid(completed);
|
|
294
348
|
const { values, rest } = split(completed);
|
|
@@ -296,17 +350,11 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
296
350
|
const sql = insertSqlFor(names);
|
|
297
351
|
return chain(prepared(`insert:${names.join(',')}`, sql), (statement) => {
|
|
298
352
|
const params = [...values.map((value) => value.value), JSON.stringify(rest)];
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
catch (error) {
|
|
306
|
-
throw wrapWrite(error, completed[keys[0]]);
|
|
307
|
-
}
|
|
308
|
-
if (out !== null) return { ...completed, [autoKey]: out.key };
|
|
309
|
-
return completed;
|
|
353
|
+
const returning = autoKey !== null && !names.includes(autoKey);
|
|
354
|
+
return chain(
|
|
355
|
+
attempt(() => (returning ? statement.get(params) : statement.run(params)),
|
|
356
|
+
(error) => wrapWrite(error, completed[keys[0]])),
|
|
357
|
+
(out) => asStored(returning ? { ...completed, [autoKey]: out.key } : completed));
|
|
310
358
|
});
|
|
311
359
|
},
|
|
312
360
|
get(key) {
|
|
@@ -317,12 +365,22 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
317
365
|
},
|
|
318
366
|
update(key, changes) {
|
|
319
367
|
const parts = normalizeKeyArg(key);
|
|
368
|
+
refuseProjections(changes, 'update', false);
|
|
320
369
|
return chain(this.get(key), (current) => {
|
|
321
370
|
if (current === undefined) {
|
|
322
371
|
throw new DbRuntimeError('JD2006',
|
|
323
372
|
`no '${entity.name}' to update under that key`,
|
|
324
373
|
{ docPath, collection: entity.name });
|
|
325
374
|
}
|
|
375
|
+
for (const keyName of keys) {
|
|
376
|
+
// the key identifies the row the UPDATE addresses; rewriting it
|
|
377
|
+
// through `changes` moved rows out from under the tracker
|
|
378
|
+
if (changes?.[keyName] !== undefined && changes[keyName] !== current[keyName]) {
|
|
379
|
+
throw new DbRuntimeError('JD2003',
|
|
380
|
+
`'${keyName}' is the primary key — update() cannot rewrite it; delete and create`,
|
|
381
|
+
{ docPath, collection: entity.name, key: parts[0] });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
326
384
|
const next = applyDefaults({ ...current, ...changes }, { updating: true });
|
|
327
385
|
// an explicit update is last-write-wins by contract (§11.2),
|
|
328
386
|
// but it still moves a declared version token so optimistic
|
|
@@ -337,31 +395,18 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
337
395
|
].join(', ');
|
|
338
396
|
const sql = `UPDATE ${q(table)} SET ${assignments} `
|
|
339
397
|
+ `WHERE ${keyWhere(values.length + 1)}`;
|
|
340
|
-
return chain(prepared(`update:${values.length}`, sql), (statement) =>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
catch (error) {
|
|
346
|
-
throw wrapWrite(error, parts[0]);
|
|
347
|
-
}
|
|
348
|
-
return next;
|
|
349
|
-
});
|
|
398
|
+
return chain(prepared(`update:${values.length}`, sql), (statement) =>
|
|
399
|
+
chain(attempt(() => statement.run([...values.map((value) => value.value),
|
|
400
|
+
JSON.stringify(rest), ...parts]), (error) => wrapWrite(error, parts[0])),
|
|
401
|
+
() => asStored(next)));
|
|
350
402
|
});
|
|
351
403
|
},
|
|
352
404
|
delete(key) {
|
|
353
405
|
const parts = normalizeKeyArg(key);
|
|
354
406
|
const sql = `DELETE FROM ${q(table)} WHERE ${keyWhere(0)}`;
|
|
355
|
-
return chain(prepared('delete', sql), (statement) =>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
out = statement.run(parts);
|
|
359
|
-
}
|
|
360
|
-
catch (error) {
|
|
361
|
-
throw wrapWrite(error, parts[0]);
|
|
362
|
-
}
|
|
363
|
-
return chain(out, (result) => Number(result?.changes ?? 0) > 0);
|
|
364
|
-
});
|
|
407
|
+
return chain(prepared('delete', sql), (statement) =>
|
|
408
|
+
chain(attempt(() => statement.run(parts), (error) => wrapWrite(error, parts[0])),
|
|
409
|
+
(result) => Number(result?.changes ?? 0) > 0));
|
|
365
410
|
},
|
|
366
411
|
};
|
|
367
412
|
}
|
package/src/errors.js
CHANGED
|
@@ -18,6 +18,23 @@ import { CodedError } from '@jarenjs/core/errors';
|
|
|
18
18
|
* this package can raise, proven in sync with MODEL-FORMAT.md §7's
|
|
19
19
|
* normative table by a test.
|
|
20
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Whether a database error is the unique-key collision of `table.column`
|
|
23
|
+
* — the one failure every insert path reports as `JD2001` rather than
|
|
24
|
+
* the generic `JD2005`. One detector for the collection cores, the
|
|
25
|
+
* entity cores and the unit of work, so no path wraps it differently.
|
|
26
|
+
* @param {any} error
|
|
27
|
+
* @param {string} table
|
|
28
|
+
* @param {string} column
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isDuplicateKeyError(error, table, column) {
|
|
32
|
+
if (error?.errcode === 1555) return true;
|
|
33
|
+
return typeof error?.message === 'string'
|
|
34
|
+
&& error.message.includes('UNIQUE constraint failed')
|
|
35
|
+
&& error.message.includes(`${table}.${column}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
export const DB_CODES = Object.freeze({
|
|
22
39
|
JD0001: 'the SQLite library is below the supported floor',
|
|
23
40
|
JD0002: 'the declared model disagrees with the existing database',
|
|
@@ -30,10 +47,12 @@ export const DB_CODES = Object.freeze({
|
|
|
30
47
|
JD0030: 'an unknown x-entity member was declared',
|
|
31
48
|
JD0031: 'relation declarations contradict each other',
|
|
32
49
|
JD0032: 'the include specification is invalid',
|
|
50
|
+
JD0033: 'an entity query names no entity array',
|
|
33
51
|
JD0040: 'the save spans a relation cycle',
|
|
34
52
|
JD0050: 'live queries require change capture',
|
|
35
53
|
JD0051: 'the demanded live mode is unavailable',
|
|
36
54
|
JD0052: 'the live-query bound was reached',
|
|
55
|
+
JD0053: 'the live event-time declaration is invalid',
|
|
37
56
|
JD0020: "the migration's from-shape does not match the database",
|
|
38
57
|
JD0021: 'the migration is missing a required data transform',
|
|
39
58
|
JD0022: 'an applied migration disagrees with the history record',
|
|
@@ -51,6 +70,7 @@ export const DB_CODES = Object.freeze({
|
|
|
51
70
|
JD2060: 'the maintained live state exceeded its bound',
|
|
52
71
|
JD2061: 'another context owns the database',
|
|
53
72
|
JD2062: 'the store closed with job handlers still in flight',
|
|
73
|
+
JD2063: 'the store is closed',
|
|
54
74
|
});
|
|
55
75
|
|
|
56
76
|
/**
|
|
@@ -82,6 +102,9 @@ export const DB_CODES = Object.freeze({
|
|
|
82
102
|
* silently ignored mapping directive is a data-loss bug waiting
|
|
83
103
|
* - `JD0031` — two relation declarations whose inverses contradict
|
|
84
104
|
* (different `via`, impossible `many` pairings)
|
|
105
|
+
* - `JD0033` — a document handed to `store.execute()` over entities
|
|
106
|
+
* ranges over no declared entity array (`$.<Entity>[*]`); the root
|
|
107
|
+
* is the map of entity arrays, so there are no rows to answer
|
|
85
108
|
* - `JD0032` — a graph-load include specification is invalid: an
|
|
86
109
|
* unknown relation, a cycle, an untranslatable filter, or the
|
|
87
110
|
* depth bound exceeded (the bound is printed, never silent)
|
|
@@ -94,6 +117,8 @@ export const DB_CODES = Object.freeze({
|
|
|
94
117
|
* classifies as re-run; the reason names the forcing construct
|
|
95
118
|
* - `JD0052` — registering would exceed the store's `live.maxQueries`
|
|
96
119
|
* bound; the bound is printed, never silent
|
|
120
|
+
* - `JD0053` — a live query's `eventTime` names a member it does not
|
|
121
|
+
* admit, or a watermark/retention that is not a finite span
|
|
97
122
|
* - `JD0020` — a migration's `from` hash does not match the
|
|
98
123
|
* database's recorded shape; running it would corrupt
|
|
99
124
|
* - `JD0021` — a draft transform was not filled in, or a document no
|
|
@@ -149,6 +174,9 @@ export class DbCompileError extends CodedError {
|
|
|
149
174
|
* - `JD2061` — a second context tried to open a database whose
|
|
150
175
|
* storage grants one context exclusive access (the owner topology
|
|
151
176
|
* of LIVE-FORMAT §11); connect to the owner instead
|
|
177
|
+
* - `JD2063` — a call after `close()`: every entry point of a closed
|
|
178
|
+
* store refuses by name rather than leaking the driver's own error,
|
|
179
|
+
* and a second `close()` is a no-op on every driver
|
|
152
180
|
*/
|
|
153
181
|
export class DbRuntimeError extends CodedError {
|
|
154
182
|
/**
|
package/src/index.js
CHANGED
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
} from './ddl.js';
|
|
22
22
|
export {
|
|
23
23
|
planQuery, assertDecidedKind, entityShape, entityPathRef,
|
|
24
|
-
planEntityPredicate, planEntityQuery,
|
|
24
|
+
planEntityPredicate, planEntityQuery, collectEntityRoots, entityRoot,
|
|
25
25
|
} from './plan.js';
|
|
26
26
|
export { emitPlan, createEntityPredicateEmitters, emitEntityPlan } from './emit.js';
|
|
27
27
|
export { mergeEntityRow, parseGraphRow } from './graph.js';
|
|
@@ -45,7 +45,7 @@ export {
|
|
|
45
45
|
applyMandatoryPredicate, applyRowBound,
|
|
46
46
|
} from './profile.js';
|
|
47
47
|
export { translatePatch } from './patch-sql.js';
|
|
48
|
-
export { normalizeEntities, explainMapping } from './model.js';
|
|
48
|
+
export { normalizeEntities, explainMapping, relationTables } from './model.js';
|
|
49
49
|
export { planEntity, planJoinTable } from './ddl.js';
|
|
50
50
|
export { entityCore } from './entity.js';
|
|
51
51
|
export { entityEmitModel } from './emit-model.js';
|