@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/tracker.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* snapshot against current with the suite's own diff engine and plans
|
|
9
9
|
* the MINIMAL set of parameterised statements: scalar/epoch/foreign-
|
|
10
10
|
* key column writes, `jsonb_set`/`jsonb_remove` chains for document
|
|
11
|
-
* paths, join-table synchronisation for many-to-many members
|
|
12
|
-
*
|
|
11
|
+
* paths, join-table synchronisation for many-to-many members — by the
|
|
12
|
+
* key-set difference a `put` implies, or by an explicit `link`/`unlink`
|
|
13
|
+
* delta resolved against the join table at save time — and a counted
|
|
14
|
+
* whole-row fallback for anything untranslatable.
|
|
13
15
|
*
|
|
14
16
|
* Ordering never violates a foreign key mid-transaction: inserts run
|
|
15
17
|
* parent-first, deletes child-first, updates in between, join rows
|
|
@@ -24,13 +26,37 @@ import { createJSONPatch } from '@jarenjs/json/patch';
|
|
|
24
26
|
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
25
27
|
|
|
26
28
|
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
27
|
-
import { chain } from './driver.js';
|
|
29
|
+
import { chain, attempt } from './driver.js';
|
|
28
30
|
import { translatePatch } from './patch-sql.js';
|
|
29
31
|
|
|
30
32
|
/** Rows per batched INSERT: bounded by the portable parameter budget. */
|
|
31
33
|
export const BATCH_PARAM_BUDGET = 900;
|
|
32
34
|
export const BATCH_ROW_BOUND = 100;
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The target keys a many-to-many membership array names: a key, or a
|
|
38
|
+
* document carrying the target's key. One reading for the unit of work
|
|
39
|
+
* and for `create()`, so the two attach the same rows.
|
|
40
|
+
* @param {any} value - the member's value
|
|
41
|
+
* @param {string} targetKey - the target entity's key property
|
|
42
|
+
* @param {string} member
|
|
43
|
+
* @param {(reason: string) => Error} refuse
|
|
44
|
+
* @returns {(string | number)[]}
|
|
45
|
+
*/
|
|
46
|
+
export function membershipKeys(value, targetKey, member, refuse) {
|
|
47
|
+
if (value === undefined || value === null) return [];
|
|
48
|
+
if (!Array.isArray(value)) throw refuse(`'${member}' must be an array to synchronise its join table`);
|
|
49
|
+
return value.map((element) => {
|
|
50
|
+
const key = typeof element === 'string' || typeof element === 'number'
|
|
51
|
+
? element
|
|
52
|
+
: element !== null && typeof element === 'object'
|
|
53
|
+
? element[targetKey] : undefined;
|
|
54
|
+
if (typeof key !== 'string' && typeof key !== 'number')
|
|
55
|
+
throw refuse(`an element of '${member}' carries no usable '${targetKey}' key`);
|
|
56
|
+
return key;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
const UNIT_SEPARATOR = '';
|
|
35
61
|
|
|
36
62
|
/**
|
|
@@ -70,6 +96,11 @@ export function createTracker(context) {
|
|
|
70
96
|
const records = new Map();
|
|
71
97
|
/** @type {Map<string, any>} */
|
|
72
98
|
const removals = new Map();
|
|
99
|
+
/** Pending membership deltas (§11.7), one per entity, own key and
|
|
100
|
+
* many-to-many member: the targets to link and the targets to unlink.
|
|
101
|
+
* @type {Map<string, { entity: string, member: string, ownKey: string | number,
|
|
102
|
+
* links: Set<string | number>, unlinks: Set<string | number> }>} */
|
|
103
|
+
const memberships = new Map();
|
|
73
104
|
let pendingSequence = 0;
|
|
74
105
|
|
|
75
106
|
const keyOf = (entityName, parts) =>
|
|
@@ -89,6 +120,12 @@ export function createTracker(context) {
|
|
|
89
120
|
collection: entityName,
|
|
90
121
|
});
|
|
91
122
|
|
|
123
|
+
/** A membership write needs the entity's own key: an `auto` key is
|
|
124
|
+
* allocated by the save, so a pending insert has none to attach to. */
|
|
125
|
+
const needsOwnKey = (entityName, member, verb) => contractError(entityName,
|
|
126
|
+
`'${member}' membership needs the entity's own key at ${verb} time — `
|
|
127
|
+
+ 'save the entity first, then attach');
|
|
128
|
+
|
|
92
129
|
const register = (entityName, doc) => {
|
|
93
130
|
deepFreeze(doc);
|
|
94
131
|
const key = recordKeyFor(entityName, doc);
|
|
@@ -169,6 +206,60 @@ export function createTracker(context) {
|
|
|
169
206
|
});
|
|
170
207
|
};
|
|
171
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The pending membership delta a `link`/`unlink` addresses (§11.7):
|
|
211
|
+
* the member must be a many-to-many relation of the entity; the own
|
|
212
|
+
* key is read from a key or a document (a pending insert whose key
|
|
213
|
+
* the save allocates has none to attach to); the target is a key or a
|
|
214
|
+
* document carrying the target's key — the reading a membership array
|
|
215
|
+
* gets, so the two attach the same rows.
|
|
216
|
+
*/
|
|
217
|
+
const membershipOf = (entityName, own, member, target, verb) => {
|
|
218
|
+
const plan = coreFor(entityName).plan;
|
|
219
|
+
const relation = entities.get(entityName).properties.get(member)?.relation;
|
|
220
|
+
if (relation === undefined || relation.kind !== 'manyToMany') {
|
|
221
|
+
throw contractError(entityName, relation === undefined
|
|
222
|
+
? `'${member}' is not a relation member of '${entityName}' — ${verb}() attaches a `
|
|
223
|
+
+ 'many-to-many membership through its join table'
|
|
224
|
+
: `'${member}' is a ${relation.kind} relation — ${verb}() attaches many-to-many `
|
|
225
|
+
+ "memberships only; write the related entity's foreign key instead");
|
|
226
|
+
}
|
|
227
|
+
let ownKey;
|
|
228
|
+
if (own !== null && typeof own === 'object' && !Array.isArray(own)) {
|
|
229
|
+
ownKey = own[plan.keys[0]];
|
|
230
|
+
if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
|
|
231
|
+
throw needsOwnKey(entityName, member, `${verb}()`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
ownKey = coreFor(entityName).normalizeKey(own)[0];
|
|
235
|
+
}
|
|
236
|
+
const targetKey = mapping.entities[relation.to].keys[0];
|
|
237
|
+
const [key] = membershipKeys([target], targetKey, member,
|
|
238
|
+
(reason) => contractError(entityName, reason));
|
|
239
|
+
const id = `${keyOf(entityName, [ownKey])}${UNIT_SEPARATOR}${member}`;
|
|
240
|
+
let pending = memberships.get(id);
|
|
241
|
+
if (pending === undefined) {
|
|
242
|
+
pending = { entity: entityName, member, ownKey, links: new Set(), unlinks: new Set() };
|
|
243
|
+
memberships.set(id, pending);
|
|
244
|
+
}
|
|
245
|
+
return { pending, key };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Attach one membership (local, synchronous); the last word on one
|
|
249
|
+
* target wins, so `unlink` after `link` means unlink. */
|
|
250
|
+
const link = (entityName, own, member, target) => {
|
|
251
|
+
const { pending, key } = membershipOf(entityName, own, member, target, 'link');
|
|
252
|
+
pending.unlinks.delete(key);
|
|
253
|
+
pending.links.add(key);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Detach one membership (local, synchronous). */
|
|
257
|
+
const unlink = (entityName, own, member, target) => {
|
|
258
|
+
const { pending, key } = membershipOf(entityName, own, member, target, 'unlink');
|
|
259
|
+
pending.links.delete(key);
|
|
260
|
+
pending.unlinks.add(key);
|
|
261
|
+
};
|
|
262
|
+
|
|
172
263
|
const counts = () => {
|
|
173
264
|
let pendingInserts = 0;
|
|
174
265
|
for (const record of records.values()) {
|
|
@@ -178,6 +269,7 @@ export function createTracker(context) {
|
|
|
178
269
|
tracked: records.size - pendingInserts,
|
|
179
270
|
pendingInserts,
|
|
180
271
|
pendingDeletes: removals.size,
|
|
272
|
+
pendingMemberships: memberships.size,
|
|
181
273
|
};
|
|
182
274
|
};
|
|
183
275
|
|
|
@@ -275,29 +367,31 @@ export function createTracker(context) {
|
|
|
275
367
|
return { columnSets, docBuild, m2mMembers, fallback };
|
|
276
368
|
};
|
|
277
369
|
|
|
370
|
+
/** The join-table endpoints a many-to-many member writes through. The
|
|
371
|
+
* endpoint columns come from the mapping, never from the join table's
|
|
372
|
+
* NAME: an entity name with an underscore, or a `through` name, does
|
|
373
|
+
* not split into its endpoints. */
|
|
374
|
+
const joinEndpoints = (entityName, member) => {
|
|
375
|
+
const relation = entities.get(entityName).properties.get(member).relation;
|
|
376
|
+
const join = mapping.joinTables[relation.joinTable];
|
|
377
|
+
const own = join.left.entity === entityName ? join.left : join.right;
|
|
378
|
+
const target = own === join.left ? join.right : join.left;
|
|
379
|
+
return {
|
|
380
|
+
entity: entityName,
|
|
381
|
+
member,
|
|
382
|
+
joinTable: relation.joinTable,
|
|
383
|
+
ownColumn: own.column,
|
|
384
|
+
targetColumn: target.column,
|
|
385
|
+
tableColumns: [join.left.column, join.right.column],
|
|
386
|
+
targetKey: mapping.entities[relation.to].keys[0],
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
|
|
278
390
|
/** Compute a many-to-many member's join-row difference by key sets. */
|
|
279
391
|
const joinDiff = (entityName, before, after, ownKey, member) => {
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
const extract = (value) => {
|
|
284
|
-
if (value === undefined || value === null) return [];
|
|
285
|
-
if (!Array.isArray(value)) {
|
|
286
|
-
throw contractError(entityName,
|
|
287
|
-
`'${member}' must be an array to synchronise its join table`);
|
|
288
|
-
}
|
|
289
|
-
return value.map((element) => {
|
|
290
|
-
const key = typeof element === 'string' || typeof element === 'number'
|
|
291
|
-
? element
|
|
292
|
-
: element !== null && typeof element === 'object'
|
|
293
|
-
? element[targetKey] : undefined;
|
|
294
|
-
if (typeof key !== 'string' && typeof key !== 'number') {
|
|
295
|
-
throw contractError(entityName,
|
|
296
|
-
`an element of '${member}' carries no usable '${targetKey}' key`);
|
|
297
|
-
}
|
|
298
|
-
return key;
|
|
299
|
-
});
|
|
300
|
-
};
|
|
392
|
+
const endpoints = joinEndpoints(entityName, member);
|
|
393
|
+
const extract = (value) => membershipKeys(value, endpoints.targetKey, member,
|
|
394
|
+
(reason) => contractError(entityName, reason));
|
|
301
395
|
// a snapshot that never LOADED the member knows nothing about the
|
|
302
396
|
// current membership — treating unknown as empty would re-insert
|
|
303
397
|
// existing rows (a UNIQUE violation the seeded corpus found); the
|
|
@@ -307,15 +401,24 @@ export function createTracker(context) {
|
|
|
307
401
|
? null
|
|
308
402
|
: [...new Set(extract(memberValue))];
|
|
309
403
|
return {
|
|
310
|
-
|
|
311
|
-
ownColumn: `${entityName}_key`,
|
|
312
|
-
targetColumn: `${relation.to}_key`,
|
|
404
|
+
...endpoints,
|
|
313
405
|
ownKey,
|
|
314
406
|
beforeKeys,
|
|
315
407
|
afterKeys: [...new Set(extract(after[member]))],
|
|
316
408
|
};
|
|
317
409
|
};
|
|
318
410
|
|
|
411
|
+
/** A pending `link`/`unlink` delta as a join op. Its baseline is the
|
|
412
|
+
* join table as read at save time, so linking a member that exists
|
|
413
|
+
* and unlinking one that does not are no-ops — the two-run property. */
|
|
414
|
+
const membershipDelta = (pending) => ({
|
|
415
|
+
...joinEndpoints(pending.entity, pending.member),
|
|
416
|
+
ownKey: pending.ownKey,
|
|
417
|
+
beforeKeys: null,
|
|
418
|
+
links: [...pending.links],
|
|
419
|
+
unlinks: [...pending.unlinks],
|
|
420
|
+
});
|
|
421
|
+
|
|
319
422
|
/** Relation members riding a pending INSERT: many-to-many becomes
|
|
320
423
|
* join rows; anything else refuses — projections are not state. */
|
|
321
424
|
const insertRelationOps = (record) => {
|
|
@@ -334,11 +437,8 @@ export function createTracker(context) {
|
|
|
334
437
|
+ 'projection, not stored state; add the related entities themselves');
|
|
335
438
|
}
|
|
336
439
|
const ownKey = record.current[plan.keys[0]];
|
|
337
|
-
if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
|
|
338
|
-
throw
|
|
339
|
-
`'${property.name}' membership needs the entity's own key at `
|
|
340
|
-
+ 'add() time — save the entity first, then attach');
|
|
341
|
-
}
|
|
440
|
+
if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
|
|
441
|
+
throw needsOwnKey(record.entity, property.name, 'add()');
|
|
342
442
|
ops.push(joinDiff(record.entity, null, record.current, ownKey, property.name));
|
|
343
443
|
}
|
|
344
444
|
return ops;
|
|
@@ -368,6 +468,10 @@ export function createTracker(context) {
|
|
|
368
468
|
continue;
|
|
369
469
|
}
|
|
370
470
|
if (record.current === record.snapshot) continue;
|
|
471
|
+
// a record with a pending removal is deleted, not updated: planning
|
|
472
|
+
// both bumped the version on the UPDATE and left the DELETE's
|
|
473
|
+
// snapshot guard matching nothing (JD2040), so the row survived
|
|
474
|
+
if (removals.has(recordKeyFor(record.entity, record.snapshot) ?? '')) continue;
|
|
371
475
|
// probe before stamping: an update stamp must never turn a
|
|
372
476
|
// deep-equal replacement into a phantom write
|
|
373
477
|
if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
|
|
@@ -397,6 +501,23 @@ export function createTracker(context) {
|
|
|
397
501
|
unversioned.add(removal.entity);
|
|
398
502
|
}
|
|
399
503
|
|
|
504
|
+
// a link/unlink beside a put-based synchronisation of the SAME member
|
|
505
|
+
// folds into that op's key set: one intent per entity, own key and
|
|
506
|
+
// member, never two statements racing for one row
|
|
507
|
+
const synced = new Map(joinOps.map((op) =>
|
|
508
|
+
[`${op.entity}${UNIT_SEPARATOR}${op.ownKey}${UNIT_SEPARATOR}${op.member}`, op]));
|
|
509
|
+
for (const [id, pending] of memberships) {
|
|
510
|
+
const diff = synced.get(id);
|
|
511
|
+
if (diff === undefined) {
|
|
512
|
+
joinOps.push(membershipDelta(pending));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const after = new Set(diff.afterKeys);
|
|
516
|
+
for (const key of pending.unlinks) after.delete(key);
|
|
517
|
+
for (const key of pending.links) after.add(key);
|
|
518
|
+
diff.afterKeys = [...after];
|
|
519
|
+
}
|
|
520
|
+
|
|
400
521
|
// resolve unknown membership baselines, then finalize each op
|
|
401
522
|
const resolveJoins = (i) => {
|
|
402
523
|
if (i >= joinOps.length) return null;
|
|
@@ -413,6 +534,11 @@ export function createTracker(context) {
|
|
|
413
534
|
const finalizeJoins = () => {
|
|
414
535
|
for (const op of joinOps) {
|
|
415
536
|
const before = new Set(op.beforeKeys);
|
|
537
|
+
if (op.links !== undefined) {
|
|
538
|
+
op.added = op.links.filter((key) => !before.has(key));
|
|
539
|
+
op.removed = op.unlinks.filter((key) => before.has(key));
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
416
542
|
const after = new Set(op.afterKeys);
|
|
417
543
|
op.added = op.afterKeys.filter((key) => !before.has(key));
|
|
418
544
|
op.removed = op.beforeKeys.filter((key) => !after.has(key));
|
|
@@ -525,7 +651,7 @@ export function createTracker(context) {
|
|
|
525
651
|
+ `(${q(op.ownColumn)}, ${q(op.targetColumn)}) VALUES `
|
|
526
652
|
+ op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
|
|
527
653
|
statements.push({
|
|
528
|
-
kind: 'join-insert', entity: op.joinTable, sql,
|
|
654
|
+
kind: 'join-insert', entity: op.joinTable, sql, tableColumns: op.tableColumns,
|
|
529
655
|
params: op.added.flatMap((key) => [op.ownKey, key]),
|
|
530
656
|
joinRows: op.added.map((key) => ({
|
|
531
657
|
own: op.ownKey, target: key,
|
|
@@ -535,7 +661,7 @@ export function createTracker(context) {
|
|
|
535
661
|
}
|
|
536
662
|
for (const key of op.removed) {
|
|
537
663
|
statements.push({
|
|
538
|
-
kind: 'join-delete', entity: op.joinTable,
|
|
664
|
+
kind: 'join-delete', entity: op.joinTable, tableColumns: op.tableColumns,
|
|
539
665
|
sql: `DELETE FROM ${q(op.joinTable)} WHERE ${q(op.ownColumn)} = ${parameterAt(1)} `
|
|
540
666
|
+ `AND ${q(op.targetColumn)} = ${parameterAt(2)}`,
|
|
541
667
|
params: [op.ownKey, key],
|
|
@@ -633,14 +759,8 @@ export function createTracker(context) {
|
|
|
633
759
|
? captureJoinDelete(statement.entity, statement.removal.parts)
|
|
634
760
|
: null,
|
|
635
761
|
() => {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
ran = prepared.run(statement.params);
|
|
639
|
-
}
|
|
640
|
-
catch (error) {
|
|
641
|
-
throw wrapDb(error, statement);
|
|
642
|
-
}
|
|
643
|
-
return chain(ran, (outcome) => {
|
|
762
|
+
return chain(attempt(() => prepared.run(statement.params),
|
|
763
|
+
(error) => wrapDb(error, statement)), (outcome) => {
|
|
644
764
|
const changed = Number(outcome?.changes ?? 0);
|
|
645
765
|
report.statements.push({ sql: statement.sql, rows: changed });
|
|
646
766
|
if (statement.kind === 'insert') report.inserted += statement.records.length;
|
|
@@ -709,12 +829,11 @@ export function createTracker(context) {
|
|
|
709
829
|
else if (statement.kind === 'join-insert' || statement.kind === 'join-delete') {
|
|
710
830
|
for (const row of statement.joinRows ?? []) {
|
|
711
831
|
// the join-row "document" lists its columns in table order
|
|
712
|
-
// (the
|
|
713
|
-
const pair = statement.entity.split('_');
|
|
832
|
+
// (the mapping's left, right) so both capture modes agree exactly
|
|
714
833
|
const value = { [row.ownColumn]: row.own, [row.targetColumn]: row.target };
|
|
715
834
|
const ordered = {};
|
|
716
|
-
for (const
|
|
717
|
-
const keyParts =
|
|
835
|
+
for (const column of statement.tableColumns) ordered[column] = value[column];
|
|
836
|
+
const keyParts = statement.tableColumns.map((column) => ordered[column]);
|
|
718
837
|
if (statement.kind === 'join-insert') {
|
|
719
838
|
captureRecord?.(statement.entity, keyParts, null, ordered);
|
|
720
839
|
}
|
|
@@ -734,6 +853,7 @@ export function createTracker(context) {
|
|
|
734
853
|
}
|
|
735
854
|
}
|
|
736
855
|
removals.clear();
|
|
856
|
+
memberships.clear();
|
|
737
857
|
};
|
|
738
858
|
|
|
739
859
|
const saveChanges = () => {
|
|
@@ -767,10 +887,15 @@ export function createTracker(context) {
|
|
|
767
887
|
/** Drop tracking for a key without scheduling anything. */
|
|
768
888
|
const discard = (entityName, keyOrDoc) => {
|
|
769
889
|
const parts = coreFor(entityName).normalizeKey(keyOrDoc);
|
|
770
|
-
|
|
890
|
+
const key = keyOf(entityName, parts);
|
|
891
|
+
records.delete(key);
|
|
892
|
+
// a pending membership change belongs to the key it attaches to
|
|
893
|
+
for (const id of memberships.keys()) {
|
|
894
|
+
if (id.startsWith(`${key}${UNIT_SEPARATOR}`)) memberships.delete(id);
|
|
895
|
+
}
|
|
771
896
|
};
|
|
772
897
|
|
|
773
898
|
return {
|
|
774
|
-
register, registerGraph, add, put, remove, discard, counts, saveChanges,
|
|
899
|
+
register, registerGraph, add, put, remove, discard, link, unlink, counts, saveChanges,
|
|
775
900
|
};
|
|
776
901
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -84,7 +84,9 @@ export interface ExecuteOptions {
|
|
|
84
84
|
pushdown?: boolean;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
/** An include's clauses: the root's without `after` — a keyset cursor
|
|
88
|
+
* paginates the root alone; an include windows with `skip`/`take`. */
|
|
89
|
+
export interface LoadInclude extends Omit<LoadSpec, 'after'> {
|
|
88
90
|
/** Project the related-row COUNT instead of the rows. */
|
|
89
91
|
count?: boolean;
|
|
90
92
|
}
|
|
@@ -128,6 +130,8 @@ export interface StoreStats {
|
|
|
128
130
|
tracked: number;
|
|
129
131
|
pendingInserts: number;
|
|
130
132
|
pendingDeletes: number;
|
|
133
|
+
/** Pending `link`/`unlink` records: one per entity, own key and member (§11.7). */
|
|
134
|
+
pendingMemberships: number;
|
|
131
135
|
} | null;
|
|
132
136
|
liveQueries: number;
|
|
133
137
|
}
|
|
@@ -186,12 +190,44 @@ export interface SyncCollection<T = unknown> {
|
|
|
186
190
|
|
|
187
191
|
// ————— entities (phase B) —————
|
|
188
192
|
|
|
193
|
+
/** One row of an entity's relation table (MODEL-FORMAT §10.1): the
|
|
194
|
+
* declared relation as plain data a query producer can lower a hop
|
|
195
|
+
* from — never a document dialect. For a foreign-key relation `via`
|
|
196
|
+
* names the key property, `fkEntity` the entity holding it, `fkTargets`
|
|
197
|
+
* the entity it references and `targetKey` the key property it
|
|
198
|
+
* references there (the column a hop's equality compares `via` with);
|
|
199
|
+
* `kind` says which side holds the key (`oneToOne`: the declaring
|
|
200
|
+
* entity; `oneToMany`: the target). A many-to-many carries its
|
|
201
|
+
* `joinTable` and the target's `targetKey`. */
|
|
202
|
+
export interface RelationEntry {
|
|
203
|
+
readonly to: string;
|
|
204
|
+
readonly kind: 'oneToOne' | 'oneToMany' | 'manyToMany';
|
|
205
|
+
readonly via?: string;
|
|
206
|
+
readonly fkEntity?: string;
|
|
207
|
+
readonly fkTargets?: string;
|
|
208
|
+
readonly joinTable?: string;
|
|
209
|
+
readonly targetKey: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** An entity's relation table: one entry per declared relation member. */
|
|
213
|
+
export type RelationTable = Readonly<Record<string, RelationEntry>>;
|
|
214
|
+
|
|
215
|
+
/** The identity every entity set of one store shares — two sets with one
|
|
216
|
+
* `scope` may be joined in one document — carrying the relation tables
|
|
217
|
+
* of every root, keyed by entity name, so a hop can chain into another
|
|
218
|
+
* root of the same scope. */
|
|
219
|
+
export interface EntityScope {
|
|
220
|
+
readonly relations: Readonly<Record<string, RelationTable>>;
|
|
221
|
+
}
|
|
222
|
+
|
|
189
223
|
export interface UntrackedReads<T = unknown> {
|
|
190
224
|
get(key: EntityKeyArg): Promise<T | undefined>;
|
|
191
225
|
load(spec?: LoadSpec): Promise<T[]>;
|
|
192
226
|
}
|
|
193
227
|
|
|
194
228
|
export interface EntitySet<T = unknown, I = unknown> {
|
|
229
|
+
/** The provider phantom: a chain over this set infers its item type. */
|
|
230
|
+
readonly __item?: T;
|
|
195
231
|
create(doc: I): Promise<Readonly<T>>;
|
|
196
232
|
get(key: EntityKeyArg): Promise<Readonly<T> | undefined>;
|
|
197
233
|
update(key: EntityKeyArg, changes: Partial<T>): Promise<Readonly<T>>;
|
|
@@ -206,7 +242,27 @@ export interface EntitySet<T = unknown, I = unknown> {
|
|
|
206
242
|
remove(key: EntityKeyArg | T): void;
|
|
207
243
|
/** Drop tracking without scheduling anything — conflict recovery. */
|
|
208
244
|
discard(key: EntityKeyArg | T): void;
|
|
245
|
+
/** Attach / detach one many-to-many membership through the unit of
|
|
246
|
+
* work (§11.7): local bookkeeping, written by `saveChanges()` as join
|
|
247
|
+
* rows against the join table as it stands then — idempotent. `own`
|
|
248
|
+
* and `target` are each a key or a document carrying the key. */
|
|
249
|
+
link(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
|
|
250
|
+
unlink(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
|
|
209
251
|
asNoTracking(): UntrackedReads<T>;
|
|
252
|
+
/** The provider contract over this entity's root (MODEL-FORMAT §10.1):
|
|
253
|
+
* the document is over the multi-entity root and arrives whole; the
|
|
254
|
+
* answer is the engine's result shape, value-or-promise (D2). */
|
|
255
|
+
execute<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
|
|
256
|
+
explain(document: unknown, options?: ExecuteOptions): Promise<unknown>;
|
|
257
|
+
/** The root expression this set's rows are bound through (`$.<Name>[*]`). */
|
|
258
|
+
readonly root: string;
|
|
259
|
+
/** The identity every entity set of one store shares: two sets with one
|
|
260
|
+
* `scope` may be joined in one document; it carries every root's
|
|
261
|
+
* relation table. */
|
|
262
|
+
readonly scope: EntityScope;
|
|
263
|
+
/** This entity's relation table (MODEL-FORMAT §10.1) — what a query
|
|
264
|
+
* producer lowers a relation hop from. */
|
|
265
|
+
readonly relations: RelationTable;
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
export interface SyncUntrackedReads<T = unknown> {
|
|
@@ -215,6 +271,8 @@ export interface SyncUntrackedReads<T = unknown> {
|
|
|
215
271
|
}
|
|
216
272
|
|
|
217
273
|
export interface SyncEntitySet<T = unknown, I = unknown> {
|
|
274
|
+
/** The provider phantom: a chain over this set infers its item type. */
|
|
275
|
+
readonly __item?: T;
|
|
218
276
|
create(doc: I): Readonly<T>;
|
|
219
277
|
get(key: EntityKeyArg): Readonly<T> | undefined;
|
|
220
278
|
update(key: EntityKeyArg, changes: Partial<T>): Readonly<T>;
|
|
@@ -225,7 +283,15 @@ export interface SyncEntitySet<T = unknown, I = unknown> {
|
|
|
225
283
|
put(next: T): Readonly<T>;
|
|
226
284
|
remove(key: EntityKeyArg | T): void;
|
|
227
285
|
discard(key: EntityKeyArg | T): void;
|
|
286
|
+
link(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
|
|
287
|
+
unlink(own: EntityKeyArg | T, member: string, target: EntityKeyArg | object): void;
|
|
228
288
|
asNoTracking(): SyncUntrackedReads<T>;
|
|
289
|
+
/** The provider contract over this entity's root, answering values. */
|
|
290
|
+
execute<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
|
|
291
|
+
explain(document: unknown, options?: ExecuteOptions): unknown;
|
|
292
|
+
readonly root: string;
|
|
293
|
+
readonly scope: EntityScope;
|
|
294
|
+
readonly relations: RelationTable;
|
|
229
295
|
}
|
|
230
296
|
|
|
231
297
|
// ————— the store —————
|
|
@@ -237,6 +303,14 @@ export interface SyncStore {
|
|
|
237
303
|
entity(name: string): SyncEntitySet;
|
|
238
304
|
transaction<R>(fn: (store: Store) => R): R;
|
|
239
305
|
execute?<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
|
|
306
|
+
explain?(document: unknown, options?: ExecuteOptions): unknown;
|
|
307
|
+
/** The entity roots this store-level provider serves (present with
|
|
308
|
+
* entities): it has no single root of its own, so a chain over it is
|
|
309
|
+
* refused by name — chain over `entity(name)` instead. */
|
|
310
|
+
readonly roots?: readonly string[];
|
|
311
|
+
/** The relation tables of every entity, keyed by entity name (present
|
|
312
|
+
* with entities; MODEL-FORMAT §10.1). */
|
|
313
|
+
readonly relations?: Readonly<Record<string, RelationTable>>;
|
|
240
314
|
saveChanges?(): SaveReport;
|
|
241
315
|
}
|
|
242
316
|
|
|
@@ -253,6 +327,13 @@ export interface Store {
|
|
|
253
327
|
* in the engine's result shape. */
|
|
254
328
|
execute?<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
|
|
255
329
|
explain?(document: unknown, options?: ExecuteOptions): Promise<unknown>;
|
|
330
|
+
/** The entity roots this store-level provider serves (present with
|
|
331
|
+
* entities): it has no single root of its own, so a chain over it is
|
|
332
|
+
* refused by name — chain over `entity(name)` instead. */
|
|
333
|
+
readonly roots?: readonly string[];
|
|
334
|
+
/** The relation tables of every entity, keyed by entity name (present
|
|
335
|
+
* with entities; MODEL-FORMAT §10.1). */
|
|
336
|
+
readonly relations?: Readonly<Record<string, RelationTable>>;
|
|
256
337
|
/** The unit of work (§11); present only with entities. */
|
|
257
338
|
saveChanges?(): Promise<SaveReport>;
|
|
258
339
|
transaction<R>(fn: (store: Store) => R | Promise<R>): Promise<Awaited<R>>;
|
|
@@ -409,10 +490,19 @@ export interface OpenStoreOptions {
|
|
|
409
490
|
* than answered in UTC. No time-zone database is bundled. */
|
|
410
491
|
zoneProvider?: unknown;
|
|
411
492
|
busyTimeout?: number;
|
|
493
|
+
/** How long work waits for an open transaction to settle before
|
|
494
|
+
* `JD0012` (MODEL-FORMAT §5.1); reaches every driver. */
|
|
495
|
+
queueTimeout?: number;
|
|
412
496
|
journalMode?: string;
|
|
413
497
|
statementCacheBound?: number;
|
|
414
498
|
profile?: unknown;
|
|
415
499
|
readOnly?: boolean;
|
|
500
|
+
/** A `createJsltRegistry()` registry (Ring 2/3): the operators a
|
|
501
|
+
* query may use, and the pushable subset. */
|
|
502
|
+
operators?: unknown;
|
|
503
|
+
/** Raw registry-free operators; never pushed. */
|
|
504
|
+
functions?: Record<string, unknown>;
|
|
505
|
+
extensions?: Record<string, unknown>;
|
|
416
506
|
}
|
|
417
507
|
|
|
418
508
|
export declare function openStore(model: unknown, options: OpenStoreOptions): Promise<Store>;
|
|
@@ -433,7 +523,9 @@ export interface Dialect {
|
|
|
433
523
|
export interface Driver {
|
|
434
524
|
readonly name: string;
|
|
435
525
|
readonly dialect: Dialect;
|
|
436
|
-
|
|
526
|
+
/** Open a connection (value-or-promise) at `path` (`':memory:'` for
|
|
527
|
+
* none) with the driver's own options. */
|
|
528
|
+
open(path: string, options?: unknown): unknown;
|
|
437
529
|
}
|
|
438
530
|
|
|
439
531
|
export declare const sqliteDialect: Dialect;
|
|
@@ -444,6 +536,12 @@ export declare const SQLITE_FLOOR: string;
|
|
|
444
536
|
|
|
445
537
|
export declare function normalizeEntities(model: unknown): Map<string, unknown>;
|
|
446
538
|
export declare function explainMapping(model: unknown): unknown;
|
|
539
|
+
/** The relation tables of normalized entities, keyed by entity name
|
|
540
|
+
* then by relation member (MODEL-FORMAT §10.1) — what every entity set
|
|
541
|
+
* exposes as `relations` and every scope carries for all its roots. */
|
|
542
|
+
export declare function relationTables(
|
|
543
|
+
entities: Map<string, unknown>,
|
|
544
|
+
): Readonly<Record<string, RelationTable>>;
|
|
447
545
|
|
|
448
546
|
/**
|
|
449
547
|
* Build the EMIT-FORMAT model document for a model's entities.
|
|
@@ -527,7 +625,9 @@ export declare const HISTORY_TABLE: string;
|
|
|
527
625
|
// are deliberately WIDE (unknown), never wrong.
|
|
528
626
|
|
|
529
627
|
export declare function planCollection(name: string, collection: unknown, dialect: Dialect): unknown;
|
|
530
|
-
export declare function compileIndexPath(
|
|
628
|
+
export declare function compileIndexPath(expression: string, docPath: string): unknown;
|
|
629
|
+
export declare function normalizeDeclaredSql(sql: string): string;
|
|
630
|
+
export declare function comparableDeclaredSql(sql: string): string;
|
|
531
631
|
export declare function schemaTypeAt(schema: unknown, segments: unknown): unknown;
|
|
532
632
|
export declare const KEY_COLUMN: string;
|
|
533
633
|
export declare const DOC_COLUMN: string;
|
|
@@ -575,7 +675,7 @@ export declare function openConnection(raw: unknown, options: unknown): unknown;
|
|
|
575
675
|
export declare function wrapStatement(statement: unknown): unknown;
|
|
576
676
|
export declare function lazyOpen(spec: unknown, reason: string, use: unknown, args?: unknown): unknown;
|
|
577
677
|
export declare function classifyLiveQuery(
|
|
578
|
-
document: unknown, queryShape: unknown, keyed: boolean): unknown;
|
|
678
|
+
document: unknown, queryShape: unknown, keyed: boolean, eventTime?: unknown): unknown;
|
|
579
679
|
export declare function createLiveRegistry(
|
|
580
680
|
bounds: { maxQueries: number; maxMaintained: number }): unknown;
|
|
581
681
|
export declare function diffRows(oldRows: readonly unknown[], newRows: readonly unknown[]):
|
|
@@ -586,6 +686,38 @@ export declare function createSortedWindow(
|
|
|
586
686
|
export declare function compareCodepoint(a: string, b: string): number;
|
|
587
687
|
export declare function collectEntityRoots(
|
|
588
688
|
document: unknown, entities: ReadonlyMap<string, unknown>): Set<string>;
|
|
689
|
+
/** The root expression an entity's rows are bound through (`$.<Name>[*]`) —
|
|
690
|
+
* what an entity set exposes as `root` and what `collectEntityRoots` reads. */
|
|
691
|
+
export declare function entityRoot(name: string): string;
|
|
692
|
+
|
|
693
|
+
// ————— the derived-index and k-nearest machinery —————
|
|
694
|
+
// Constants carry their real shapes; the functions take and answer the
|
|
695
|
+
// planner's own records, which have no published type — WIDE, never
|
|
696
|
+
// wrong (the line at the top of this file).
|
|
697
|
+
|
|
698
|
+
export declare const DERIVE_KINDS: ReadonlySet<string>;
|
|
699
|
+
export declare const DERIVE_MAPPING: Readonly<Record<string, string | null>>;
|
|
700
|
+
export declare const PHYSICAL_KINDS: ReadonlySet<string>;
|
|
701
|
+
export declare const BBOX_COMPONENTS: readonly ['w', 's', 'e', 'n'];
|
|
702
|
+
export declare const BBOX_INDEX_ORDER: readonly ['w', 'e', 's', 'n'];
|
|
703
|
+
export declare const PRECISION_MIN: number;
|
|
704
|
+
export declare const PRECISION_MAX: number;
|
|
705
|
+
export declare const DIMS_MIN: number;
|
|
706
|
+
export declare const DIMS_MAX: number;
|
|
707
|
+
export declare function derivedMappingFor(kind: string, driverMapping: unknown): unknown;
|
|
708
|
+
export declare function deriveGeohash(value: unknown, precision: number): unknown;
|
|
709
|
+
export declare function deriveBboxEdge(value: unknown, component: 'w' | 's' | 'e' | 'n'): unknown;
|
|
710
|
+
export declare function deriveVector(member: unknown, dims: number): unknown;
|
|
711
|
+
export declare function storedMemberForm(member: unknown): unknown;
|
|
712
|
+
export declare function derivedValue(column: unknown, member: unknown): unknown;
|
|
713
|
+
export declare function memberAt(doc: unknown, segments: unknown): unknown;
|
|
714
|
+
export declare function registerDeriveFunctions(connection: unknown): unknown;
|
|
715
|
+
export declare function probeVector(value: unknown, dims: number): unknown;
|
|
716
|
+
export declare function columnScore(bytes: unknown, dims: number, probe: unknown): unknown;
|
|
717
|
+
export declare const KNN_MARGIN: number;
|
|
718
|
+
export declare const IDENTITY_CHUNK: number;
|
|
719
|
+
export declare function cutCandidates(rows: unknown[], m: number, margin: number): unknown;
|
|
720
|
+
export declare function identityBatches(identities: unknown[]): unknown;
|
|
589
721
|
|
|
590
722
|
// ————— the job queue (JOBS-FORMAT) —————
|
|
591
723
|
|
|
@@ -617,21 +749,25 @@ export interface JobCounts {
|
|
|
617
749
|
|
|
618
750
|
export interface JobWorker {
|
|
619
751
|
start(): JobWorker;
|
|
620
|
-
/**
|
|
621
|
-
|
|
752
|
+
/** Stop claiming, signal in-flight handlers, and wait up to `graceMs`
|
|
753
|
+
* (JOBS-FORMAT §6): the record says whether every loop drained. */
|
|
754
|
+
stop(options?: { graceMs?: number }): Promise<{ drained: boolean; inFlight: number }>;
|
|
622
755
|
stats(): { claims: number; completions: number; failures: number;
|
|
623
|
-
polls: number; wakes: number };
|
|
756
|
+
polls: number; wakes: number; claimErrors: number; inFlight: number };
|
|
624
757
|
}
|
|
625
758
|
|
|
626
759
|
export interface JobWorkerOptions {
|
|
627
|
-
handlers: Record<string,
|
|
628
|
-
|
|
760
|
+
handlers: Record<string, (payload: unknown, context: {
|
|
761
|
+
job: JobRecord; checkpointsFor: Function; signal: AbortSignal }) => unknown>;
|
|
762
|
+
/** A positive integer; the loops claiming concurrently. */
|
|
629
763
|
concurrency?: number;
|
|
630
764
|
pollInterval?: number;
|
|
631
765
|
leaseMs?: number;
|
|
632
766
|
owner?: string;
|
|
633
767
|
backoffBase?: number;
|
|
634
768
|
backoffCap?: number;
|
|
769
|
+
/** How long `stop()` waits for in-flight handlers by default. */
|
|
770
|
+
stopGraceMs?: number;
|
|
635
771
|
}
|
|
636
772
|
|
|
637
773
|
export interface JobsApi {
|
|
@@ -659,6 +795,7 @@ export interface JobsOptions {
|
|
|
659
795
|
pollInterval?: number;
|
|
660
796
|
backoffBase?: number;
|
|
661
797
|
backoffCap?: number;
|
|
798
|
+
stopGraceMs?: number;
|
|
662
799
|
/** Injectable clock and randomness — every test injects both. */
|
|
663
800
|
now?: () => number;
|
|
664
801
|
random?: () => number;
|
|
@@ -674,6 +811,7 @@ export declare function createDagJobRunner(store: Store, options: {
|
|
|
674
811
|
owner?: string;
|
|
675
812
|
backoffBase?: number;
|
|
676
813
|
backoffCap?: number;
|
|
814
|
+
stopGraceMs?: number;
|
|
677
815
|
}): JobWorker;
|
|
678
816
|
|
|
679
817
|
export declare function createJobEngine(options: {
|
|
@@ -683,4 +821,8 @@ export declare const JOBS_TABLE: string;
|
|
|
683
821
|
export declare const JOB_CHECKPOINTS_TABLE: string;
|
|
684
822
|
export declare const JOB_DEFAULTS: Readonly<{
|
|
685
823
|
maxAttempts: number; leaseMs: number; pollInterval: number;
|
|
686
|
-
backoffBase: number; backoffCap: number }>;
|
|
824
|
+
backoffBase: number; backoffCap: number; stopGraceMs: number }>;
|
|
825
|
+
/** A total diagnostic string for any value, including ones that fight back. */
|
|
826
|
+
export declare function describeValue(value: unknown): string;
|
|
827
|
+
/** A job result as the queue stores it: JSON text, or the reason it could not be. */
|
|
828
|
+
export declare function serializeResult(value: unknown): unknown;
|
package/types/node.d.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import type { Driver } from '@jarenjs/db';
|
|
3
3
|
|
|
4
4
|
export interface NodeOpenOptions {
|
|
5
|
-
|
|
5
|
+
/** The busy timeout in milliseconds. */
|
|
6
6
|
timeout?: number;
|
|
7
7
|
readOnly?: boolean;
|
|
8
|
+
/** How long work waits for an open transaction (`JD0012` after). */
|
|
9
|
+
queueTimeout?: number;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/** The `node:sqlite` binding; the builtin loads lazily inside open(). */
|