@jarenjs/db 0.49.2 → 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 +27 -15
- package/README.md +141 -41
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +38 -9
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +232 -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/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialects/sqlite.js +2 -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 +10 -3
- package/src/entity.js +92 -47
- package/src/errors.js +25 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +135 -38
- package/src/query.js +138 -13
- package/src/store.js +221 -66
- package/src/tracker.js +173 -48
- package/types/index.d.ts +152 -10
- 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 -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
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
|
};
|
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,6 +47,7 @@ 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',
|
|
@@ -52,6 +70,7 @@ export const DB_CODES = Object.freeze({
|
|
|
52
70
|
JD2060: 'the maintained live state exceeded its bound',
|
|
53
71
|
JD2061: 'another context owns the database',
|
|
54
72
|
JD2062: 'the store closed with job handlers still in flight',
|
|
73
|
+
JD2063: 'the store is closed',
|
|
55
74
|
});
|
|
56
75
|
|
|
57
76
|
/**
|
|
@@ -83,6 +102,9 @@ export const DB_CODES = Object.freeze({
|
|
|
83
102
|
* silently ignored mapping directive is a data-loss bug waiting
|
|
84
103
|
* - `JD0031` — two relation declarations whose inverses contradict
|
|
85
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
|
|
86
108
|
* - `JD0032` — a graph-load include specification is invalid: an
|
|
87
109
|
* unknown relation, a cycle, an untranslatable filter, or the
|
|
88
110
|
* depth bound exceeded (the bound is printed, never silent)
|
|
@@ -152,6 +174,9 @@ export class DbCompileError extends CodedError {
|
|
|
152
174
|
* - `JD2061` — a second context tried to open a database whose
|
|
153
175
|
* storage grants one context exclusive access (the owner topology
|
|
154
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
|
|
155
180
|
*/
|
|
156
181
|
export class DbRuntimeError extends CodedError {
|
|
157
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';
|
package/src/jobs.js
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* timer, and its abandoned job recovers by lease expiry (§5).
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { chain } from './driver.js';
|
|
35
|
+
import { chain, attempt } from './driver.js';
|
|
36
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
36
37
|
|
|
37
38
|
export const JOBS_TABLE = '_jaren_jobs';
|
|
38
39
|
export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
|
|
@@ -147,12 +148,25 @@ export function createJobEngine(options) {
|
|
|
147
148
|
const random = options.random ?? Math.random;
|
|
148
149
|
const defaults = { ...JOB_DEFAULTS, ...options.defaults };
|
|
149
150
|
|
|
151
|
+
/** Every queue statement failure rides the store's own wrap (§9):
|
|
152
|
+
* a read-only file, a locked database, a constraint — never the
|
|
153
|
+
* driver's raw error. */
|
|
154
|
+
const wrapJobs = (error) => (typeof error?.code === 'string' && error.code.startsWith('JD')
|
|
155
|
+
? error
|
|
156
|
+
: new DbRuntimeError('JD2005',
|
|
157
|
+
`the database rejected the operation: ${error?.message ?? String(error)}`,
|
|
158
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, cause: error }));
|
|
150
159
|
/** @type {Map<string, any>} */
|
|
151
160
|
const statements = new Map();
|
|
152
161
|
const prepared = (key, sql) => {
|
|
153
162
|
let statement = statements.get(key);
|
|
154
163
|
if (statement === undefined) {
|
|
155
|
-
|
|
164
|
+
const raw = connection.prepare(sql);
|
|
165
|
+
statement = {
|
|
166
|
+
run: (params) => attempt(() => raw.run(params), wrapJobs),
|
|
167
|
+
get: (params) => attempt(() => raw.get(params), wrapJobs),
|
|
168
|
+
all: (params) => attempt(() => raw.all(params), wrapJobs),
|
|
169
|
+
};
|
|
156
170
|
statements.set(key, statement);
|
|
157
171
|
}
|
|
158
172
|
return statement;
|
|
@@ -164,13 +178,27 @@ export function createJobEngine(options) {
|
|
|
164
178
|
for (const wake of [...wakers]) wake();
|
|
165
179
|
};
|
|
166
180
|
|
|
167
|
-
|
|
181
|
+
// the tables are created here, or refused here: a read-only store
|
|
182
|
+
// leaked the driver's "attempt to write a readonly database"
|
|
183
|
+
const ready = attempt(() => connection.exec(CREATE_JOBS), (error) => new DbCompileError('JD0002',
|
|
184
|
+
`the job tables could not be created (${error?.message ?? String(error)}) — `
|
|
185
|
+
+ 'a read-only store creates nothing; open it read-write once, or without jobs',
|
|
186
|
+
'/jobs', error));
|
|
168
187
|
|
|
169
188
|
const enqueue = (kind, payload, enqueueOptions) => {
|
|
170
189
|
if (typeof kind !== 'string' || kind === '') {
|
|
171
190
|
throw new TypeError('enqueue: "kind" must be a non-empty string');
|
|
172
191
|
}
|
|
173
192
|
const id = enqueueOptions?.id ?? crypto.randomUUID();
|
|
193
|
+
// a `runAt` that is not a number stored as NaN and left the job
|
|
194
|
+
// pending forever; a Date could not even be bound
|
|
195
|
+
if (enqueueOptions?.runAt !== undefined && !Number.isFinite(enqueueOptions.runAt)) {
|
|
196
|
+
throw new TypeError('enqueue: "runAt" is an epoch in milliseconds (a finite number)');
|
|
197
|
+
}
|
|
198
|
+
if (enqueueOptions?.maxAttempts !== undefined
|
|
199
|
+
&& !(Number.isInteger(enqueueOptions.maxAttempts) && enqueueOptions.maxAttempts >= 1)) {
|
|
200
|
+
throw new TypeError('enqueue: "maxAttempts" is a positive integer');
|
|
201
|
+
}
|
|
174
202
|
const at = now();
|
|
175
203
|
return chain(prepared('enqueue', `INSERT INTO "${JOBS_TABLE}"
|
|
176
204
|
(id, kind, payload, state, run_at, max_attempts, created_at, updated_at)
|
|
@@ -341,8 +369,12 @@ export function createJobEngine(options) {
|
|
|
341
369
|
const kinds = Object.keys(handlers);
|
|
342
370
|
const owner = workerOptions.owner ?? crypto.randomUUID();
|
|
343
371
|
const concurrency = workerOptions.concurrency ?? 1;
|
|
372
|
+
if (!(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
373
|
+
// `Array.from({ length: 0 })` started a worker that never claimed
|
|
374
|
+
throw new TypeError('createWorker: "concurrency" is a positive integer');
|
|
375
|
+
}
|
|
344
376
|
const pollInterval = workerOptions.pollInterval ?? defaults.pollInterval;
|
|
345
|
-
const stats = { claims: 0, completions: 0, failures: 0, polls: 0, wakes: 0 };
|
|
377
|
+
const stats = { claims: 0, completions: 0, failures: 0, polls: 0, wakes: 0, claimErrors: 0 };
|
|
346
378
|
|
|
347
379
|
let running = false;
|
|
348
380
|
/** @type {Promise<void>[]} */
|
|
@@ -448,7 +480,10 @@ export function createJobEngine(options) {
|
|
|
448
480
|
kinds, owner, leaseMs: workerOptions.leaseMs }));
|
|
449
481
|
}
|
|
450
482
|
catch {
|
|
451
|
-
|
|
483
|
+
// a storage failure backs off to the poll — COUNTED, so a
|
|
484
|
+
// worker on a read-only store is not silently idle forever
|
|
485
|
+
stats.claimErrors += 1;
|
|
486
|
+
job = undefined;
|
|
452
487
|
}
|
|
453
488
|
if (!running || loopSession.cancelled) return;
|
|
454
489
|
if (job === undefined) {
|
package/src/live-time.js
CHANGED
|
@@ -77,7 +77,7 @@ export function normalizeEventTime(options, collection) {
|
|
|
77
77
|
const declared = options?.eventTime;
|
|
78
78
|
if (declared === undefined || declared === null) return null;
|
|
79
79
|
const refuse = (reason) => {
|
|
80
|
-
throw new DbCompileError('JD0053', reason, {
|
|
80
|
+
throw new DbCompileError('JD0053', reason, `/collections/${collection}`);
|
|
81
81
|
};
|
|
82
82
|
if (!isJsonObject(declared))
|
|
83
83
|
refuse('live eventTime is an object with a path, a watermark and a retention');
|
|
@@ -178,6 +178,15 @@ export function classifyEventTime(inner, windowed, keyed, eventTime) {
|
|
|
178
178
|
return rerun(`'${name}' — a named zone resolves through the injected provider, which `
|
|
179
179
|
+ 'maintenance would have to consult per boundary');
|
|
180
180
|
}
|
|
181
|
+
// the kernel reads member NAMES (`at`, `value`); the document spells
|
|
182
|
+
// them as row selectors (`$.at`) — handed the document's spelling, the
|
|
183
|
+
// kernel read `row['$.at']` and every fold died on its first reading
|
|
184
|
+
const valueMember = spec.value === undefined ? null : singularSelector(spec.value);
|
|
185
|
+
if (spec.value !== undefined && valueMember === null) {
|
|
186
|
+
return rerun(`'${name}' — the spec reads a value selector this view cannot follow`);
|
|
187
|
+
}
|
|
188
|
+
const kernelSpec = { ...spec, at: eventTime.member,
|
|
189
|
+
...(valueMember === null ? {} : { value: valueMember }) };
|
|
181
190
|
const aggregate = spec.aggregate ?? 'mean';
|
|
182
191
|
if (!MAINTAINED_AGGREGATES.includes(aggregate)) {
|
|
183
192
|
return rerun(`'${name}' — '${aggregate}' names a row by its position in the series, `
|
|
@@ -215,7 +224,7 @@ export function classifyEventTime(inner, windowed, keyed, eventTime) {
|
|
|
215
224
|
return {
|
|
216
225
|
strategy: 'rolling',
|
|
217
226
|
source: operand.source,
|
|
218
|
-
spec,
|
|
227
|
+
spec: kernelSpec,
|
|
219
228
|
member: eventTime.member,
|
|
220
229
|
width: span.width,
|
|
221
230
|
eventTime,
|
|
@@ -236,7 +245,7 @@ export function classifyEventTime(inner, windowed, keyed, eventTime) {
|
|
|
236
245
|
return {
|
|
237
246
|
strategy: 'bucket',
|
|
238
247
|
source: operand.source,
|
|
239
|
-
spec,
|
|
248
|
+
spec: kernelSpec,
|
|
240
249
|
member: eventTime.member,
|
|
241
250
|
ladder,
|
|
242
251
|
fill,
|
package/src/live.js
CHANGED
|
@@ -79,12 +79,22 @@ function unwrapDocument(document) {
|
|
|
79
79
|
return { inner: doc, whole, windowed, offset, limit, aggregate };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Whether a `$for` source is the whole collection: the bare `$[*]`, or
|
|
83
|
+
* its packed spelling `["$[*]"]` — one array item a `$for` unpacks back
|
|
84
|
+
* into the rows, which is how `@jarenjs/linq` binds every source so an
|
|
85
|
+
* array-valued row stays one item. Over a collection the two are the
|
|
86
|
+
* same rows (the planner reads through the packing the same way). */
|
|
87
|
+
function isWholeCollection(source) {
|
|
88
|
+
return source === '$[*]'
|
|
89
|
+
|| (Array.isArray(source) && source.length === 1 && source[0] === '$[*]');
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
/** The single for-binding name of a canonical flwor, or null. */
|
|
83
93
|
function bindingNameOf(inner) {
|
|
84
94
|
if (!isJsonObject(inner) || !isJsonObject(inner.$for)) return null;
|
|
85
95
|
const names = Object.keys(inner.$for);
|
|
86
96
|
if (names.length !== 1) return null;
|
|
87
|
-
return inner.$for[names[0]]
|
|
97
|
+
return isWholeCollection(inner.$for[names[0]]) ? names[0] : null;
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/** The whole-documents read behind a flwor: same binding, same
|