@jarenjs/db 0.34.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 +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- package/types/wasm.d.ts +5 -0
package/src/tracker.js
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The unit of work (§11): copy-on-write change tracking and
|
|
4
|
+
* minimal writes. Materialised entities are plain, DEEP-FROZEN JSON —
|
|
5
|
+
* no proxies anywhere — and the tracker retains exactly ONE reference
|
|
6
|
+
* per tracked entity: the frozen document itself is the snapshot.
|
|
7
|
+
* Mutation is replacement (`put(next)`); `saveChanges()` diffs
|
|
8
|
+
* snapshot against current with the suite's own diff engine and plans
|
|
9
|
+
* the MINIMAL set of parameterised statements: scalar/epoch/foreign-
|
|
10
|
+
* key column writes, `jsonb_set`/`jsonb_remove` chains for document
|
|
11
|
+
* paths, join-table synchronisation for many-to-many members, and a
|
|
12
|
+
* counted whole-row fallback for anything untranslatable.
|
|
13
|
+
*
|
|
14
|
+
* Ordering never violates a foreign key mid-transaction: inserts run
|
|
15
|
+
* parent-first, deletes child-first, updates in between, join rows
|
|
16
|
+
* after both endpoints exist. A foreign-key cycle among the entities
|
|
17
|
+
* being inserted or deleted is `JD0040`, reported, never a deadlock.
|
|
18
|
+
* The whole save is one transaction; the tracker is mutated ONLY
|
|
19
|
+
* after commit, so a failed save leaves it exactly as it was and a
|
|
20
|
+
* retry is possible.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createJSONPatch } from '@jarenjs/json/patch';
|
|
24
|
+
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
25
|
+
|
|
26
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
27
|
+
import { chain } from './driver.js';
|
|
28
|
+
import { translatePatch } from './patch-sql.js';
|
|
29
|
+
|
|
30
|
+
/** Rows per batched INSERT: bounded by the portable parameter budget. */
|
|
31
|
+
export const BATCH_PARAM_BUDGET = 900;
|
|
32
|
+
export const BATCH_ROW_BOUND = 100;
|
|
33
|
+
|
|
34
|
+
const UNIT_SEPARATOR = '';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deep-freeze a JSON value in place and return it. Idempotent; shared
|
|
38
|
+
* substructure (a graph load's children) freezes once.
|
|
39
|
+
* @template T
|
|
40
|
+
* @param {T} value
|
|
41
|
+
* @returns {T}
|
|
42
|
+
*/
|
|
43
|
+
export function deepFreeze(value) {
|
|
44
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value))
|
|
45
|
+
return value;
|
|
46
|
+
Object.freeze(value);
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
for (const item of value) deepFreeze(item);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
for (const key of Object.keys(value)) deepFreeze(/** @type {any} */ (value)[key]);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The store-level unit of work.
|
|
57
|
+
* @param {{ connection: any, entities: Map<string, any>, mapping: any,
|
|
58
|
+
* coreFor: (name: string) => any }} context
|
|
59
|
+
* @returns {any}
|
|
60
|
+
*/
|
|
61
|
+
export function createTracker(context) {
|
|
62
|
+
const { connection, entities, mapping, coreFor } = context;
|
|
63
|
+
const captureRecord = context.captureRecord ?? null;
|
|
64
|
+
const captureJoinDelete = context.captureJoinDelete ?? null;
|
|
65
|
+
const dialect = connection.dialect;
|
|
66
|
+
const q = dialect.quoteIdentifier;
|
|
67
|
+
const parameterAt = (i) => dialect.parameterRef(i, 'v');
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, any>} */
|
|
70
|
+
const records = new Map();
|
|
71
|
+
/** @type {Map<string, any>} */
|
|
72
|
+
const removals = new Map();
|
|
73
|
+
let pendingSequence = 0;
|
|
74
|
+
|
|
75
|
+
const keyOf = (entityName, parts) =>
|
|
76
|
+
`${entityName}${UNIT_SEPARATOR}${parts.join(UNIT_SEPARATOR)}`;
|
|
77
|
+
|
|
78
|
+
const recordKeyFor = (entityName, doc) => {
|
|
79
|
+
const plan = coreFor(entityName).plan;
|
|
80
|
+
const parts = plan.keys.map((k) => doc[k]);
|
|
81
|
+
if (parts.some((part) => typeof part !== 'string' && typeof part !== 'number'))
|
|
82
|
+
return null;
|
|
83
|
+
return keyOf(entityName, parts);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const contractError = (entityName, reason) => new DbRuntimeError('JD2003',
|
|
87
|
+
reason, {
|
|
88
|
+
docPath: entities.get(entityName)?.docPath ?? '/entities',
|
|
89
|
+
collection: entityName,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const register = (entityName, doc) => {
|
|
93
|
+
deepFreeze(doc);
|
|
94
|
+
const key = recordKeyFor(entityName, doc);
|
|
95
|
+
if (key === null) return doc; // no usable key — plain data
|
|
96
|
+
const existing = records.get(key);
|
|
97
|
+
// a re-read refreshes a CLEAN record; a dirty record stays
|
|
98
|
+
// authoritative for the save in flight (§11.1)
|
|
99
|
+
if (existing === undefined
|
|
100
|
+
|| (existing.current === existing.snapshot && existing.pendingInsert !== true)) {
|
|
101
|
+
records.set(key, {
|
|
102
|
+
entity: entityName, snapshot: doc, current: doc, pendingInsert: false,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return doc;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Register a graph load: the root and every included child. */
|
|
109
|
+
const registerGraph = (tree, docs) => {
|
|
110
|
+
const walk = (node, doc) => {
|
|
111
|
+
register(node.entity.name, doc);
|
|
112
|
+
for (const include of node.includes) {
|
|
113
|
+
if (include.count === true) continue;
|
|
114
|
+
const value = doc[include.name];
|
|
115
|
+
if (include.many) {
|
|
116
|
+
for (const child of value ?? []) walk(include.child, child);
|
|
117
|
+
}
|
|
118
|
+
else if (value !== null && value !== undefined) {
|
|
119
|
+
walk(include.child, value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
for (const doc of docs) walk(tree, doc);
|
|
124
|
+
return docs;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const add = (entityName, doc) => {
|
|
128
|
+
const core = coreFor(entityName);
|
|
129
|
+
const completed = deepFreeze(core.complete(doc, { updating: false }));
|
|
130
|
+
const key = recordKeyFor(entityName, completed)
|
|
131
|
+
?? `${entityName}${UNIT_SEPARATOR}#pending${pendingSequence++}`;
|
|
132
|
+
records.set(key, {
|
|
133
|
+
entity: entityName, snapshot: null, current: completed,
|
|
134
|
+
pendingInsert: true, pendingKey: key,
|
|
135
|
+
});
|
|
136
|
+
return completed;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const put = (entityName, next) => {
|
|
140
|
+
const core = coreFor(entityName);
|
|
141
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next))
|
|
142
|
+
throw contractError(entityName, 'put() takes an entity document');
|
|
143
|
+
const key = recordKeyFor(entityName, next);
|
|
144
|
+
const record = key === null ? undefined : records.get(key);
|
|
145
|
+
if (record === undefined) {
|
|
146
|
+
throw new DbRuntimeError('JD2006',
|
|
147
|
+
`'${entityName}' is not tracked under that key — read it first, `
|
|
148
|
+
+ 'add() it, or use the explicit update()',
|
|
149
|
+
{ docPath: entities.get(entityName)?.docPath, collection: entityName });
|
|
150
|
+
}
|
|
151
|
+
core.validateOnly(next);
|
|
152
|
+
record.current = deepFreeze(next);
|
|
153
|
+
return record.current;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const remove = (entityName, keyOrDoc) => {
|
|
157
|
+
const core = coreFor(entityName);
|
|
158
|
+
const parts = core.normalizeKey(keyOrDoc);
|
|
159
|
+
const key = keyOf(entityName, parts);
|
|
160
|
+
const record = records.get(key);
|
|
161
|
+
if (record !== undefined && record.pendingInsert === true) {
|
|
162
|
+
records.delete(key); // added then removed: a no-op
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
removals.set(key, {
|
|
166
|
+
entity: entityName,
|
|
167
|
+
parts,
|
|
168
|
+
snapshot: record?.snapshot ?? null,
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const counts = () => {
|
|
173
|
+
let pendingInserts = 0;
|
|
174
|
+
for (const record of records.values()) {
|
|
175
|
+
if (record.pendingInsert === true) pendingInserts++;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
tracked: records.size - pendingInserts,
|
|
179
|
+
pendingInserts,
|
|
180
|
+
pendingDeletes: removals.size,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ————— planning —————
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Order entity names so referenced entities come first. Only edges
|
|
188
|
+
* between the given names constrain; a cycle (self-loops included)
|
|
189
|
+
* is `JD0040`.
|
|
190
|
+
*/
|
|
191
|
+
const orderEntities = (names, verb) => {
|
|
192
|
+
const present = new Set(names);
|
|
193
|
+
const waiting = new Map(names.map((name) => [name,
|
|
194
|
+
new Set(mapping.entities[name].foreignKeys
|
|
195
|
+
.map((fk) => fk.references)
|
|
196
|
+
.filter((reference) => present.has(reference))),
|
|
197
|
+
]));
|
|
198
|
+
/** @type {string[]} */
|
|
199
|
+
const out = [];
|
|
200
|
+
const placed = new Set();
|
|
201
|
+
while (out.length < names.length) {
|
|
202
|
+
const ready = [...waiting.entries()]
|
|
203
|
+
.filter(([name, deps]) => !placed.has(name)
|
|
204
|
+
&& [...deps].every((dep) => placed.has(dep) && dep !== name))
|
|
205
|
+
.map(([name]) => name)
|
|
206
|
+
.sort();
|
|
207
|
+
if (ready.length === 0) {
|
|
208
|
+
const cycle = names.filter((name) => !placed.has(name)).sort();
|
|
209
|
+
throw new DbCompileError('JD0040',
|
|
210
|
+
`cannot order the ${verb} — these entities form a foreign-key `
|
|
211
|
+
+ `cycle: ${cycle.join(' → ')} (break the save in two)`,
|
|
212
|
+
entities.get(cycle[0])?.docPath);
|
|
213
|
+
}
|
|
214
|
+
for (const name of ready) {
|
|
215
|
+
placed.add(name);
|
|
216
|
+
out.push(name);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Partition one update's diff into the statement ingredients. */
|
|
223
|
+
const partitionDiff = (entityName, record) => {
|
|
224
|
+
const core = coreFor(entityName);
|
|
225
|
+
const plan = core.plan;
|
|
226
|
+
const entity = entities.get(entityName);
|
|
227
|
+
const ops = createJSONPatch(record.snapshot, record.stamped);
|
|
228
|
+
const columnSets = new Map();
|
|
229
|
+
const docOps = [];
|
|
230
|
+
const m2mMembers = new Set();
|
|
231
|
+
let fallback = false;
|
|
232
|
+
for (const op of ops) {
|
|
233
|
+
if (op.from !== undefined) {
|
|
234
|
+
fallback = true;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const names = parseJSONPointer(op.path);
|
|
238
|
+
if (names.length === 0) {
|
|
239
|
+
fallback = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const first = names[0];
|
|
243
|
+
const property = entity.properties.get(first);
|
|
244
|
+
if (property?.relation !== undefined) {
|
|
245
|
+
if (property.relation.kind === 'manyToMany') {
|
|
246
|
+
m2mMembers.add(first);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
throw contractError(entityName,
|
|
250
|
+
`'${first}' is a relation member — a materialised projection, `
|
|
251
|
+
+ 'not stored state; change the related entities themselves');
|
|
252
|
+
}
|
|
253
|
+
if (first === plan.version) continue; // engine-owned (§11.5)
|
|
254
|
+
if (plan.columnSet.has(first)) {
|
|
255
|
+
const isEpoch = plan.scalarColumns.some(
|
|
256
|
+
(column) => column.name === first && column.epoch);
|
|
257
|
+
if (names.length !== 1) {
|
|
258
|
+
fallback = true;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const value = op.op === 'remove' ? undefined : op.value;
|
|
262
|
+
columnSets.set(first, plan.encodeColumn(first, value));
|
|
263
|
+
// the epoch string ALSO lives in the document
|
|
264
|
+
if (isEpoch) docOps.push(op);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
docOps.push(op);
|
|
268
|
+
}
|
|
269
|
+
let docBuild = null;
|
|
270
|
+
if (!fallback && docOps.length > 0) {
|
|
271
|
+
const translated = translatePatch(docOps, record.snapshot, dialect);
|
|
272
|
+
if (translated === null) fallback = true;
|
|
273
|
+
else docBuild = translated;
|
|
274
|
+
}
|
|
275
|
+
return { columnSets, docBuild, m2mMembers, fallback };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Compute a many-to-many member's join-row difference by key sets. */
|
|
279
|
+
const joinDiff = (entityName, before, after, ownKey, member) => {
|
|
280
|
+
const entity = entities.get(entityName);
|
|
281
|
+
const relation = entity.properties.get(member).relation;
|
|
282
|
+
const targetKey = mapping.entities[relation.to].keys[0];
|
|
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
|
+
};
|
|
301
|
+
// a snapshot that never LOADED the member knows nothing about the
|
|
302
|
+
// current membership — treating unknown as empty would re-insert
|
|
303
|
+
// existing rows (a UNIQUE violation the seeded corpus found); the
|
|
304
|
+
// save resolves unknowns by reading the join table first
|
|
305
|
+
const memberValue = before === null ? [] : before[member];
|
|
306
|
+
const beforeKeys = before !== null && memberValue === undefined
|
|
307
|
+
? null
|
|
308
|
+
: [...new Set(extract(memberValue))];
|
|
309
|
+
return {
|
|
310
|
+
joinTable: relation.joinTable,
|
|
311
|
+
ownColumn: `${entityName}_key`,
|
|
312
|
+
targetColumn: `${relation.to}_key`,
|
|
313
|
+
ownKey,
|
|
314
|
+
beforeKeys,
|
|
315
|
+
afterKeys: [...new Set(extract(after[member]))],
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** Relation members riding a pending INSERT: many-to-many becomes
|
|
320
|
+
* join rows; anything else refuses — projections are not state. */
|
|
321
|
+
const insertRelationOps = (record) => {
|
|
322
|
+
const entity = entities.get(record.entity);
|
|
323
|
+
const plan = coreFor(record.entity).plan;
|
|
324
|
+
/** @type {any[]} */
|
|
325
|
+
const ops = [];
|
|
326
|
+
for (const property of entity.properties.values()) {
|
|
327
|
+
if (property.relation === undefined) continue;
|
|
328
|
+
const value = record.current[property.name];
|
|
329
|
+
if (value === undefined || (Array.isArray(value) && value.length === 0))
|
|
330
|
+
continue;
|
|
331
|
+
if (property.relation.kind !== 'manyToMany') {
|
|
332
|
+
throw contractError(record.entity,
|
|
333
|
+
`'${property.name}' is a relation member — a materialised `
|
|
334
|
+
+ 'projection, not stored state; add the related entities themselves');
|
|
335
|
+
}
|
|
336
|
+
const ownKey = record.current[plan.keys[0]];
|
|
337
|
+
if (typeof ownKey !== 'string' && typeof ownKey !== 'number') {
|
|
338
|
+
throw contractError(record.entity,
|
|
339
|
+
`'${property.name}' membership needs the entity's own key at `
|
|
340
|
+
+ 'add() time — save the entity first, then attach');
|
|
341
|
+
}
|
|
342
|
+
ops.push(joinDiff(record.entity, null, record.current, ownKey, property.name));
|
|
343
|
+
}
|
|
344
|
+
return ops;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** Build the ordered statement list for the current tracked state. */
|
|
348
|
+
const planSave = () => {
|
|
349
|
+
/** @type {Map<string, any[]>} */
|
|
350
|
+
const inserts = new Map();
|
|
351
|
+
/** @type {any[]} */
|
|
352
|
+
const updates = [];
|
|
353
|
+
/** @type {any[]} */
|
|
354
|
+
const joinOps = [];
|
|
355
|
+
let fallbacks = 0;
|
|
356
|
+
const unversioned = new Set();
|
|
357
|
+
|
|
358
|
+
for (const record of records.values()) {
|
|
359
|
+
const core = coreFor(record.entity);
|
|
360
|
+
if (record.pendingInsert === true) {
|
|
361
|
+
let list = inserts.get(record.entity);
|
|
362
|
+
if (list === undefined) {
|
|
363
|
+
list = [];
|
|
364
|
+
inserts.set(record.entity, list);
|
|
365
|
+
}
|
|
366
|
+
list.push(record);
|
|
367
|
+
joinOps.push(...insertRelationOps(record));
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (record.current === record.snapshot) continue;
|
|
371
|
+
// probe before stamping: an update stamp must never turn a
|
|
372
|
+
// deep-equal replacement into a phantom write
|
|
373
|
+
if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
|
|
374
|
+
record.stamped = core.stampUpdated(record.current);
|
|
375
|
+
const parts = partitionDiff(record.entity, record);
|
|
376
|
+
for (const member of parts.m2mMembers) {
|
|
377
|
+
joinOps.push(joinDiff(record.entity, record.snapshot, record.stamped,
|
|
378
|
+
record.stamped[core.plan.keys[0]], member));
|
|
379
|
+
}
|
|
380
|
+
if (parts.columnSets.size === 0 && parts.docBuild === null
|
|
381
|
+
&& !parts.fallback) {
|
|
382
|
+
// nothing but join-table changes (or a no-op put)
|
|
383
|
+
if (parts.m2mMembers.size > 0) record.joinOnly = true;
|
|
384
|
+
else record.stamped = undefined;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (parts.fallback) fallbacks++;
|
|
388
|
+
if (core.plan.version === null) unversioned.add(record.entity);
|
|
389
|
+
updates.push({ record, parts });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** @type {any[]} */
|
|
393
|
+
const deletes = [];
|
|
394
|
+
for (const removal of removals.values()) {
|
|
395
|
+
deletes.push(removal);
|
|
396
|
+
if (coreFor(removal.entity).plan.version === null)
|
|
397
|
+
unversioned.add(removal.entity);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// resolve unknown membership baselines, then finalize each op
|
|
401
|
+
const resolveJoins = (i) => {
|
|
402
|
+
if (i >= joinOps.length) return null;
|
|
403
|
+
const op = joinOps[i];
|
|
404
|
+
if (op.beforeKeys !== null) return resolveJoins(i + 1);
|
|
405
|
+
const sql = `SELECT ${q(op.targetColumn)} AS ${q('t')} FROM ${q(op.joinTable)} `
|
|
406
|
+
+ `WHERE ${q(op.ownColumn)} = ${parameterAt(1)}`;
|
|
407
|
+
return chain(connection.prepare(sql), (statement) =>
|
|
408
|
+
chain(statement.all([op.ownKey]), (rows) => {
|
|
409
|
+
op.beforeKeys = rows.map((row) => row.t);
|
|
410
|
+
return resolveJoins(i + 1);
|
|
411
|
+
}));
|
|
412
|
+
};
|
|
413
|
+
const finalizeJoins = () => {
|
|
414
|
+
for (const op of joinOps) {
|
|
415
|
+
const before = new Set(op.beforeKeys);
|
|
416
|
+
const after = new Set(op.afterKeys);
|
|
417
|
+
op.added = op.afterKeys.filter((key) => !before.has(key));
|
|
418
|
+
op.removed = op.beforeKeys.filter((key) => !after.has(key));
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const assemble = () => {
|
|
423
|
+
finalizeJoins();
|
|
424
|
+
const insertOrder = orderEntities([...inserts.keys()], 'inserts');
|
|
425
|
+
const deleteOrder = orderEntities(
|
|
426
|
+
[...new Set(deletes.map((removal) => removal.entity))], 'deletes').reverse();
|
|
427
|
+
|
|
428
|
+
/** @type {any[]} */
|
|
429
|
+
const statements = [];
|
|
430
|
+
|
|
431
|
+
// 1. inserts, parent-first, batched per column-name signature
|
|
432
|
+
for (const entityName of insertOrder) {
|
|
433
|
+
const plan = coreFor(entityName).plan;
|
|
434
|
+
/** @type {Map<string, any[]>} */
|
|
435
|
+
const shapes = new Map();
|
|
436
|
+
for (const record of inserts.get(entityName)) {
|
|
437
|
+
const split = plan.split(record.current);
|
|
438
|
+
const signature = split.values.map((value) => value.name).join(',');
|
|
439
|
+
let group = shapes.get(signature);
|
|
440
|
+
if (group === undefined) {
|
|
441
|
+
shapes.set(signature, group = []);
|
|
442
|
+
}
|
|
443
|
+
group.push({ record, split });
|
|
444
|
+
}
|
|
445
|
+
for (const group of shapes.values()) {
|
|
446
|
+
const names = group[0].split.values.map((value) => value.name);
|
|
447
|
+
const paramsPerRow = names.length + 1;
|
|
448
|
+
const rowsPerBatch = Math.max(1, Math.min(BATCH_ROW_BOUND,
|
|
449
|
+
Math.floor(BATCH_PARAM_BUDGET / paramsPerRow)));
|
|
450
|
+
for (let at = 0; at < group.length; at += rowsPerBatch) {
|
|
451
|
+
const batch = group.slice(at, at + rowsPerBatch);
|
|
452
|
+
const returning = plan.autoKey !== null && !names.includes(plan.autoKey);
|
|
453
|
+
const rowSql = (base) => `(${[...names.map((_, i) => parameterAt(base + i + 1)),
|
|
454
|
+
dialect.jsonEncode(parameterAt(base + names.length + 1))].join(', ')})`;
|
|
455
|
+
const sql = `INSERT INTO ${q(plan.table)} `
|
|
456
|
+
+ `(${[...names.map(q), q('doc')].join(', ')}) VALUES `
|
|
457
|
+
+ batch.map((_, i) => rowSql(i * paramsPerRow)).join(', ')
|
|
458
|
+
+ (returning ? ` RETURNING ${q(plan.autoKey)} AS ${q('key')}` : '');
|
|
459
|
+
const params = batch.flatMap(({ split }) => [
|
|
460
|
+
...split.values.map((value) => value.value),
|
|
461
|
+
JSON.stringify(split.rest),
|
|
462
|
+
]);
|
|
463
|
+
statements.push({
|
|
464
|
+
kind: 'insert', entity: entityName, sql, params, returning,
|
|
465
|
+
records: batch.map(({ record }) => record),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 2. updates (inserts already exist; deletes still ahead)
|
|
472
|
+
for (const { record, parts } of updates) {
|
|
473
|
+
const plan = coreFor(record.entity).plan;
|
|
474
|
+
const params = [];
|
|
475
|
+
const assignments = [];
|
|
476
|
+
if (parts.fallback) {
|
|
477
|
+
const split = plan.split(record.stamped);
|
|
478
|
+
for (const value of split.values) {
|
|
479
|
+
if (value.name === plan.version) continue;
|
|
480
|
+
assignments.push(`${q(value.name)} = ${parameterAt(params.length + 1)}`);
|
|
481
|
+
params.push(value.value);
|
|
482
|
+
}
|
|
483
|
+
assignments.push(`${q('doc')} = ${dialect.jsonEncode(parameterAt(params.length + 1))}`);
|
|
484
|
+
params.push(JSON.stringify(split.rest));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
for (const [name, value] of parts.columnSets) {
|
|
488
|
+
assignments.push(`${q(name)} = ${parameterAt(params.length + 1)}`);
|
|
489
|
+
params.push(value);
|
|
490
|
+
}
|
|
491
|
+
if (parts.docBuild !== null) {
|
|
492
|
+
const built = parts.docBuild.build(q('doc'), params.length);
|
|
493
|
+
assignments.push(`${q('doc')} = ${built.expression}`);
|
|
494
|
+
params.push(...built.params);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const snapshotVersion = plan.version === null
|
|
498
|
+
? null : Number(record.snapshot[plan.version]) || 0;
|
|
499
|
+
if (plan.version !== null) {
|
|
500
|
+
assignments.push(`${q(plan.version)} = ${parameterAt(params.length + 1)}`);
|
|
501
|
+
params.push(snapshotVersion + 1);
|
|
502
|
+
}
|
|
503
|
+
const wheres = plan.keys.map((key) => {
|
|
504
|
+
params.push(record.snapshot[key]);
|
|
505
|
+
return `${q(key)} = ${parameterAt(params.length)}`;
|
|
506
|
+
});
|
|
507
|
+
if (plan.version !== null) {
|
|
508
|
+
params.push(snapshotVersion);
|
|
509
|
+
wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
|
|
510
|
+
}
|
|
511
|
+
statements.push({
|
|
512
|
+
kind: 'update', entity: record.entity, record,
|
|
513
|
+
sql: `UPDATE ${q(plan.table)} SET ${assignments.join(', ')} `
|
|
514
|
+
+ `WHERE ${wheres.join(' AND ')}`,
|
|
515
|
+
params,
|
|
516
|
+
guarded: plan.version !== null,
|
|
517
|
+
newVersion: plan.version === null ? null : snapshotVersion + 1,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 3. join-table rows: after both endpoints exist, before deletes
|
|
522
|
+
for (const op of joinOps) {
|
|
523
|
+
if (op.added.length > 0) {
|
|
524
|
+
const sql = `INSERT INTO ${q(op.joinTable)} `
|
|
525
|
+
+ `(${q(op.ownColumn)}, ${q(op.targetColumn)}) VALUES `
|
|
526
|
+
+ op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
|
|
527
|
+
statements.push({
|
|
528
|
+
kind: 'join-insert', entity: op.joinTable, sql,
|
|
529
|
+
params: op.added.flatMap((key) => [op.ownKey, key]),
|
|
530
|
+
joinRows: op.added.map((key) => ({
|
|
531
|
+
own: op.ownKey, target: key,
|
|
532
|
+
ownColumn: op.ownColumn, targetColumn: op.targetColumn,
|
|
533
|
+
})),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
for (const key of op.removed) {
|
|
537
|
+
statements.push({
|
|
538
|
+
kind: 'join-delete', entity: op.joinTable,
|
|
539
|
+
sql: `DELETE FROM ${q(op.joinTable)} WHERE ${q(op.ownColumn)} = ${parameterAt(1)} `
|
|
540
|
+
+ `AND ${q(op.targetColumn)} = ${parameterAt(2)}`,
|
|
541
|
+
params: [op.ownKey, key],
|
|
542
|
+
joinRows: [{ own: op.ownKey, target: key,
|
|
543
|
+
ownColumn: op.ownColumn, targetColumn: op.targetColumn }],
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 4. deletes, child-first
|
|
549
|
+
for (const entityName of deleteOrder) {
|
|
550
|
+
const plan = coreFor(entityName).plan;
|
|
551
|
+
for (const removal of deletes) {
|
|
552
|
+
if (removal.entity !== entityName) continue;
|
|
553
|
+
const params = [...removal.parts];
|
|
554
|
+
const wheres = plan.keys.map((key, i) => `${q(key)} = ${parameterAt(i + 1)}`);
|
|
555
|
+
const snapshotVersion = plan.version !== null && removal.snapshot !== null
|
|
556
|
+
? Number(removal.snapshot[plan.version]) || 0 : null;
|
|
557
|
+
if (snapshotVersion !== null) {
|
|
558
|
+
params.push(snapshotVersion);
|
|
559
|
+
wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
|
|
560
|
+
}
|
|
561
|
+
statements.push({
|
|
562
|
+
kind: 'delete', entity: entityName, removal,
|
|
563
|
+
sql: `DELETE FROM ${q(plan.table)} WHERE ${wheres.join(' AND ')}`,
|
|
564
|
+
params,
|
|
565
|
+
guarded: snapshotVersion !== null,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { statements, fallbacks, unversioned: [...unversioned].sort() };
|
|
571
|
+
};
|
|
572
|
+
return chain(resolveJoins(0), assemble);
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// ————— execution —————
|
|
576
|
+
|
|
577
|
+
const conflict = (statement) => {
|
|
578
|
+
const keyParts = statement.kind === 'delete'
|
|
579
|
+
? statement.removal.parts
|
|
580
|
+
: coreFor(statement.entity).plan.keys
|
|
581
|
+
.map((key) => statement.record.snapshot[key]);
|
|
582
|
+
const key = keyParts.length === 1 ? keyParts[0] : keyParts;
|
|
583
|
+
return new DbRuntimeError('JD2040',
|
|
584
|
+
statement.guarded
|
|
585
|
+
? `'${statement.entity}' ${JSON.stringify(key)} changed under the save `
|
|
586
|
+
+ '(version mismatch) — re-read and retry'
|
|
587
|
+
: `'${statement.entity}' ${JSON.stringify(key)} no longer exists`,
|
|
588
|
+
{
|
|
589
|
+
docPath: entities.get(statement.entity)?.docPath,
|
|
590
|
+
collection: statement.entity,
|
|
591
|
+
key,
|
|
592
|
+
});
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const wrapDb = (error, statement) => {
|
|
596
|
+
if (typeof (/** @type {any} */ (error))?.code === 'string'
|
|
597
|
+
&& String((/** @type {any} */ (error)).code).startsWith('JD')) return error;
|
|
598
|
+
return new DbRuntimeError('JD2005',
|
|
599
|
+
`the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
|
|
600
|
+
{
|
|
601
|
+
docPath: entities.get(statement.entity)?.docPath,
|
|
602
|
+
collection: statement.entity,
|
|
603
|
+
cause: error,
|
|
604
|
+
});
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const runStatements = (statements, report) => {
|
|
608
|
+
const next = (i) => {
|
|
609
|
+
if (i >= statements.length) return report;
|
|
610
|
+
const statement = statements[i];
|
|
611
|
+
return chain(connection.prepare(statement.sql), (prepared) => {
|
|
612
|
+
if (statement.kind === 'insert' && statement.returning === true) {
|
|
613
|
+
let fetched;
|
|
614
|
+
try {
|
|
615
|
+
fetched = prepared.all(statement.params);
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
throw wrapDb(error, statement);
|
|
619
|
+
}
|
|
620
|
+
return chain(fetched, (rows) => {
|
|
621
|
+
// auto keys allocate monotonically in insertion order —
|
|
622
|
+
// sort ascending to pair rows with records (asserted by
|
|
623
|
+
// test, not assumed silently)
|
|
624
|
+
const keys = rows.map((row) => row.key).sort((a, b) => a - b);
|
|
625
|
+
statement.generatedKeys = keys;
|
|
626
|
+
report.inserted += statement.records.length;
|
|
627
|
+
report.statements.push({ sql: statement.sql, rows: statement.records.length });
|
|
628
|
+
return next(i + 1);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return chain(
|
|
632
|
+
statement.kind === 'delete' && captureJoinDelete !== null
|
|
633
|
+
? captureJoinDelete(statement.entity, statement.removal.parts)
|
|
634
|
+
: null,
|
|
635
|
+
() => {
|
|
636
|
+
let ran;
|
|
637
|
+
try {
|
|
638
|
+
ran = prepared.run(statement.params);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
throw wrapDb(error, statement);
|
|
642
|
+
}
|
|
643
|
+
return chain(ran, (outcome) => {
|
|
644
|
+
const changed = Number(outcome?.changes ?? 0);
|
|
645
|
+
report.statements.push({ sql: statement.sql, rows: changed });
|
|
646
|
+
if (statement.kind === 'insert') report.inserted += statement.records.length;
|
|
647
|
+
else if (statement.kind === 'update') {
|
|
648
|
+
if (changed === 0) throw conflict(statement);
|
|
649
|
+
report.updated += 1;
|
|
650
|
+
}
|
|
651
|
+
else if (statement.kind === 'delete') {
|
|
652
|
+
if (changed === 0 && statement.guarded) throw conflict(statement);
|
|
653
|
+
statement.deletedRows = changed;
|
|
654
|
+
report.deleted += changed;
|
|
655
|
+
}
|
|
656
|
+
else if (statement.kind === 'join-insert') report.joinInserted += changed;
|
|
657
|
+
else if (statement.kind === 'join-delete') report.joinDeleted += changed;
|
|
658
|
+
return next(i + 1);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
};
|
|
663
|
+
return next(0);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
/** Commit phase: only reached after the transaction succeeded. */
|
|
667
|
+
const commit = (statements) => {
|
|
668
|
+
for (const statement of statements) {
|
|
669
|
+
if (statement.kind === 'insert') {
|
|
670
|
+
statement.records.forEach((record, i) => {
|
|
671
|
+
const plan = coreFor(statement.entity).plan;
|
|
672
|
+
let doc = record.current;
|
|
673
|
+
if (statement.returning === true) {
|
|
674
|
+
doc = deepFreeze({ ...doc, [plan.autoKey]: statement.generatedKeys[i] });
|
|
675
|
+
}
|
|
676
|
+
// re-key under the real identity
|
|
677
|
+
records.delete(record.pendingKey);
|
|
678
|
+
const key = recordKeyFor(statement.entity, doc);
|
|
679
|
+
records.set(/** @type {string} */ (key), {
|
|
680
|
+
entity: statement.entity, snapshot: doc, current: doc, pendingInsert: false,
|
|
681
|
+
});
|
|
682
|
+
record.saved = doc;
|
|
683
|
+
captureRecord?.(statement.entity,
|
|
684
|
+
plan.keys.map((k) => doc[k]), null, doc);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
else if (statement.kind === 'update') {
|
|
688
|
+
const record = statement.record;
|
|
689
|
+
const plan = coreFor(statement.entity).plan;
|
|
690
|
+
const before = record.snapshot;
|
|
691
|
+
const saved = statement.newVersion === null
|
|
692
|
+
? record.stamped
|
|
693
|
+
: deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
|
|
694
|
+
record.snapshot = deepFreeze(saved);
|
|
695
|
+
record.current = record.snapshot;
|
|
696
|
+
record.stamped = undefined;
|
|
697
|
+
captureRecord?.(statement.entity,
|
|
698
|
+
plan.keys.map((k) => record.snapshot[k]), before, record.snapshot);
|
|
699
|
+
}
|
|
700
|
+
else if (statement.kind === 'delete') {
|
|
701
|
+
const removal = statement.removal;
|
|
702
|
+
records.delete(keyOf(removal.entity, removal.parts));
|
|
703
|
+
removals.delete(keyOf(removal.entity, removal.parts));
|
|
704
|
+
if ((statement.deletedRows ?? 0) > 0) {
|
|
705
|
+
captureRecord?.(removal.entity, removal.parts,
|
|
706
|
+
removal.snapshot ?? undefined, null);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else if (statement.kind === 'join-insert' || statement.kind === 'join-delete') {
|
|
710
|
+
for (const row of statement.joinRows ?? []) {
|
|
711
|
+
// the join-row "document" lists its columns in table order
|
|
712
|
+
// (the sorted pair) so both capture modes agree exactly
|
|
713
|
+
const pair = statement.entity.split('_');
|
|
714
|
+
const value = { [row.ownColumn]: row.own, [row.targetColumn]: row.target };
|
|
715
|
+
const ordered = {};
|
|
716
|
+
for (const part of pair) ordered[part + '_key'] = value[part + '_key'];
|
|
717
|
+
const keyParts = pair.map((part) => ordered[part + '_key']);
|
|
718
|
+
if (statement.kind === 'join-insert') {
|
|
719
|
+
captureRecord?.(statement.entity, keyParts, null, ordered);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
captureRecord?.(statement.entity, keyParts, undefined, null);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// join-only records: their member state is now persisted
|
|
728
|
+
for (const record of records.values()) {
|
|
729
|
+
if (record.joinOnly === true) {
|
|
730
|
+
record.snapshot = record.stamped ?? record.current;
|
|
731
|
+
record.current = record.snapshot;
|
|
732
|
+
record.joinOnly = undefined;
|
|
733
|
+
record.stamped = undefined;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
removals.clear();
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const saveChanges = () => {
|
|
740
|
+
const startedAt = performance.now();
|
|
741
|
+
return chain(planSave(), ({ statements, fallbacks, unversioned }) => {
|
|
742
|
+
const report = {
|
|
743
|
+
inserted: 0, updated: 0, deleted: 0,
|
|
744
|
+
joinInserted: 0, joinDeleted: 0,
|
|
745
|
+
fallbacks,
|
|
746
|
+
statements: /** @type {{ sql: string, rows: number }[]} */ ([]),
|
|
747
|
+
concurrency: {
|
|
748
|
+
checked: statements.filter((statement) => statement.guarded === true).length,
|
|
749
|
+
unversioned,
|
|
750
|
+
},
|
|
751
|
+
elapsedMs: 0,
|
|
752
|
+
};
|
|
753
|
+
if (statements.length === 0) {
|
|
754
|
+
report.elapsedMs = performance.now() - startedAt;
|
|
755
|
+
return report;
|
|
756
|
+
}
|
|
757
|
+
return chain(
|
|
758
|
+
connection.transaction(() => runStatements(statements, report)),
|
|
759
|
+
(finished) => {
|
|
760
|
+
commit(statements);
|
|
761
|
+
finished.elapsedMs = performance.now() - startedAt;
|
|
762
|
+
return finished;
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
/** Drop tracking for a key without scheduling anything. */
|
|
768
|
+
const discard = (entityName, keyOrDoc) => {
|
|
769
|
+
const parts = coreFor(entityName).normalizeKey(keyOrDoc);
|
|
770
|
+
records.delete(keyOf(entityName, parts));
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
register, registerGraph, add, put, remove, discard, counts, saveChanges,
|
|
775
|
+
};
|
|
776
|
+
}
|