@jarenjs/db 0.56.0 → 0.67.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 +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- 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 +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- 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 +17 -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.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/src/tracker.js
CHANGED
|
@@ -17,9 +17,15 @@
|
|
|
17
17
|
* parent-first, deletes child-first, updates in between, join rows
|
|
18
18
|
* after both endpoints exist. A foreign-key cycle among the entities
|
|
19
19
|
* being inserted or deleted is `JD0040`, reported, never a deadlock.
|
|
20
|
-
* The whole save is one transaction
|
|
21
|
-
* after
|
|
22
|
-
* 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.
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
import { createJSONPatch } from '@jarenjs/json/patch';
|
|
@@ -103,8 +109,9 @@ export function createTracker(context) {
|
|
|
103
109
|
const memberships = new Map();
|
|
104
110
|
let pendingSequence = 0;
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
|
|
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]);
|
|
108
115
|
|
|
109
116
|
const recordKeyFor = (entityName, doc) => {
|
|
110
117
|
const plan = coreFor(entityName).plan;
|
|
@@ -206,13 +213,34 @@ export function createTracker(context) {
|
|
|
206
213
|
});
|
|
207
214
|
};
|
|
208
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
|
+
|
|
209
234
|
/**
|
|
210
235
|
* The pending membership delta a `link`/`unlink` addresses (§11.7):
|
|
211
236
|
* the member must be a many-to-many relation of the entity; the own
|
|
212
|
-
* key is read from a key or a document
|
|
213
|
-
* the save allocates has none to attach to); the target is a key or a
|
|
237
|
+
* key is read from a key or a document; the target is a key or a
|
|
214
238
|
* document carrying the target's key — the reading a membership array
|
|
215
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.
|
|
216
244
|
*/
|
|
217
245
|
const membershipOf = (entityName, own, member, target, verb) => {
|
|
218
246
|
const plan = coreFor(entityName).plan;
|
|
@@ -225,10 +253,14 @@ export function createTracker(context) {
|
|
|
225
253
|
+ "memberships only; write the related entity's foreign key instead");
|
|
226
254
|
}
|
|
227
255
|
let ownKey;
|
|
256
|
+
let ownRecord;
|
|
228
257
|
if (own !== null && typeof own === 'object' && !Array.isArray(own)) {
|
|
229
258
|
ownKey = own[plan.keys[0]];
|
|
230
|
-
if (typeof ownKey !== 'string' && typeof ownKey !== 'number')
|
|
231
|
-
|
|
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
|
+
}
|
|
232
264
|
}
|
|
233
265
|
else {
|
|
234
266
|
ownKey = coreFor(entityName).normalizeKey(own)[0];
|
|
@@ -236,10 +268,11 @@ export function createTracker(context) {
|
|
|
236
268
|
const targetKey = mapping.entities[relation.to].keys[0];
|
|
237
269
|
const [key] = membershipKeys([target], targetKey, member,
|
|
238
270
|
(reason) => contractError(entityName, reason));
|
|
239
|
-
const id = `${
|
|
271
|
+
const id = `${ownToken(entityName, ownKey, ownRecord)}${UNIT_SEPARATOR}${member}`;
|
|
240
272
|
let pending = memberships.get(id);
|
|
241
273
|
if (pending === undefined) {
|
|
242
|
-
pending = { entity: entityName, member, ownKey,
|
|
274
|
+
pending = { entity: entityName, member, ownKey, ownRecord,
|
|
275
|
+
links: new Set(), unlinks: new Set() };
|
|
243
276
|
memberships.set(id, pending);
|
|
244
277
|
}
|
|
245
278
|
return { pending, key };
|
|
@@ -414,7 +447,10 @@ export function createTracker(context) {
|
|
|
414
447
|
const membershipDelta = (pending) => ({
|
|
415
448
|
...joinEndpoints(pending.entity, pending.member),
|
|
416
449
|
ownKey: pending.ownKey,
|
|
417
|
-
|
|
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 : [],
|
|
418
454
|
links: [...pending.links],
|
|
419
455
|
unlinks: [...pending.unlinks],
|
|
420
456
|
});
|
|
@@ -437,9 +473,16 @@ export function createTracker(context) {
|
|
|
437
473
|
+ 'projection, not stored state; add the related entities themselves');
|
|
438
474
|
}
|
|
439
475
|
const ownKey = record.current[plan.keys[0]];
|
|
440
|
-
|
|
476
|
+
const known = typeof ownKey === 'string' || typeof ownKey === 'number';
|
|
477
|
+
if (!known && plan.autoKey === null)
|
|
441
478
|
throw needsOwnKey(record.entity, property.name, 'add()');
|
|
442
|
-
|
|
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
|
+
});
|
|
443
486
|
}
|
|
444
487
|
return ops;
|
|
445
488
|
};
|
|
@@ -452,6 +495,10 @@ export function createTracker(context) {
|
|
|
452
495
|
const updates = [];
|
|
453
496
|
/** @type {any[]} */
|
|
454
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 = [];
|
|
455
502
|
let fallbacks = 0;
|
|
456
503
|
const unversioned = new Set();
|
|
457
504
|
|
|
@@ -484,7 +531,10 @@ export function createTracker(context) {
|
|
|
484
531
|
if (parts.columnSets.size === 0 && parts.docBuild === null
|
|
485
532
|
&& !parts.fallback) {
|
|
486
533
|
// nothing but join-table changes (or a no-op put)
|
|
487
|
-
if (parts.m2mMembers.size > 0)
|
|
534
|
+
if (parts.m2mMembers.size > 0) {
|
|
535
|
+
record.joinOnly = true;
|
|
536
|
+
joinOnly.push(record);
|
|
537
|
+
}
|
|
488
538
|
else record.stamped = undefined;
|
|
489
539
|
continue;
|
|
490
540
|
}
|
|
@@ -505,7 +555,7 @@ export function createTracker(context) {
|
|
|
505
555
|
// folds into that op's key set: one intent per entity, own key and
|
|
506
556
|
// member, never two statements racing for one row
|
|
507
557
|
const synced = new Map(joinOps.map((op) =>
|
|
508
|
-
[`${op.entity
|
|
558
|
+
[`${ownToken(op.entity, op.ownKey, op.ownRecord)}${UNIT_SEPARATOR}${op.member}`, op]));
|
|
509
559
|
for (const [id, pending] of memberships) {
|
|
510
560
|
const diff = synced.get(id);
|
|
511
561
|
if (diff === undefined) {
|
|
@@ -652,6 +702,9 @@ export function createTracker(context) {
|
|
|
652
702
|
+ op.added.map((_, i) => `(${parameterAt(i * 2 + 1)}, ${parameterAt(i * 2 + 2)})`).join(', ');
|
|
653
703
|
statements.push({
|
|
654
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,
|
|
655
708
|
params: op.added.flatMap((key) => [op.ownKey, key]),
|
|
656
709
|
joinRows: op.added.map((key) => ({
|
|
657
710
|
own: op.ownKey, target: key,
|
|
@@ -693,7 +746,7 @@ export function createTracker(context) {
|
|
|
693
746
|
}
|
|
694
747
|
}
|
|
695
748
|
|
|
696
|
-
return { statements, fallbacks, unversioned: [...unversioned].sort() };
|
|
749
|
+
return { statements, fallbacks, joinOnly, unversioned: [...unversioned].sort() };
|
|
697
750
|
};
|
|
698
751
|
return chain(resolveJoins(0), assemble);
|
|
699
752
|
};
|
|
@@ -734,6 +787,15 @@ export function createTracker(context) {
|
|
|
734
787
|
const next = (i) => {
|
|
735
788
|
if (i >= statements.length) return report;
|
|
736
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
|
+
}
|
|
737
799
|
return chain(connection.prepare(statement.sql), (prepared) => {
|
|
738
800
|
if (statement.kind === 'insert' && statement.returning === true) {
|
|
739
801
|
let fetched;
|
|
@@ -749,6 +811,9 @@ export function createTracker(context) {
|
|
|
749
811
|
// test, not assumed silently)
|
|
750
812
|
const keys = rows.map((row) => row.key).sort((a, b) => a - b);
|
|
751
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]; });
|
|
752
817
|
report.inserted += statement.records.length;
|
|
753
818
|
report.statements.push({ sql: statement.sql, rows: statement.records.length });
|
|
754
819
|
return next(i + 1);
|
|
@@ -783,21 +848,116 @@ export function createTracker(context) {
|
|
|
783
848
|
return next(0);
|
|
784
849
|
};
|
|
785
850
|
|
|
786
|
-
/**
|
|
787
|
-
|
|
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;
|
|
788
945
|
for (const statement of statements) {
|
|
789
946
|
if (statement.kind === 'insert') {
|
|
790
947
|
statement.records.forEach((record, i) => {
|
|
791
948
|
const plan = coreFor(statement.entity).plan;
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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;
|
|
796
955
|
// re-key under the real identity
|
|
797
956
|
records.delete(record.pendingKey);
|
|
798
957
|
const key = recordKeyFor(statement.entity, doc);
|
|
799
958
|
records.set(/** @type {string} */ (key), {
|
|
800
|
-
entity: statement.entity, snapshot: doc,
|
|
959
|
+
entity: statement.entity, snapshot: doc,
|
|
960
|
+
current: untouched(record) ? doc : record.current, pendingInsert: false,
|
|
801
961
|
});
|
|
802
962
|
record.saved = doc;
|
|
803
963
|
captureRecord?.(statement.entity,
|
|
@@ -811,8 +971,9 @@ export function createTracker(context) {
|
|
|
811
971
|
const saved = statement.newVersion === null
|
|
812
972
|
? record.stamped
|
|
813
973
|
: deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
|
|
974
|
+
const pending = untouched(record) ? null : record.current;
|
|
814
975
|
record.snapshot = deepFreeze(saved);
|
|
815
|
-
record.current = record.snapshot;
|
|
976
|
+
record.current = pending ?? record.snapshot;
|
|
816
977
|
record.stamped = undefined;
|
|
817
978
|
captureRecord?.(statement.entity,
|
|
818
979
|
plan.keys.map((k) => record.snapshot[k]), before, record.snapshot);
|
|
@@ -846,8 +1007,9 @@ export function createTracker(context) {
|
|
|
846
1007
|
// join-only records: their member state is now persisted
|
|
847
1008
|
for (const record of records.values()) {
|
|
848
1009
|
if (record.joinOnly === true) {
|
|
849
|
-
|
|
850
|
-
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;
|
|
851
1013
|
record.joinOnly = undefined;
|
|
852
1014
|
record.stamped = undefined;
|
|
853
1015
|
}
|
|
@@ -858,7 +1020,7 @@ export function createTracker(context) {
|
|
|
858
1020
|
|
|
859
1021
|
const saveChanges = () => {
|
|
860
1022
|
const startedAt = performance.now();
|
|
861
|
-
return chain(planSave(), ({ statements, fallbacks, unversioned }) => {
|
|
1023
|
+
return chain(planSave(), ({ statements, fallbacks, joinOnly, unversioned }) => {
|
|
862
1024
|
const report = {
|
|
863
1025
|
inserted: 0, updated: 0, deleted: 0,
|
|
864
1026
|
joinInserted: 0, joinDeleted: 0,
|
|
@@ -874,10 +1036,22 @@ export function createTracker(context) {
|
|
|
874
1036
|
report.elapsedMs = performance.now() - startedAt;
|
|
875
1037
|
return report;
|
|
876
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);
|
|
877
1043
|
return chain(
|
|
878
1044
|
connection.transaction(() => runStatements(statements, report)),
|
|
879
1045
|
(finished) => {
|
|
880
|
-
|
|
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) });
|
|
881
1055
|
finished.elapsedMs = performance.now() - startedAt;
|
|
882
1056
|
return finished;
|
|
883
1057
|
});
|
package/src/udf.js
CHANGED
|
@@ -16,10 +16,16 @@
|
|
|
16
16
|
* fingerprint clash between DIFFERENT identities is disambiguated with
|
|
17
17
|
* a suffix rather than collapsed.
|
|
18
18
|
*
|
|
19
|
-
* WHERE-clause use only
|
|
20
|
-
*
|
|
21
|
-
* registered
|
|
22
|
-
*
|
|
19
|
+
* WHERE-clause use only, and that is the difference between this hatch
|
|
20
|
+
* and a DECLARED index expression (MODEL-FORMAT §7A). An index over a
|
|
21
|
+
* registered function makes the database unwritable from a connection
|
|
22
|
+
* that has not registered the identical function; a fragment registered
|
|
23
|
+
* here is a QUERY's, discovered from the caller's document at run time,
|
|
24
|
+
* and indexing one would make a passing query a permanent schema
|
|
25
|
+
* dependency nobody declared. A model's `indexes[].expression` carries
|
|
26
|
+
* exactly that dependency in the model, where every store that opens it
|
|
27
|
+
* is handed the same declaration and one that cannot honour it refuses
|
|
28
|
+
* at open.
|
|
23
29
|
*
|
|
24
30
|
* Ring 3 extends the hatch to registry `pushable:'scalar'`
|
|
25
31
|
* operators: a predicate fragment that uses a registered scalar operator
|
|
@@ -34,7 +40,7 @@
|
|
|
34
40
|
|
|
35
41
|
import { semanticKey } from '@jarenjs/core/object';
|
|
36
42
|
import { hashContent } from '@jarenjs/core/string';
|
|
37
|
-
import { compileJsonQuery, analyzeQuery } from '@jarenjs/json/query';
|
|
43
|
+
import { compileJsonQuery, analyzeQuery, JsonQueryRuntimeError } from '@jarenjs/json/query';
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
46
|
* The SQL identifier for one fragment identity: a short fingerprint of
|
|
@@ -61,7 +67,14 @@ const functionNameFor = (identity) => `jaren_p_${hashContent(identity)}`;
|
|
|
61
67
|
* external, the determinism check below rejects the fragment, and the
|
|
62
68
|
* hatch silently never engages.
|
|
63
69
|
* @returns {{ key: string, name: string,
|
|
64
|
-
* compile: () => (docText: string) => number } | null}
|
|
70
|
+
* compile: () => (docText: string, mount?: string) => number } | null}
|
|
71
|
+
* - the compiled function takes the row's document text and the
|
|
72
|
+
* conjunct's JSON Pointer in the CALLER's document (`/$where`, or
|
|
73
|
+
* `/$where/$and/<i>`), which the emitter passes as a literal: an
|
|
74
|
+
* engine error raised inside names the wrapper's path (`/$return/…`)
|
|
75
|
+
* and is rebased onto that mount, so the native mode and the residual
|
|
76
|
+
* report the same location while one registration still serves every
|
|
77
|
+
* document that carries the fragment
|
|
65
78
|
*/
|
|
66
79
|
export function deterministicFragment(fragment, operators = null, binding = 'it') {
|
|
67
80
|
const analyzeOpts = operators === null
|
|
@@ -103,6 +116,7 @@ export function deterministicFragment(fragment, operators = null, binding = 'it'
|
|
|
103
116
|
// correct, so it does not qualify for the hatch
|
|
104
117
|
return null;
|
|
105
118
|
}
|
|
119
|
+
const WRAPPER = '/$return';
|
|
106
120
|
return {
|
|
107
121
|
key,
|
|
108
122
|
name: functionNameFor(key),
|
|
@@ -111,7 +125,25 @@ export function deterministicFragment(fragment, operators = null, binding = 'it'
|
|
|
111
125
|
// different name than the analysis would judge one document and
|
|
112
126
|
// run another
|
|
113
127
|
const compiled = compileJsonQuery(wrap(fragment), analyzeOpts);
|
|
114
|
-
|
|
128
|
+
// two declared parameters on purpose: node:sqlite registers the
|
|
129
|
+
// function with the arity `fn.length` reports, and the emitter
|
|
130
|
+
// always passes the mount beside the document
|
|
131
|
+
return (docText, mount) => {
|
|
132
|
+
try {
|
|
133
|
+
return compiled.ebv(JSON.parse(docText)) ? 1 : 0;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// the engine's own refusal, relocated from the wrapper onto
|
|
137
|
+
// the caller's document; anything else propagates as it is
|
|
138
|
+
if (error instanceof JsonQueryRuntimeError && typeof error.docPath === 'string'
|
|
139
|
+
&& error.docPath.startsWith(WRAPPER)) {
|
|
140
|
+
throw new JsonQueryRuntimeError(error.code, error.reason,
|
|
141
|
+
(typeof mount === 'string' ? mount : '/$where') + error.docPath.slice(WRAPPER.length),
|
|
142
|
+
Object.hasOwn(error, 'cause') ? { cause: error.cause } : undefined);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
115
147
|
},
|
|
116
148
|
};
|
|
117
149
|
}
|
|
@@ -139,3 +171,52 @@ export function registerFragment(connection, registered, fragment) {
|
|
|
139
171
|
registered.set(fragment.key, name);
|
|
140
172
|
return name;
|
|
141
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The SQL identifier for one registered aggregate. Unlike a predicate
|
|
177
|
+
* fragment, the identity IS the operator name — one registry, one
|
|
178
|
+
* function per name — so the fingerprint has nothing to disambiguate.
|
|
179
|
+
* @param {string} name
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
const aggregateNameFor = (name) => `jaren_a_${hashContent(name)}`;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Register a pushable aggregate once per store and answer the SQL name
|
|
186
|
+
* to call. The SQL fold accumulates the column's values and hands them
|
|
187
|
+
* to the SAME pure function the residual would call, so the two sides
|
|
188
|
+
* differ in who drives the loop and in nothing else.
|
|
189
|
+
*
|
|
190
|
+
* A `NULL` column value is SKIPPED, because the engine's sequence has no
|
|
191
|
+
* item where the member is absent — which is why only a path the schema
|
|
192
|
+
* types as a number that cannot hold `null` reaches here: a stored
|
|
193
|
+
* `null` and an absent member are one value in SQL, and dropping a
|
|
194
|
+
* present `null` would answer where the engine does not.
|
|
195
|
+
*
|
|
196
|
+
* `undefined` — what these summaries answer for an input they cannot
|
|
197
|
+
* summarise — becomes SQL `NULL`, which the aggregate decoder reads back
|
|
198
|
+
* as the empty answer, exactly as the engine's empty sequence does.
|
|
199
|
+
* @param {any} connection
|
|
200
|
+
* @param {Map<string, string>} registered - operator name → SQL name
|
|
201
|
+
* @param {string} name - the registry operator name (`$mean`)
|
|
202
|
+
* @param {{ fn: Function }} spec
|
|
203
|
+
* @returns {string} the SQL function name to call
|
|
204
|
+
*/
|
|
205
|
+
export function registerAggregateOperator(connection, registered, name, spec) {
|
|
206
|
+
const owned = registered.get(name);
|
|
207
|
+
if (owned !== undefined) return owned;
|
|
208
|
+
const sqlName = aggregateNameFor(name);
|
|
209
|
+
connection.registerAggregate(sqlName, {
|
|
210
|
+
start: () => [],
|
|
211
|
+
step: (values, value) => {
|
|
212
|
+
if (value !== null && value !== undefined) values.push(value);
|
|
213
|
+
return values;
|
|
214
|
+
},
|
|
215
|
+
result: (values) => {
|
|
216
|
+
const out = spec.fn(values);
|
|
217
|
+
return out === undefined || out === null ? null : out;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
registered.set(name, sqlName);
|
|
221
|
+
return sqlName;
|
|
222
|
+
}
|