@jarenjs/db 0.46.5 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -149
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -167
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live.d.ts +0 -62
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/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
|
}
|