@jarenjs/db 0.49.2 → 0.66.1
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 +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +18 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -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,29 +8,61 @@
|
|
|
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
|
|
16
18
|
* after both endpoints exist. A foreign-key cycle among the entities
|
|
17
19
|
* being inserted or deleted is `JD0040`, reported, never a deadlock.
|
|
18
|
-
* The whole save is one transaction
|
|
19
|
-
* after
|
|
20
|
-
* retry is possible.
|
|
20
|
+
* The whole save is one transaction, and the tracker is mutated only
|
|
21
|
+
* after its statements have run — so a save that FAILS leaves the
|
|
22
|
+
* tracker exactly as it was and a retry is possible. A save that
|
|
23
|
+
* SUCCEEDS inside a larger transaction advances at once (inside it, the
|
|
24
|
+
* database does hold those rows, and every later plan and optimistic
|
|
25
|
+
* guard has to agree), and registers the withdrawal of that advance
|
|
26
|
+
* against the scope that owns the connection: an enclosing rollback
|
|
27
|
+
* takes it back, so the retry plans the same statements again rather
|
|
28
|
+
* than reporting a success it never had.
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
import { createJSONPatch } from '@jarenjs/json/patch';
|
|
24
32
|
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
25
33
|
|
|
26
34
|
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
27
|
-
import { chain } from './driver.js';
|
|
35
|
+
import { chain, attempt } from './driver.js';
|
|
28
36
|
import { translatePatch } from './patch-sql.js';
|
|
29
37
|
|
|
30
38
|
/** Rows per batched INSERT: bounded by the portable parameter budget. */
|
|
31
39
|
export const BATCH_PARAM_BUDGET = 900;
|
|
32
40
|
export const BATCH_ROW_BOUND = 100;
|
|
33
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The target keys a many-to-many membership array names: a key, or a
|
|
44
|
+
* document carrying the target's key. One reading for the unit of work
|
|
45
|
+
* and for `create()`, so the two attach the same rows.
|
|
46
|
+
* @param {any} value - the member's value
|
|
47
|
+
* @param {string} targetKey - the target entity's key property
|
|
48
|
+
* @param {string} member
|
|
49
|
+
* @param {(reason: string) => Error} refuse
|
|
50
|
+
* @returns {(string | number)[]}
|
|
51
|
+
*/
|
|
52
|
+
export function membershipKeys(value, targetKey, member, refuse) {
|
|
53
|
+
if (value === undefined || value === null) return [];
|
|
54
|
+
if (!Array.isArray(value)) throw refuse(`'${member}' must be an array to synchronise its join table`);
|
|
55
|
+
return value.map((element) => {
|
|
56
|
+
const key = typeof element === 'string' || typeof element === 'number'
|
|
57
|
+
? element
|
|
58
|
+
: element !== null && typeof element === 'object'
|
|
59
|
+
? element[targetKey] : undefined;
|
|
60
|
+
if (typeof key !== 'string' && typeof key !== 'number')
|
|
61
|
+
throw refuse(`an element of '${member}' carries no usable '${targetKey}' key`);
|
|
62
|
+
return key;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
const UNIT_SEPARATOR = '';
|
|
35
67
|
|
|
36
68
|
/**
|
|
@@ -70,10 +102,16 @@ export function createTracker(context) {
|
|
|
70
102
|
const records = new Map();
|
|
71
103
|
/** @type {Map<string, any>} */
|
|
72
104
|
const removals = new Map();
|
|
105
|
+
/** Pending membership deltas (§11.7), one per entity, own key and
|
|
106
|
+
* many-to-many member: the targets to link and the targets to unlink.
|
|
107
|
+
* @type {Map<string, { entity: string, member: string, ownKey: string | number,
|
|
108
|
+
* links: Set<string | number>, unlinks: Set<string | number> }>} */
|
|
109
|
+
const memberships = new Map();
|
|
73
110
|
let pendingSequence = 0;
|
|
74
111
|
|
|
75
|
-
|
|
76
|
-
|
|
112
|
+
// Keys may contain the membership separator themselves. Encode the
|
|
113
|
+
// whole tuple so its component boundaries and entity remain distinct.
|
|
114
|
+
const keyOf = (entityName, parts) => JSON.stringify([entityName, ...parts]);
|
|
77
115
|
|
|
78
116
|
const recordKeyFor = (entityName, doc) => {
|
|
79
117
|
const plan = coreFor(entityName).plan;
|
|
@@ -89,6 +127,12 @@ export function createTracker(context) {
|
|
|
89
127
|
collection: entityName,
|
|
90
128
|
});
|
|
91
129
|
|
|
130
|
+
/** A membership write needs the entity's own key: an `auto` key is
|
|
131
|
+
* allocated by the save, so a pending insert has none to attach to. */
|
|
132
|
+
const needsOwnKey = (entityName, member, verb) => contractError(entityName,
|
|
133
|
+
`'${member}' membership needs the entity's own key at ${verb} time — `
|
|
134
|
+
+ 'save the entity first, then attach');
|
|
135
|
+
|
|
92
136
|
const register = (entityName, doc) => {
|
|
93
137
|
deepFreeze(doc);
|
|
94
138
|
const key = recordKeyFor(entityName, doc);
|
|
@@ -169,6 +213,86 @@ export function createTracker(context) {
|
|
|
169
213
|
});
|
|
170
214
|
};
|
|
171
215
|
|
|
216
|
+
/** The pending insert a caller is holding, found by the identity of
|
|
217
|
+
* the document `add()` handed back: a record whose key the save has
|
|
218
|
+
* yet to allocate has nothing else to be found by. */
|
|
219
|
+
const pendingInsertHolding = (entityName, doc) => {
|
|
220
|
+
for (const record of records.values()) {
|
|
221
|
+
if (record.pendingInsert === true && record.entity === entityName
|
|
222
|
+
&& record.current === doc) return record;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** What a join op and its membership delta are filed under: the own
|
|
228
|
+
* key once there is one, and the pending record's own identity while
|
|
229
|
+
* the save has yet to allocate it. */
|
|
230
|
+
const ownToken = (entityName, ownKey, ownRecord) => (ownRecord === undefined
|
|
231
|
+
? keyOf(entityName, [ownKey])
|
|
232
|
+
: ownRecord.pendingKey);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The pending membership delta a `link`/`unlink` addresses (§11.7):
|
|
236
|
+
* the member must be a many-to-many relation of the entity; the own
|
|
237
|
+
* key is read from a key or a document; the target is a key or a
|
|
238
|
+
* document carrying the target's key — the reading a membership array
|
|
239
|
+
* gets, so the two attach the same rows.
|
|
240
|
+
*
|
|
241
|
+
* A document whose key the save allocates carries none to attach to,
|
|
242
|
+
* so the delta is filed against the pending INSERT it belongs to and
|
|
243
|
+
* the join row takes the key that insert returns.
|
|
244
|
+
*/
|
|
245
|
+
const membershipOf = (entityName, own, member, target, verb) => {
|
|
246
|
+
const plan = coreFor(entityName).plan;
|
|
247
|
+
const relation = entities.get(entityName).properties.get(member)?.relation;
|
|
248
|
+
if (relation === undefined || relation.kind !== 'manyToMany') {
|
|
249
|
+
throw contractError(entityName, relation === undefined
|
|
250
|
+
? `'${member}' is not a relation member of '${entityName}' — ${verb}() attaches a `
|
|
251
|
+
+ 'many-to-many membership through its join table'
|
|
252
|
+
: `'${member}' is a ${relation.kind} relation — ${verb}() attaches many-to-many `
|
|
253
|
+
+ "memberships only; write the related entity's foreign key instead");
|
|
254
|
+
}
|
|
255
|
+
let ownKey;
|
|
256
|
+
let ownRecord;
|
|
257
|
+
if (own !== null && typeof own === 'object' && !Array.isArray(own)) {
|
|
258
|
+
ownKey = own[plan.keys[0]];
|
|
259
|
+
if (typeof ownKey !== 'string' && typeof ownKey !== 'number') {
|
|
260
|
+
ownRecord = pendingInsertHolding(entityName, own);
|
|
261
|
+
if (ownRecord === undefined) throw needsOwnKey(entityName, member, `${verb}()`);
|
|
262
|
+
ownKey = undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
ownKey = coreFor(entityName).normalizeKey(own)[0];
|
|
267
|
+
}
|
|
268
|
+
const targetKey = mapping.entities[relation.to].keys[0];
|
|
269
|
+
const [key] = membershipKeys([target], targetKey, member,
|
|
270
|
+
(reason) => contractError(entityName, reason));
|
|
271
|
+
const id = `${ownToken(entityName, ownKey, ownRecord)}${UNIT_SEPARATOR}${member}`;
|
|
272
|
+
let pending = memberships.get(id);
|
|
273
|
+
if (pending === undefined) {
|
|
274
|
+
pending = { entity: entityName, member, ownKey, ownRecord,
|
|
275
|
+
links: new Set(), unlinks: new Set() };
|
|
276
|
+
memberships.set(id, pending);
|
|
277
|
+
}
|
|
278
|
+
return { pending, key };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Attach one membership (local, synchronous); the last word on one
|
|
282
|
+
* target wins, so `unlink` after `link` means unlink. */
|
|
283
|
+
const link = (entityName, own, member, target) => {
|
|
284
|
+
const { pending, key } = membershipOf(entityName, own, member, target, 'link');
|
|
285
|
+
pending.unlinks.delete(key);
|
|
286
|
+
pending.links.add(key);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Detach one membership (local, synchronous). */
|
|
290
|
+
const unlink = (entityName, own, member, target) => {
|
|
291
|
+
const { pending, key } = membershipOf(entityName, own, member, target, 'unlink');
|
|
292
|
+
pending.links.delete(key);
|
|
293
|
+
pending.unlinks.add(key);
|
|
294
|
+
};
|
|
295
|
+
|
|
172
296
|
const counts = () => {
|
|
173
297
|
let pendingInserts = 0;
|
|
174
298
|
for (const record of records.values()) {
|
|
@@ -178,6 +302,7 @@ export function createTracker(context) {
|
|
|
178
302
|
tracked: records.size - pendingInserts,
|
|
179
303
|
pendingInserts,
|
|
180
304
|
pendingDeletes: removals.size,
|
|
305
|
+
pendingMemberships: memberships.size,
|
|
181
306
|
};
|
|
182
307
|
};
|
|
183
308
|
|
|
@@ -275,29 +400,31 @@ export function createTracker(context) {
|
|
|
275
400
|
return { columnSets, docBuild, m2mMembers, fallback };
|
|
276
401
|
};
|
|
277
402
|
|
|
403
|
+
/** The join-table endpoints a many-to-many member writes through. The
|
|
404
|
+
* endpoint columns come from the mapping, never from the join table's
|
|
405
|
+
* NAME: an entity name with an underscore, or a `through` name, does
|
|
406
|
+
* not split into its endpoints. */
|
|
407
|
+
const joinEndpoints = (entityName, member) => {
|
|
408
|
+
const relation = entities.get(entityName).properties.get(member).relation;
|
|
409
|
+
const join = mapping.joinTables[relation.joinTable];
|
|
410
|
+
const own = join.left.entity === entityName ? join.left : join.right;
|
|
411
|
+
const target = own === join.left ? join.right : join.left;
|
|
412
|
+
return {
|
|
413
|
+
entity: entityName,
|
|
414
|
+
member,
|
|
415
|
+
joinTable: relation.joinTable,
|
|
416
|
+
ownColumn: own.column,
|
|
417
|
+
targetColumn: target.column,
|
|
418
|
+
tableColumns: [join.left.column, join.right.column],
|
|
419
|
+
targetKey: mapping.entities[relation.to].keys[0],
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
|
|
278
423
|
/** Compute a many-to-many member's join-row difference by key sets. */
|
|
279
424
|
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
|
-
};
|
|
425
|
+
const endpoints = joinEndpoints(entityName, member);
|
|
426
|
+
const extract = (value) => membershipKeys(value, endpoints.targetKey, member,
|
|
427
|
+
(reason) => contractError(entityName, reason));
|
|
301
428
|
// a snapshot that never LOADED the member knows nothing about the
|
|
302
429
|
// current membership — treating unknown as empty would re-insert
|
|
303
430
|
// existing rows (a UNIQUE violation the seeded corpus found); the
|
|
@@ -307,15 +434,27 @@ export function createTracker(context) {
|
|
|
307
434
|
? null
|
|
308
435
|
: [...new Set(extract(memberValue))];
|
|
309
436
|
return {
|
|
310
|
-
|
|
311
|
-
ownColumn: `${entityName}_key`,
|
|
312
|
-
targetColumn: `${relation.to}_key`,
|
|
437
|
+
...endpoints,
|
|
313
438
|
ownKey,
|
|
314
439
|
beforeKeys,
|
|
315
440
|
afterKeys: [...new Set(extract(after[member]))],
|
|
316
441
|
};
|
|
317
442
|
};
|
|
318
443
|
|
|
444
|
+
/** A pending `link`/`unlink` delta as a join op. Its baseline is the
|
|
445
|
+
* join table as read at save time, so linking a member that exists
|
|
446
|
+
* and unlinking one that does not are no-ops — the two-run property. */
|
|
447
|
+
const membershipDelta = (pending) => ({
|
|
448
|
+
...joinEndpoints(pending.entity, pending.member),
|
|
449
|
+
ownKey: pending.ownKey,
|
|
450
|
+
ownRecord: pending.ownRecord,
|
|
451
|
+
// a row the save is about to INSERT has no memberships to read: the
|
|
452
|
+
// key does not exist yet, so its baseline is empty rather than unknown
|
|
453
|
+
beforeKeys: pending.ownRecord === undefined ? null : [],
|
|
454
|
+
links: [...pending.links],
|
|
455
|
+
unlinks: [...pending.unlinks],
|
|
456
|
+
});
|
|
457
|
+
|
|
319
458
|
/** Relation members riding a pending INSERT: many-to-many becomes
|
|
320
459
|
* join rows; anything else refuses — projections are not state. */
|
|
321
460
|
const insertRelationOps = (record) => {
|
|
@@ -334,12 +473,16 @@ export function createTracker(context) {
|
|
|
334
473
|
+ 'projection, not stored state; add the related entities themselves');
|
|
335
474
|
}
|
|
336
475
|
const ownKey = record.current[plan.keys[0]];
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
ops.push(
|
|
476
|
+
const known = typeof ownKey === 'string' || typeof ownKey === 'number';
|
|
477
|
+
if (!known && plan.autoKey === null)
|
|
478
|
+
throw needsOwnKey(record.entity, property.name, 'add()');
|
|
479
|
+
// the key this row will have is the one its INSERT returns, so the
|
|
480
|
+
// join row is planned against the record and takes the key at run time
|
|
481
|
+
ops.push({
|
|
482
|
+
...joinDiff(record.entity, null, record.current,
|
|
483
|
+
known ? ownKey : undefined, property.name),
|
|
484
|
+
ownRecord: known ? undefined : record,
|
|
485
|
+
});
|
|
343
486
|
}
|
|
344
487
|
return ops;
|
|
345
488
|
};
|
|
@@ -352,6 +495,10 @@ export function createTracker(context) {
|
|
|
352
495
|
const updates = [];
|
|
353
496
|
/** @type {any[]} */
|
|
354
497
|
const joinOps = [];
|
|
498
|
+
/** Records this save advances through their join table alone: they
|
|
499
|
+
* carry no statement of their own, and the commit still settles
|
|
500
|
+
* them, so the undo delta has to know about them. @type {any[]} */
|
|
501
|
+
const joinOnly = [];
|
|
355
502
|
let fallbacks = 0;
|
|
356
503
|
const unversioned = new Set();
|
|
357
504
|
|
|
@@ -368,6 +515,10 @@ export function createTracker(context) {
|
|
|
368
515
|
continue;
|
|
369
516
|
}
|
|
370
517
|
if (record.current === record.snapshot) continue;
|
|
518
|
+
// a record with a pending removal is deleted, not updated: planning
|
|
519
|
+
// both bumped the version on the UPDATE and left the DELETE's
|
|
520
|
+
// snapshot guard matching nothing (JD2040), so the row survived
|
|
521
|
+
if (removals.has(recordKeyFor(record.entity, record.snapshot) ?? '')) continue;
|
|
371
522
|
// probe before stamping: an update stamp must never turn a
|
|
372
523
|
// deep-equal replacement into a phantom write
|
|
373
524
|
if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
|
|
@@ -380,7 +531,10 @@ export function createTracker(context) {
|
|
|
380
531
|
if (parts.columnSets.size === 0 && parts.docBuild === null
|
|
381
532
|
&& !parts.fallback) {
|
|
382
533
|
// nothing but join-table changes (or a no-op put)
|
|
383
|
-
if (parts.m2mMembers.size > 0)
|
|
534
|
+
if (parts.m2mMembers.size > 0) {
|
|
535
|
+
record.joinOnly = true;
|
|
536
|
+
joinOnly.push(record);
|
|
537
|
+
}
|
|
384
538
|
else record.stamped = undefined;
|
|
385
539
|
continue;
|
|
386
540
|
}
|
|
@@ -397,6 +551,23 @@ export function createTracker(context) {
|
|
|
397
551
|
unversioned.add(removal.entity);
|
|
398
552
|
}
|
|
399
553
|
|
|
554
|
+
// a link/unlink beside a put-based synchronisation of the SAME member
|
|
555
|
+
// folds into that op's key set: one intent per entity, own key and
|
|
556
|
+
// member, never two statements racing for one row
|
|
557
|
+
const synced = new Map(joinOps.map((op) =>
|
|
558
|
+
[`${ownToken(op.entity, op.ownKey, op.ownRecord)}${UNIT_SEPARATOR}${op.member}`, op]));
|
|
559
|
+
for (const [id, pending] of memberships) {
|
|
560
|
+
const diff = synced.get(id);
|
|
561
|
+
if (diff === undefined) {
|
|
562
|
+
joinOps.push(membershipDelta(pending));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const after = new Set(diff.afterKeys);
|
|
566
|
+
for (const key of pending.unlinks) after.delete(key);
|
|
567
|
+
for (const key of pending.links) after.add(key);
|
|
568
|
+
diff.afterKeys = [...after];
|
|
569
|
+
}
|
|
570
|
+
|
|
400
571
|
// resolve unknown membership baselines, then finalize each op
|
|
401
572
|
const resolveJoins = (i) => {
|
|
402
573
|
if (i >= joinOps.length) return null;
|
|
@@ -413,6 +584,11 @@ export function createTracker(context) {
|
|
|
413
584
|
const finalizeJoins = () => {
|
|
414
585
|
for (const op of joinOps) {
|
|
415
586
|
const before = new Set(op.beforeKeys);
|
|
587
|
+
if (op.links !== undefined) {
|
|
588
|
+
op.added = op.links.filter((key) => !before.has(key));
|
|
589
|
+
op.removed = op.unlinks.filter((key) => before.has(key));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
416
592
|
const after = new Set(op.afterKeys);
|
|
417
593
|
op.added = op.afterKeys.filter((key) => !before.has(key));
|
|
418
594
|
op.removed = op.beforeKeys.filter((key) => !after.has(key));
|
|
@@ -525,7 +701,10 @@ export function createTracker(context) {
|
|
|
525
701
|
+ `(${q(op.ownColumn)}, ${q(op.targetColumn)}) VALUES `
|
|
526
702
|
+ op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
|
|
527
703
|
statements.push({
|
|
528
|
-
kind: 'join-insert', entity: op.joinTable, sql,
|
|
704
|
+
kind: 'join-insert', entity: op.joinTable, sql, tableColumns: op.tableColumns,
|
|
705
|
+
// an own key the save has yet to allocate is filled in from the
|
|
706
|
+
// insert's RETURNING, which the ordering above guarantees has run
|
|
707
|
+
ownFrom: op.ownRecord,
|
|
529
708
|
params: op.added.flatMap((key) => [op.ownKey, key]),
|
|
530
709
|
joinRows: op.added.map((key) => ({
|
|
531
710
|
own: op.ownKey, target: key,
|
|
@@ -535,7 +714,7 @@ export function createTracker(context) {
|
|
|
535
714
|
}
|
|
536
715
|
for (const key of op.removed) {
|
|
537
716
|
statements.push({
|
|
538
|
-
kind: 'join-delete', entity: op.joinTable,
|
|
717
|
+
kind: 'join-delete', entity: op.joinTable, tableColumns: op.tableColumns,
|
|
539
718
|
sql: `DELETE FROM ${q(op.joinTable)} WHERE ${q(op.ownColumn)} = ${parameterAt(1)} `
|
|
540
719
|
+ `AND ${q(op.targetColumn)} = ${parameterAt(2)}`,
|
|
541
720
|
params: [op.ownKey, key],
|
|
@@ -567,7 +746,7 @@ export function createTracker(context) {
|
|
|
567
746
|
}
|
|
568
747
|
}
|
|
569
748
|
|
|
570
|
-
return { statements, fallbacks, unversioned: [...unversioned].sort() };
|
|
749
|
+
return { statements, fallbacks, joinOnly, unversioned: [...unversioned].sort() };
|
|
571
750
|
};
|
|
572
751
|
return chain(resolveJoins(0), assemble);
|
|
573
752
|
};
|
|
@@ -608,6 +787,15 @@ export function createTracker(context) {
|
|
|
608
787
|
const next = (i) => {
|
|
609
788
|
if (i >= statements.length) return report;
|
|
610
789
|
const statement = statements[i];
|
|
790
|
+
// a join row whose own key the save allocates: the INSERT that
|
|
791
|
+
// allocates it has already run (inserts precede join rows), so the
|
|
792
|
+
// key is on the record by now
|
|
793
|
+
if (statement.ownFrom !== undefined) {
|
|
794
|
+
const ownKey = statement.ownFrom.allocatedKey;
|
|
795
|
+
statement.params = statement.joinRows.flatMap(
|
|
796
|
+
(/** @type {any} */ row) => [ownKey, row.target]);
|
|
797
|
+
for (const row of statement.joinRows) row.own = ownKey;
|
|
798
|
+
}
|
|
611
799
|
return chain(connection.prepare(statement.sql), (prepared) => {
|
|
612
800
|
if (statement.kind === 'insert' && statement.returning === true) {
|
|
613
801
|
let fetched;
|
|
@@ -623,6 +811,9 @@ export function createTracker(context) {
|
|
|
623
811
|
// test, not assumed silently)
|
|
624
812
|
const keys = rows.map((row) => row.key).sort((a, b) => a - b);
|
|
625
813
|
statement.generatedKeys = keys;
|
|
814
|
+
// a join row planned against one of these records reads its
|
|
815
|
+
// key from here, before the commit phase re-keys anything
|
|
816
|
+
statement.records.forEach((record, at) => { record.allocatedKey = keys[at]; });
|
|
626
817
|
report.inserted += statement.records.length;
|
|
627
818
|
report.statements.push({ sql: statement.sql, rows: statement.records.length });
|
|
628
819
|
return next(i + 1);
|
|
@@ -633,14 +824,8 @@ export function createTracker(context) {
|
|
|
633
824
|
? captureJoinDelete(statement.entity, statement.removal.parts)
|
|
634
825
|
: null,
|
|
635
826
|
() => {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
ran = prepared.run(statement.params);
|
|
639
|
-
}
|
|
640
|
-
catch (error) {
|
|
641
|
-
throw wrapDb(error, statement);
|
|
642
|
-
}
|
|
643
|
-
return chain(ran, (outcome) => {
|
|
827
|
+
return chain(attempt(() => prepared.run(statement.params),
|
|
828
|
+
(error) => wrapDb(error, statement)), (outcome) => {
|
|
644
829
|
const changed = Number(outcome?.changes ?? 0);
|
|
645
830
|
report.statements.push({ sql: statement.sql, rows: changed });
|
|
646
831
|
if (statement.kind === 'insert') report.inserted += statement.records.length;
|
|
@@ -663,21 +848,116 @@ export function createTracker(context) {
|
|
|
663
848
|
return next(0);
|
|
664
849
|
};
|
|
665
850
|
|
|
666
|
-
/**
|
|
667
|
-
|
|
851
|
+
/**
|
|
852
|
+
* The tracker state a save's commit will advance, exactly as it stands
|
|
853
|
+
* before the save runs: the map slot behind every record the commit
|
|
854
|
+
* may re-key or drop, the fields it may overwrite on a record it
|
|
855
|
+
* keeps, and the pending removals and membership deltas, which it
|
|
856
|
+
* clears whole. Bounded by the save, never by the tracker's size.
|
|
857
|
+
*
|
|
858
|
+
* `planSave` names the join-only records because they carry no
|
|
859
|
+
* statement of their own and the commit still advances them.
|
|
860
|
+
* @param {any[]} statements
|
|
861
|
+
* @param {any[]} joinOnly
|
|
862
|
+
*/
|
|
863
|
+
const undoFor = (statements, joinOnly) => {
|
|
864
|
+
/** @type {Map<string, any>} */
|
|
865
|
+
const slots = new Map();
|
|
866
|
+
/** @type {Map<any, any>} */
|
|
867
|
+
const fields = new Map();
|
|
868
|
+
const takeSlot = (key) => {
|
|
869
|
+
if (!slots.has(key)) slots.set(key, records.get(key));
|
|
870
|
+
};
|
|
871
|
+
const takeFields = (record) => {
|
|
872
|
+
if (record === undefined || fields.has(record)) return;
|
|
873
|
+
fields.set(record, {
|
|
874
|
+
snapshot: record.snapshot, current: record.current,
|
|
875
|
+
stamped: record.stamped, joinOnly: record.joinOnly,
|
|
876
|
+
pendingInsert: record.pendingInsert, saved: record.saved,
|
|
877
|
+
});
|
|
878
|
+
};
|
|
879
|
+
for (const statement of statements) {
|
|
880
|
+
if (statement.kind === 'insert') {
|
|
881
|
+
for (const record of statement.records) {
|
|
882
|
+
takeSlot(record.pendingKey);
|
|
883
|
+
takeFields(record);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else if (statement.kind === 'update') takeFields(statement.record);
|
|
887
|
+
else if (statement.kind === 'delete') {
|
|
888
|
+
const key = keyOf(statement.removal.entity, statement.removal.parts);
|
|
889
|
+
takeSlot(key);
|
|
890
|
+
takeFields(records.get(key));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
for (const record of joinOnly) takeFields(record);
|
|
894
|
+
return {
|
|
895
|
+
slots, fields,
|
|
896
|
+
removals: [...removals],
|
|
897
|
+
// the delta sets are mutated in place by a later link()/unlink(),
|
|
898
|
+
// so the undo needs copies rather than the live ones
|
|
899
|
+
memberships: [...memberships].map(([id, pending]) => [id, {
|
|
900
|
+
...pending, links: new Set(pending.links), unlinks: new Set(pending.unlinks),
|
|
901
|
+
}]),
|
|
902
|
+
};
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
/** Put back what {@link undoFor} took a copy of: the save's statements
|
|
906
|
+
* were rolled back, so every claim they made about the database is
|
|
907
|
+
* withdrawn and a retry plans them again. */
|
|
908
|
+
const restore = (undo) => {
|
|
909
|
+
for (const [key, entry] of undo.slots) {
|
|
910
|
+
if (entry === undefined) records.delete(key);
|
|
911
|
+
else records.set(key, entry);
|
|
912
|
+
}
|
|
913
|
+
for (const [record, was] of undo.fields) {
|
|
914
|
+
record.snapshot = was.snapshot;
|
|
915
|
+
record.current = was.current;
|
|
916
|
+
record.stamped = was.stamped;
|
|
917
|
+
record.joinOnly = was.joinOnly;
|
|
918
|
+
record.pendingInsert = was.pendingInsert;
|
|
919
|
+
record.saved = was.saved;
|
|
920
|
+
}
|
|
921
|
+
removals.clear();
|
|
922
|
+
for (const [key, removal] of undo.removals) removals.set(key, removal);
|
|
923
|
+
memberships.clear();
|
|
924
|
+
for (const [id, pending] of undo.memberships) memberships.set(id, pending);
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Advance phase: runs as soon as every statement of the save has
|
|
929
|
+
* succeeded — inside an enclosing transaction too, where the database
|
|
930
|
+
* already holds these rows and every later read, plan and optimistic
|
|
931
|
+
* guard must agree. What is registered as a settlement effect is only
|
|
932
|
+
* the WITHDRAWAL of this advance ({@link restore} over `undo`), which
|
|
933
|
+
* the owning scope runs if it rolls back.
|
|
934
|
+
*
|
|
935
|
+
* `undo` is the state the save started from. It is read for one
|
|
936
|
+
* decision: an edit made to a tracked record AFTER this save was
|
|
937
|
+
* planned is still pending work, and only a record left exactly as
|
|
938
|
+
* the save found it becomes clean.
|
|
939
|
+
* @param {any[]} statements
|
|
940
|
+
* @param {any} undo
|
|
941
|
+
*/
|
|
942
|
+
const commit = (statements, undo) => {
|
|
943
|
+
const untouched = (record) => !undo.fields.has(record)
|
|
944
|
+
|| undo.fields.get(record).current === record.current;
|
|
668
945
|
for (const statement of statements) {
|
|
669
946
|
if (statement.kind === 'insert') {
|
|
670
947
|
statement.records.forEach((record, i) => {
|
|
671
948
|
const plan = coreFor(statement.entity).plan;
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
949
|
+
// the row this statement wrote is the one the save PLANNED; an
|
|
950
|
+
// edit made to the same record afterwards is not in the database
|
|
951
|
+
const planned = undo.fields.get(record)?.current ?? record.current;
|
|
952
|
+
const doc = statement.returning === true
|
|
953
|
+
? deepFreeze({ ...planned, [plan.autoKey]: statement.generatedKeys[i] })
|
|
954
|
+
: planned;
|
|
676
955
|
// re-key under the real identity
|
|
677
956
|
records.delete(record.pendingKey);
|
|
678
957
|
const key = recordKeyFor(statement.entity, doc);
|
|
679
958
|
records.set(/** @type {string} */ (key), {
|
|
680
|
-
entity: statement.entity, snapshot: doc,
|
|
959
|
+
entity: statement.entity, snapshot: doc,
|
|
960
|
+
current: untouched(record) ? doc : record.current, pendingInsert: false,
|
|
681
961
|
});
|
|
682
962
|
record.saved = doc;
|
|
683
963
|
captureRecord?.(statement.entity,
|
|
@@ -691,8 +971,9 @@ export function createTracker(context) {
|
|
|
691
971
|
const saved = statement.newVersion === null
|
|
692
972
|
? record.stamped
|
|
693
973
|
: deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
|
|
974
|
+
const pending = untouched(record) ? null : record.current;
|
|
694
975
|
record.snapshot = deepFreeze(saved);
|
|
695
|
-
record.current = record.snapshot;
|
|
976
|
+
record.current = pending ?? record.snapshot;
|
|
696
977
|
record.stamped = undefined;
|
|
697
978
|
captureRecord?.(statement.entity,
|
|
698
979
|
plan.keys.map((k) => record.snapshot[k]), before, record.snapshot);
|
|
@@ -709,12 +990,11 @@ export function createTracker(context) {
|
|
|
709
990
|
else if (statement.kind === 'join-insert' || statement.kind === 'join-delete') {
|
|
710
991
|
for (const row of statement.joinRows ?? []) {
|
|
711
992
|
// the join-row "document" lists its columns in table order
|
|
712
|
-
// (the
|
|
713
|
-
const pair = statement.entity.split('_');
|
|
993
|
+
// (the mapping's left, right) so both capture modes agree exactly
|
|
714
994
|
const value = { [row.ownColumn]: row.own, [row.targetColumn]: row.target };
|
|
715
995
|
const ordered = {};
|
|
716
|
-
for (const
|
|
717
|
-
const keyParts =
|
|
996
|
+
for (const column of statement.tableColumns) ordered[column] = value[column];
|
|
997
|
+
const keyParts = statement.tableColumns.map((column) => ordered[column]);
|
|
718
998
|
if (statement.kind === 'join-insert') {
|
|
719
999
|
captureRecord?.(statement.entity, keyParts, null, ordered);
|
|
720
1000
|
}
|
|
@@ -727,18 +1007,20 @@ export function createTracker(context) {
|
|
|
727
1007
|
// join-only records: their member state is now persisted
|
|
728
1008
|
for (const record of records.values()) {
|
|
729
1009
|
if (record.joinOnly === true) {
|
|
730
|
-
|
|
731
|
-
record.
|
|
1010
|
+
const pending = untouched(record) ? null : record.current;
|
|
1011
|
+
record.snapshot = record.stamped ?? undo.fields.get(record)?.current ?? record.current;
|
|
1012
|
+
record.current = pending ?? record.snapshot;
|
|
732
1013
|
record.joinOnly = undefined;
|
|
733
1014
|
record.stamped = undefined;
|
|
734
1015
|
}
|
|
735
1016
|
}
|
|
736
1017
|
removals.clear();
|
|
1018
|
+
memberships.clear();
|
|
737
1019
|
};
|
|
738
1020
|
|
|
739
1021
|
const saveChanges = () => {
|
|
740
1022
|
const startedAt = performance.now();
|
|
741
|
-
return chain(planSave(), ({ statements, fallbacks, unversioned }) => {
|
|
1023
|
+
return chain(planSave(), ({ statements, fallbacks, joinOnly, unversioned }) => {
|
|
742
1024
|
const report = {
|
|
743
1025
|
inserted: 0, updated: 0, deleted: 0,
|
|
744
1026
|
joinInserted: 0, joinDeleted: 0,
|
|
@@ -754,10 +1036,22 @@ export function createTracker(context) {
|
|
|
754
1036
|
report.elapsedMs = performance.now() - startedAt;
|
|
755
1037
|
return report;
|
|
756
1038
|
}
|
|
1039
|
+
// what the tracker looked like before the save, taken while it still
|
|
1040
|
+
// does: the statements below run in a savepoint whose release is not
|
|
1041
|
+
// a commit, so the right to KEEP what they justify waits for one
|
|
1042
|
+
const undo = undoFor(statements, joinOnly);
|
|
757
1043
|
return chain(
|
|
758
1044
|
connection.transaction(() => runStatements(statements, report)),
|
|
759
1045
|
(finished) => {
|
|
760
|
-
|
|
1046
|
+
// The advance itself lands now, because inside the transaction the
|
|
1047
|
+
// database DOES hold these rows: every later read, plan and
|
|
1048
|
+
// optimistic guard in this unit of work has to agree with that, and
|
|
1049
|
+
// a tracker still calling them pending would write them twice.
|
|
1050
|
+
// What waits for the commit is the right to keep the advance — the
|
|
1051
|
+
// enclosing scope withdraws it if it rolls back, which is what
|
|
1052
|
+
// makes a caller's retry plan the same statements again.
|
|
1053
|
+
commit(statements, undo);
|
|
1054
|
+
connection.onSettle({ rollback: () => restore(undo) });
|
|
761
1055
|
finished.elapsedMs = performance.now() - startedAt;
|
|
762
1056
|
return finished;
|
|
763
1057
|
});
|
|
@@ -767,10 +1061,15 @@ export function createTracker(context) {
|
|
|
767
1061
|
/** Drop tracking for a key without scheduling anything. */
|
|
768
1062
|
const discard = (entityName, keyOrDoc) => {
|
|
769
1063
|
const parts = coreFor(entityName).normalizeKey(keyOrDoc);
|
|
770
|
-
|
|
1064
|
+
const key = keyOf(entityName, parts);
|
|
1065
|
+
records.delete(key);
|
|
1066
|
+
// a pending membership change belongs to the key it attaches to
|
|
1067
|
+
for (const id of memberships.keys()) {
|
|
1068
|
+
if (id.startsWith(`${key}${UNIT_SEPARATOR}`)) memberships.delete(id);
|
|
1069
|
+
}
|
|
771
1070
|
};
|
|
772
1071
|
|
|
773
1072
|
return {
|
|
774
|
-
register, registerGraph, add, put, remove, discard, counts, saveChanges,
|
|
1073
|
+
register, registerGraph, add, put, remove, discard, link, unlink, counts, saveChanges,
|
|
775
1074
|
};
|
|
776
1075
|
}
|