@jarenjs/db 0.73.0 → 0.83.2
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 +70 -7
- package/README.md +69 -6
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +52 -13
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +163 -15
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +26 -4
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/derive.js +14 -3
- package/src/dialect.js +12 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +28 -4
- package/src/dialects/sqlite.js +23 -3
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +133 -25
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +81 -12
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live-nested.js +27 -10
- package/src/live.js +51 -136
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +275 -64
- package/src/query.js +175 -78
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +59 -4
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/errors.js
CHANGED
|
@@ -46,6 +46,7 @@ export const DB_CODES = Object.freeze({
|
|
|
46
46
|
JD0035: 'the continuation does not belong to this ordering',
|
|
47
47
|
JD0036: 'a snapshot page needs an immutable ordering',
|
|
48
48
|
JD0037: 'strictStreaming refused a plan that buffers',
|
|
49
|
+
JD0038: 'the native mutation document is unsupported or invalid',
|
|
49
50
|
JD0040: 'the save spans a relation cycle',
|
|
50
51
|
JD0050: 'live queries require change capture',
|
|
51
52
|
JD0051: 'the demanded live mode is unavailable',
|
|
@@ -109,6 +110,8 @@ export const DB_CODES = Object.freeze({
|
|
|
109
110
|
JD2092: 'a worker transport bound was exceeded',
|
|
110
111
|
JD2093: 'the worker protocol frame is invalid',
|
|
111
112
|
JD2094: 'the durable snapshot failed and the connection is invalid',
|
|
113
|
+
JD2095: 'the trusted SQL or synchronous transaction authority was refused',
|
|
114
|
+
JD2096: 'a persistence invariant rejected the mutation',
|
|
112
115
|
});
|
|
113
116
|
|
|
114
117
|
/**
|
|
@@ -554,6 +557,11 @@ export function wrapDriverError(error, details = {}) {
|
|
|
554
557
|
generic.retryable = false;
|
|
555
558
|
return generic;
|
|
556
559
|
}
|
|
560
|
+
if (typeof error?.message === 'string' && error.message.includes('jaren invariant:')) {
|
|
561
|
+
const wrapped = new DbRuntimeError('JD2096', error.message, { ...details, cause: error });
|
|
562
|
+
wrapped.class = 'constraint'; wrapped.retryable = false;
|
|
563
|
+
return wrapped;
|
|
564
|
+
}
|
|
557
565
|
const classified = classifyDriverError(error, details.unique);
|
|
558
566
|
const message = error?.message ?? String(error);
|
|
559
567
|
/** @type {any} */
|
package/src/graph.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* whole one.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { columnCodec } from './physical.js';
|
|
18
|
+
|
|
17
19
|
import { DbRuntimeError } from './errors.js';
|
|
18
20
|
import { utf8Length } from './cursor.js';
|
|
19
21
|
import { jsonBytes, decodeCountedJson } from './json-bytes.js';
|
|
@@ -73,9 +75,14 @@ function checkBounds(node, include, keyed, raw, sizes) {
|
|
|
73
75
|
* @returns {any}
|
|
74
76
|
*/
|
|
75
77
|
export function mergeEntityRow(entityMapping, row, docField = 'doc') {
|
|
76
|
-
const doc = JSON.parse(row[docField]);
|
|
78
|
+
const doc = entityMapping.document === false ? {} : JSON.parse(row[docField]);
|
|
77
79
|
for (const column of entityMapping.columns) {
|
|
78
80
|
if (column.source === 'epoch(document)') continue;
|
|
81
|
+
if (entityMapping.document === false && column.codec) {
|
|
82
|
+
const value = columnCodec(column).decode(row[column.physical ?? column.name]);
|
|
83
|
+
if (value !== undefined) Object.defineProperty(doc, column.name, { value, enumerable: true, writable: true, configurable: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
79
86
|
const value = row[column.name];
|
|
80
87
|
if (value === null || value === undefined) continue;
|
|
81
88
|
doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
|
package/src/index.js
CHANGED
|
@@ -100,3 +100,6 @@ export {
|
|
|
100
100
|
export { createDagJobRunner, RUN_IDENTITY_NODE } from './dag-job.js';
|
|
101
101
|
export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
|
|
102
102
|
normalizeReplication, normalizeReplicationSnapshot, encodeReplication, replicationIdentity } from './replication-format.js';
|
|
103
|
+
|
|
104
|
+
export { planInvariants } from './ddl.js';
|
|
105
|
+
export { planPhysicalMigration } from './migrate.js';
|
package/src/introspect.js
CHANGED
|
@@ -73,7 +73,7 @@ function loss(code, object, detail) {
|
|
|
73
73
|
* @param {string} table
|
|
74
74
|
* @returns {any} value-or-promise
|
|
75
75
|
*/
|
|
76
|
-
function readTable(connection, table) {
|
|
76
|
+
function readTable(connection, table, objects) {
|
|
77
77
|
const dialect = connection.dialect;
|
|
78
78
|
const all = (sql) => chain(connection.prepare(sql), (statement) => statement.all([]));
|
|
79
79
|
return chain(all(dialect.introspect.columns(table)), (columnRows) =>
|
|
@@ -95,30 +95,46 @@ function readTable(connection, table) {
|
|
|
95
95
|
(rows) => withColumns(i + 1, [...out, {
|
|
96
96
|
name: String(declared[i].name),
|
|
97
97
|
unique: Number(declared[i].uniq) !== 0,
|
|
98
|
-
|
|
98
|
+
partial: Number(declared[i].partial ?? 0) !== 0,
|
|
99
|
+
columns: rows.map((row) => row.name == null ? null : String(row.name)),
|
|
100
|
+
sql: objects.find((o) => o.type === 'index' && o.name === String(declared[i].name))?.sql ?? null,
|
|
99
101
|
}]));
|
|
100
102
|
};
|
|
101
103
|
return chain(withColumns(0, []), (indexes) =>
|
|
102
104
|
chain(keyIndex === undefined
|
|
103
105
|
? []
|
|
104
|
-
: all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) =>
|
|
106
|
+
: all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) =>
|
|
107
|
+
chain(dialect.introspect.checks === undefined ? []
|
|
108
|
+
: all(dialect.introspect.checks(table)), (checkRows) => ({
|
|
105
109
|
name: table,
|
|
106
|
-
|
|
110
|
+
sql: objects.find((o) => o.type === 'table' && o.name === table)?.sql ?? null,
|
|
111
|
+
primaryKey: columnRows.some((row) => Number(row.pk) > 0)
|
|
112
|
+
? columnRows.filter((row) => Number(row.pk) > 0)
|
|
113
|
+
.sort((a, b) => Number(a.pk) - Number(b.pk)).map((row) => String(row.name))
|
|
114
|
+
: keyRows.map((row) => String(row.name)),
|
|
107
115
|
columns: columnRows.map((row) => ({
|
|
108
116
|
name: String(row.name),
|
|
109
117
|
type: String(row.type),
|
|
110
118
|
generated: Number(row.hidden) !== 0,
|
|
119
|
+
primaryKeyOrdinal: Number(row.pk ?? 0),
|
|
120
|
+
nullable: Number(row.not_null ?? 0) === 0,
|
|
121
|
+
default: row.default_value ?? null,
|
|
111
122
|
})),
|
|
112
123
|
generated: dialect.readGenerated(generatedRows),
|
|
113
124
|
indexes,
|
|
125
|
+
checks: dialect.readChecks?.(checkRows) ?? [],
|
|
114
126
|
foreignKeys: fkRows.map((row) => ({
|
|
115
127
|
column: String(row.source_column),
|
|
116
128
|
target: String(row.target),
|
|
117
129
|
targetColumn: row.target_column === null || row.target_column === undefined
|
|
118
130
|
? null : String(row.target_column),
|
|
119
131
|
onDelete: String(row.on_delete ?? 'NO ACTION').toUpperCase(),
|
|
132
|
+
onUpdate: String(row.on_update ?? 'NO ACTION').toUpperCase(),
|
|
133
|
+
group: row.id ?? null,
|
|
134
|
+
ordinal: Number(row.seq ?? 0),
|
|
135
|
+
match: String(row.match ?? 'NONE'),
|
|
120
136
|
})),
|
|
121
|
-
})));
|
|
137
|
+
}))));
|
|
122
138
|
}))));
|
|
123
139
|
}
|
|
124
140
|
|
|
@@ -149,10 +165,23 @@ export function readSchema(connection, options = undefined) {
|
|
|
149
165
|
if (String(row.type) === 'view') views.push(name);
|
|
150
166
|
else names.push(name);
|
|
151
167
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
const readObjects = dialect.introspect.objects === undefined ? []
|
|
169
|
+
: chain(connection.prepare(dialect.introspect.objects()), (s) => s.all([]));
|
|
170
|
+
return chain(readObjects, (catalog) => {
|
|
171
|
+
const complete = [...catalog];
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
if (!complete.some((o) => o.type === row.type && o.name === row.name))
|
|
174
|
+
complete.push({ ...row, owner: row.name, sql: null });
|
|
175
|
+
}
|
|
176
|
+
const objects = complete.filter((row) => !engine.has(String(row.owner))
|
|
177
|
+
&& (wanted === null || wanted.has(String(row.owner)) || wanted.has(String(row.name))))
|
|
178
|
+
.map((row) => ({ type: String(row.type), name: String(row.name),
|
|
179
|
+
owner: String(row.owner), sql: row.sql ?? null }));
|
|
180
|
+
const step = (i, out) => (i >= names.length
|
|
181
|
+
? { tables: out, views, objects }
|
|
182
|
+
: chain(readTable(connection, names[i], objects), (table) => step(i + 1, [...out, table])));
|
|
183
|
+
return step(0, []);
|
|
184
|
+
});
|
|
156
185
|
}));
|
|
157
186
|
}
|
|
158
187
|
|
|
@@ -229,6 +258,11 @@ function deriveCollection(dialect, table, report, keys, functionNames) {
|
|
|
229
258
|
|
|
230
259
|
const indexes = [];
|
|
231
260
|
for (const index of table.indexes) {
|
|
261
|
+
if (index.partial || index.columns.some((column) => column === null)) {
|
|
262
|
+
report(loss('unmapped-index', `${table.name}.${index.name}`,
|
|
263
|
+
'a partial predicate or expression term cannot be represented by an unconditional member index'));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
232
266
|
if (index.columns.length === 1 && computed.has(index.columns[0])) {
|
|
233
267
|
const declared = { name: indexName(table.name, index.name),
|
|
234
268
|
expression: computed.get(index.columns[0]) };
|
|
@@ -416,9 +450,10 @@ function deriveEntity(dialect, table, report) {
|
|
|
416
450
|
const uniqueColumns = new Set();
|
|
417
451
|
const indexedColumns = new Set();
|
|
418
452
|
for (const index of table.indexes) {
|
|
419
|
-
if (index.columns.
|
|
453
|
+
if (index.partial || index.columns.some((column) => column === null)
|
|
454
|
+
|| index.columns.length !== 1) {
|
|
420
455
|
report(loss('unmapped-index', `${table.name}.${index.name}`,
|
|
421
|
-
'a
|
|
456
|
+
'a partial, expression or composite entity index is not a property-level declaration'));
|
|
422
457
|
continue;
|
|
423
458
|
}
|
|
424
459
|
(index.unique ? uniqueColumns : indexedColumns).add(index.columns[0]);
|
|
@@ -461,6 +496,26 @@ function deriveEntity(dialect, table, report) {
|
|
|
461
496
|
if (Object.keys(entity).length > 0) property['x-entity'] = entity;
|
|
462
497
|
properties[column.name] = property;
|
|
463
498
|
}
|
|
499
|
+
for (const check of table.checks) {
|
|
500
|
+
const property = properties[check.column];
|
|
501
|
+
const family = property?.type === 'integer' ? 'number'
|
|
502
|
+
: property?.type === 'string' ? 'string' : property?.type;
|
|
503
|
+
if (check.values === undefined || property === undefined
|
|
504
|
+
|| check.values.some((value) => typeof value !== family
|
|
505
|
+
|| (property.type === 'integer' && !Number.isInteger(value)))) {
|
|
506
|
+
report(loss('unmapped-constraint', `${table.name}.${check.name}`,
|
|
507
|
+
'the CHECK is not a complete scalar enum of the mapped column type'));
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
// Several enum CHECKs constrain the same column by intersection.
|
|
511
|
+
const values = property.enum === undefined ? check.values
|
|
512
|
+
: property.enum.filter((value) => check.values.includes(value));
|
|
513
|
+
if (values.length === 0) {
|
|
514
|
+
report(loss('unmapped-constraint', `${table.name}.${check.name}`,
|
|
515
|
+
'the enum CHECKs have an empty intersection, which a non-empty schema enum cannot declare'));
|
|
516
|
+
}
|
|
517
|
+
else property.enum = values;
|
|
518
|
+
}
|
|
464
519
|
report(loss('document-members', table.name,
|
|
465
520
|
'an entity\'s document column holds every property the mapping did not give a column, '
|
|
466
521
|
+ 'and those are not in the physical shape'));
|
|
@@ -517,6 +572,11 @@ export function introspectModel(connection, options = undefined) {
|
|
|
517
572
|
add(loss('unmapped-view', view,
|
|
518
573
|
'a view is not a shape a model document can declare'));
|
|
519
574
|
}
|
|
575
|
+
for (const object of schema.objects) {
|
|
576
|
+
if (object.type === 'trigger') add({ ...loss('unmapped-object', object.name,
|
|
577
|
+
'an application-owned trigger program must be explicitly preserved; inspection grants no drop permission'),
|
|
578
|
+
sql: object.sql, owner: object.owner });
|
|
579
|
+
}
|
|
520
580
|
|
|
521
581
|
const joinTables = new Set(schema.tables
|
|
522
582
|
.filter((table) => looksLikeJoinTable(dialect, table))
|
|
@@ -530,6 +590,8 @@ export function introspectModel(connection, options = undefined) {
|
|
|
530
590
|
if (joinTables.has(table.name)) continue;
|
|
531
591
|
if (looksLikeCollection(dialect, table)) {
|
|
532
592
|
collections[table.name] = deriveCollection(dialect, table, add, options?.keys, byName);
|
|
593
|
+
for (const check of table.checks) add(loss('unmapped-constraint', `${table.name}.${check.name}`,
|
|
594
|
+
'a CHECK over physical collection columns does not constrain the document schema'));
|
|
533
595
|
continue;
|
|
534
596
|
}
|
|
535
597
|
const hasDocument = table.columns.some((column) => column.name === DOC_COLUMN);
|
|
@@ -578,6 +640,13 @@ export function introspectModel(connection, options = undefined) {
|
|
|
578
640
|
+ `thing(s) the model would — ${report.map((row) => `${row.code} (${row.object})`)
|
|
579
641
|
.join(', ')}`);
|
|
580
642
|
}
|
|
581
|
-
|
|
643
|
+
const inventory = schema.objects.map((object) => Object.freeze({ ...object,
|
|
644
|
+
disposition: object.type === 'trigger' || object.type === 'view'
|
|
645
|
+
|| report.some((row) => row.object === object.name
|
|
646
|
+
|| row.object === `${object.owner}.${object.name}`
|
|
647
|
+
|| (row.code === 'unmapped-table' && row.object === object.owner))
|
|
648
|
+
? 'preserve' : 'derived' }));
|
|
649
|
+
return { model, report: Object.freeze(report.map((row) => Object.freeze(row))),
|
|
650
|
+
inventory: Object.freeze(inventory) };
|
|
582
651
|
});
|
|
583
652
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Persistence rules compile through the existing query evaluator. SQL lowering
|
|
3
|
+
* accepts a bounded scalar subset and reports its writer population explicitly. */
|
|
4
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
5
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** @param {any} rules @param {string} path @returns {any[]} */
|
|
8
|
+
export function normalizeInvariants(rules, path) {
|
|
9
|
+
if (rules === undefined) return [];
|
|
10
|
+
if (!Array.isArray(rules)) throw new DbCompileError('JD0005', 'invariants must be an array', path);
|
|
11
|
+
const names = new Set();
|
|
12
|
+
return rules.map((rule) => {
|
|
13
|
+
const fail = (reason) => { throw new DbCompileError('JD0005', reason, path); };
|
|
14
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) fail('an invariant must be an object');
|
|
15
|
+
for (const key of Object.keys(rule))
|
|
16
|
+
if (!['name', 'on', 'assert', 'enforcement', 'audit'].includes(key)) fail(`unknown invariant member '${key}'`);
|
|
17
|
+
if (typeof rule.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rule.name) || names.has(rule.name)) fail('invariant names must be distinct identifiers');
|
|
18
|
+
names.add(rule.name);
|
|
19
|
+
if (!['database', 'store'].includes(rule.enforcement)) fail('invariant enforcement must be database or store');
|
|
20
|
+
if (!Array.isArray(rule.on) || !rule.on.length || new Set(rule.on).size !== rule.on.length
|
|
21
|
+
|| rule.on.some((op) => !['insert', 'update', 'delete'].includes(op))) fail('invariant on must name distinct insert/update/delete operations');
|
|
22
|
+
if (rule.assert === undefined) fail('invariant assert is required');
|
|
23
|
+
if (rule.audit !== undefined && (rule.enforcement !== 'database' || !rule.audit
|
|
24
|
+
|| typeof rule.audit.entity !== 'string' || !rule.audit.values || typeof rule.audit.values !== 'object'
|
|
25
|
+
|| Array.isArray(rule.audit.values) || Object.keys(rule.audit).some((k) => !['entity', 'values'].includes(k)))) fail('audit is a database effect with an entity and values');
|
|
26
|
+
return { ...rule, evaluate: compileJsonQuery(rule.assert) };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} name @returns {DbRuntimeError} */
|
|
31
|
+
export function invariantFailure(name) {
|
|
32
|
+
const error = new DbRuntimeError('JD2096', `persistence invariant '${name}' rejected the mutation`);
|
|
33
|
+
error.class = 'constraint';
|
|
34
|
+
error.retryable = false;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {any[]} rules @param {string} op @param {any} before @param {any} after */
|
|
39
|
+
export function checkInvariants(rules, op, before, after) {
|
|
40
|
+
for (const rule of rules) {
|
|
41
|
+
if (rule.enforcement === 'store' && rule.on.includes(op)
|
|
42
|
+
&& rule.evaluate({ old: before ?? null, new: after ?? null, op }) !== true)
|
|
43
|
+
throw invariantFailure(rule.name);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/jobs.js
CHANGED
|
@@ -194,7 +194,7 @@ function isLease(value) {
|
|
|
194
194
|
/**
|
|
195
195
|
* The queue engine over one open connection.
|
|
196
196
|
* @param {{ connection: any, now?: () => number,
|
|
197
|
-
* random?: () => number,
|
|
197
|
+
* random?: () => number, adopt?: boolean,
|
|
198
198
|
* gate?: (fn: () => any, what?: string, signal?: AbortSignal) => any,
|
|
199
199
|
* bracket?: (fn: () => any) => any,
|
|
200
200
|
* defaults?: Partial<typeof JOB_DEFAULTS>,
|
|
@@ -276,9 +276,22 @@ export function createJobEngine(options) {
|
|
|
276
276
|
|
|
277
277
|
// the tables are created here, or refused here: a read-only store
|
|
278
278
|
// leaked the driver's "attempt to write a readonly database"
|
|
279
|
-
|
|
279
|
+
// Adoption accepts the current engine-owned schema without provisioning or
|
|
280
|
+
// upgrading it. The DDL remains the single schema declaration; differently
|
|
281
|
+
// shaped historical queues need an explicit upgrade before adoption.
|
|
282
|
+
const verifyExisting = () => chain(connection.prepare(connection.dialect.introspect.objects()), (statement) => chain(statement.all([]), (objects) => {
|
|
283
|
+
const normalize = (sql) => sql.replace(/'[^']*'|\bIF\s+NOT\s+EXISTS\b|[\s";]/g, (part) => part.startsWith("'") ? part : '');
|
|
284
|
+
for (const sql of CREATE_JOBS.split(';').filter((part) => part.trim())) {
|
|
285
|
+
const name = sql.match(/(?:TABLE|INDEX) IF NOT EXISTS "([^"]+)"/)[1];
|
|
286
|
+
const object = objects.find((value) => value.name === name);
|
|
287
|
+
if (!object?.sql || normalize(object.sql) !== normalize(sql))
|
|
288
|
+
throw new Error(`existing job object ${name} needs an explicit schema migration`);
|
|
289
|
+
}
|
|
290
|
+
}));
|
|
291
|
+
const ready = attempt(() => bracket(() => options.adopt === true
|
|
292
|
+
? verifyExisting() : chain(connection.exec(CREATE_JOBS), upgradeColumns)),
|
|
280
293
|
(error) => new DbCompileError('JD0002',
|
|
281
|
-
`the job tables could not be
|
|
294
|
+
`the job tables could not be opened (${error?.message ?? String(error)}) — `
|
|
282
295
|
+ 'a read-only store creates nothing; open it read-write once, or without jobs',
|
|
283
296
|
'/jobs', error));
|
|
284
297
|
|
|
@@ -436,6 +449,21 @@ export function createJobEngine(options) {
|
|
|
436
449
|
{ docPath: '/jobs', collection: JOBS_TABLE });
|
|
437
450
|
};
|
|
438
451
|
|
|
452
|
+
/** Verify current execution authority without renewing or writing a checkpoint.
|
|
453
|
+
* Inside tx.jobs this check and mapped record writes share the same lock.
|
|
454
|
+
* @param {any} lease */
|
|
455
|
+
const assertLease = (lease) => {
|
|
456
|
+
const misuse = requireLease(lease, 'assertLease()');
|
|
457
|
+
if (misuse !== null) throw misuse;
|
|
458
|
+
return chain(prepared('assertLease', `SELECT id FROM "${JOBS_TABLE}" WHERE id=? AND ${FENCE}`)
|
|
459
|
+
.get([lease.jobId, lease.token, now()]), (row) => {
|
|
460
|
+
if (row !== undefined) return true;
|
|
461
|
+
return chain(refuseSettlement(lease, 'assertLease()'), (error) => {
|
|
462
|
+
throw error ?? new DbRuntimeError('JD2065', 'the job is already settled', { docPath: '/jobs' });
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
};
|
|
466
|
+
|
|
439
467
|
/** §4: the jittered exponential backoff. */
|
|
440
468
|
const backoffOf = (attempts, workerDefaults) => {
|
|
441
469
|
const base = workerDefaults?.backoffBase ?? defaults.backoffBase;
|
|
@@ -849,10 +877,11 @@ export function createJobEngine(options) {
|
|
|
849
877
|
* @param {{ handlers: Record<string, Function>, concurrency?: number,
|
|
850
878
|
* pollInterval?: number, leaseMs?: number, owner?: string,
|
|
851
879
|
* backoffBase?: number, backoffCap?: number, renew?: boolean,
|
|
852
|
-
* onOutcome?: (event: any) => void }} workerOptions
|
|
880
|
+
* onOutcome?: (event: any) => void, effectSafety?: (job: any, context: any) => any }} workerOptions
|
|
853
881
|
*/
|
|
854
882
|
const createWorker = (workerOptions) => {
|
|
855
883
|
const handlers = workerOptions?.handlers;
|
|
884
|
+
if (workerOptions?.effectSafety !== undefined && typeof workerOptions.effectSafety !== 'function') throw new TypeError('effectSafety must be a function');
|
|
856
885
|
if (handlers === null || typeof handlers !== 'object'
|
|
857
886
|
|| Object.keys(handlers).length === 0
|
|
858
887
|
|| Object.values(handlers).some((handler) => typeof handler !== 'function')) {
|
|
@@ -1143,8 +1172,11 @@ export function createJobEngine(options) {
|
|
|
1143
1172
|
try {
|
|
1144
1173
|
let result;
|
|
1145
1174
|
try {
|
|
1146
|
-
|
|
1147
|
-
|
|
1175
|
+
const context = { job, lease: () => attempt.lease, checkpoints: attempt.checkpoints, signal: attempt.signal,
|
|
1176
|
+
pause: () => io(() => cancel(job.id, { lease: attempt.lease }), 'a durable effect pause') };
|
|
1177
|
+
const admitted = workerOptions.effectSafety === undefined || await workerOptions.effectSafety(job, context) === true;
|
|
1178
|
+
if (!admitted) { await context.pause(); recordCancelled(attempt); return; }
|
|
1179
|
+
result = await handlers[job.kind](job.payload, context);
|
|
1148
1180
|
}
|
|
1149
1181
|
catch (error) {
|
|
1150
1182
|
// past cancellation the store is closing: leave the leased
|
|
@@ -1330,6 +1362,7 @@ export function createJobEngine(options) {
|
|
|
1330
1362
|
counts,
|
|
1331
1363
|
claim,
|
|
1332
1364
|
renew,
|
|
1365
|
+
assertLease,
|
|
1333
1366
|
complete,
|
|
1334
1367
|
fail,
|
|
1335
1368
|
checkpointsFor,
|
package/src/live-nested.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//@ts-check
|
|
2
|
-
/**
|
|
2
|
+
/** Collection groups use bounded per-group recomputation over maintained leaves. */
|
|
3
3
|
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
4
4
|
import { stableStringify } from '@jarenjs/core/object';
|
|
5
5
|
import { DbRuntimeError } from './errors.js';
|
|
@@ -47,6 +47,8 @@ export function nestedGroupStrategy(description, context) {
|
|
|
47
47
|
const keyOf = compileJsonQuery([{ $for: { [binding]: '$[*]' },
|
|
48
48
|
...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: [key] }]);
|
|
49
49
|
const evaluate = compileJsonQuery([inner]);
|
|
50
|
+
const aggregate = description.aggregate === undefined ? null
|
|
51
|
+
: compileJsonQuery([{ [description.aggregate]: '$[*]' }]);
|
|
50
52
|
const docs = new Map();
|
|
51
53
|
const groups = new Map();
|
|
52
54
|
const results = new Map();
|
|
@@ -55,18 +57,25 @@ export function nestedGroupStrategy(description, context) {
|
|
|
55
57
|
let resultCount = 0;
|
|
56
58
|
const bytesOf = (value) => utf8Length(stableStringify(value));
|
|
57
59
|
const stats = { refreshedGroups: 0, dependencyReads: 0, reruns: 0 };
|
|
58
|
-
const groupOf = (doc) =>
|
|
60
|
+
const groupOf = (doc) => {
|
|
61
|
+
const keys = keyOf([doc], context.externals);
|
|
62
|
+
return keys.length === 0 ? null : stableStringify(keys);
|
|
63
|
+
};
|
|
59
64
|
const sorted = (keys) => [...keys].sort((a, b) => positions.get(a) - positions.get(b));
|
|
60
65
|
const check = () => {
|
|
61
|
-
const entries = docs.size + resultCount;
|
|
66
|
+
const entries = docs.size + resultCount + (aggregate === null ? 0 : 1);
|
|
62
67
|
if (entries > context.maxMaintained) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxMaintained');
|
|
63
68
|
if (maintainedBytes > context.maxBytes) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxBytes');
|
|
64
69
|
return entries;
|
|
65
70
|
};
|
|
66
71
|
const add = (token, doc) => {
|
|
67
72
|
const group = groupOf(doc);
|
|
73
|
+
if (group === null) return null;
|
|
74
|
+
const bytes = bytesOf(doc);
|
|
75
|
+
if (docs.size + resultCount + 1 > context.maxMaintained || maintainedBytes + bytes > context.maxBytes)
|
|
76
|
+
throw new DbRuntimeError('JD2060', 'group dependencies exceed live state credits');
|
|
68
77
|
docs.set(token, doc);
|
|
69
|
-
maintainedBytes +=
|
|
78
|
+
maintainedBytes += bytes;
|
|
70
79
|
positions.set(token, context.rowPosition(token));
|
|
71
80
|
if (!groups.has(group)) groups.set(group, new Set());
|
|
72
81
|
groups.get(group).add(token);
|
|
@@ -87,20 +96,25 @@ export function nestedGroupStrategy(description, context) {
|
|
|
87
96
|
stats.refreshedGroups++;
|
|
88
97
|
check();
|
|
89
98
|
};
|
|
90
|
-
const flatten = () =>
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
const flatten = () => {
|
|
100
|
+
const rows = [...groups.keys()].sort((a, b) =>
|
|
101
|
+
positions.get(sorted(groups.get(a))[0]) - positions.get(sorted(groups.get(b))[0]))
|
|
102
|
+
.flatMap((group) => results.get(group) ?? []);
|
|
103
|
+
return aggregate === null ? rows : aggregate(rows, context.externals);
|
|
104
|
+
};
|
|
93
105
|
return {
|
|
94
106
|
close() { docs.clear(); groups.clear(); results.clear(); positions.clear(); },
|
|
95
107
|
init() {
|
|
96
|
-
const
|
|
108
|
+
const carrier = description.carrier ?? { $for: { [binding]: '$[*]' },
|
|
109
|
+
...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: `$${binding}` };
|
|
110
|
+
const source = [{ $subsequence: [carrier, 0, context.maxMaintained + 1] }];
|
|
97
111
|
for (const doc of context.execute(source, { externals: context.externals })) add(context.keyOf(doc), doc);
|
|
98
112
|
check();
|
|
99
113
|
for (const group of groups.keys()) refresh(group);
|
|
100
114
|
return flatten();
|
|
101
115
|
}, entries: check, stats: () => ({ ...stats }),
|
|
102
116
|
apply(record, previousRows) {
|
|
103
|
-
const changes = context.touchedKeys(record, { whole: true, members: new Set() });
|
|
117
|
+
const changes = context.touchedKeys(record, description.deps ?? { whole: true, members: new Set() });
|
|
104
118
|
if (changes === null) return null;
|
|
105
119
|
const affected = new Set();
|
|
106
120
|
for (const token of changes.keys()) {
|
|
@@ -110,7 +124,10 @@ export function nestedGroupStrategy(description, context) {
|
|
|
110
124
|
docs.delete(token); positions.delete(token); maintainedBytes -= bytesOf(previous);
|
|
111
125
|
}
|
|
112
126
|
const doc = context.readRow(token); stats.dependencyReads++;
|
|
113
|
-
if (doc !== undefined)
|
|
127
|
+
if (doc !== undefined) {
|
|
128
|
+
const group = add(token, doc);
|
|
129
|
+
if (group !== null) affected.add(group);
|
|
130
|
+
}
|
|
114
131
|
}
|
|
115
132
|
check();
|
|
116
133
|
for (const group of affected) refresh(group);
|