@jarenjs/db 0.73.0 → 0.83.2
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 +70 -7
- package/README.md +69 -6
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +52 -13
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +163 -15
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +26 -4
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/derive.js +14 -3
- package/src/dialect.js +12 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +28 -4
- package/src/dialects/sqlite.js +23 -3
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +133 -25
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +81 -12
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live-nested.js +27 -10
- package/src/live.js +51 -136
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +275 -64
- package/src/query.js +175 -78
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +59 -4
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/search.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Optional lexical persistence and bounded execution over authoritative entity snapshots. */
|
|
3
|
+
import { compileLexical } from '@jarenjs/core/search';
|
|
4
|
+
import { createLexicalProvider } from '@jarenjs/json/query';
|
|
5
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
6
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
7
|
+
import { isCursorBudgetError } from './cursor.js';
|
|
8
|
+
|
|
9
|
+
/** Atomic snapshot storage over a host-declared collection of {id, payload} documents.
|
|
10
|
+
* Snapshot rows are derived caches and never answer catalog queries.
|
|
11
|
+
* @param {any} store @param {string} collection @param {{maxBytes?:number}} [options] */
|
|
12
|
+
export function createDbSearchStorage(store, collection, options = {}) {
|
|
13
|
+
const maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
|
|
14
|
+
if (typeof collection !== 'string' || !collection || !Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
15
|
+
throw new TypeError('Invalid lexical snapshot storage');
|
|
16
|
+
return {
|
|
17
|
+
async load(id) { const row = await store.collection(collection).get(id);
|
|
18
|
+
if (!row) return null;
|
|
19
|
+
if (typeof row.payload !== 'string' || row.payload.length > maxBytes || utf8ByteLength(row.payload) > maxBytes)
|
|
20
|
+
throw new RangeError('Snapshot storage byte credits');
|
|
21
|
+
return row.payload;
|
|
22
|
+
},
|
|
23
|
+
async save(id, payload) {
|
|
24
|
+
if (typeof id !== 'string' || !id || typeof payload !== 'string' || payload.length > maxBytes || utf8ByteLength(payload) > maxBytes)
|
|
25
|
+
throw new RangeError('Snapshot storage byte credits');
|
|
26
|
+
return store.transaction(async (tx) => {
|
|
27
|
+
const target = tx.collection(collection), old = await target.get(id);
|
|
28
|
+
if (old?.payload === payload) return { changes: 0 };
|
|
29
|
+
await target.put({ id, payload }); return { changes: 1 };
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read a bounded, complete entity snapshot inside the store transaction; captured
|
|
37
|
+
* commits invalidate it. External SQL invalidates through dataVersion on request.
|
|
38
|
+
* A SHA-256 source-content revision also detects uncaptured edits across reopen.
|
|
39
|
+
* Native FTS is deliberately refused: this adapter executes the shared ranker.
|
|
40
|
+
* @param {any} store @param {string} entity
|
|
41
|
+
* @param {import('@jarenjs/core/search').LexicalDefinition} definition
|
|
42
|
+
* @param {{source:string, maxRows?:number, maxBytes?:number, storage?:{load:(key:string)=>Promise<string|null>,save:(key:string,payload:string)=>Promise<any>}, snapshotKey?:string}} options
|
|
43
|
+
* @returns {Promise<any>}
|
|
44
|
+
*/
|
|
45
|
+
export async function createDbSearch(store, entity, definition, options) {
|
|
46
|
+
const maxRows = options?.maxRows ?? 10000, maxBytes = options?.maxBytes ?? 8 * 1024 * 1024;
|
|
47
|
+
if (typeof options?.source !== 'string' || !options.source || !Number.isSafeInteger(maxRows) || maxRows < 1
|
|
48
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes < 2) throw new TypeError('Invalid lexical source credits');
|
|
49
|
+
if (!store.capabilities?.capture || store.capabilities.capture === 'none') throw new TypeError('Lexical freshness requires committed capture');
|
|
50
|
+
const compiled = compileLexical(definition), index = compiled.create(), storage = options.storage;
|
|
51
|
+
const snapshotKey = options.snapshotKey ?? `${options.source}:${entity}`;
|
|
52
|
+
let rows = new Map(), sourceRevision = '', dataVersion, dirty = true, disposed = false, epoch = 0, busy = null;
|
|
53
|
+
let reads = 0, writes = 0, restores = 0, rebuilds = 0, sourceBytes = 0, recovery = null, pendingSnapshot = null;
|
|
54
|
+
const observers = new Set(), controller = new AbortController();
|
|
55
|
+
const invalidate = (reason) => {
|
|
56
|
+
dirty = true; epoch++;
|
|
57
|
+
for (const observer of observers) { try { observer({ type: 'reset', reason, revision: epoch, sourceRevision }); }
|
|
58
|
+
catch { /* An observer cannot suppress a sibling's invalidation. */ } }
|
|
59
|
+
};
|
|
60
|
+
const unsubscribe = store.observe((record) => { if (record.collections.includes(entity)) invalidate('source-changed'); });
|
|
61
|
+
const refusal = (state, reason) => ({ state, reason, hits: [], total: null, sourceRevision });
|
|
62
|
+
const refresh = async () => {
|
|
63
|
+
if (disposed) return refusal('error', 'disposed');
|
|
64
|
+
if (busy) return busy;
|
|
65
|
+
const run = async () => {
|
|
66
|
+
try {
|
|
67
|
+
const loaded = await store.transaction(async (tx) => {
|
|
68
|
+
const current = await tx.dataVersion();
|
|
69
|
+
if (dataVersion !== undefined && current !== dataVersion) invalidate('external-source-changed');
|
|
70
|
+
dataVersion = current;
|
|
71
|
+
if (!dirty) return null;
|
|
72
|
+
const page = await tx.entity(entity).page({ orderBy: '$it.id' },
|
|
73
|
+
{ limit: maxRows + 1, maxBytes, lookahead: false, signal: controller.signal });
|
|
74
|
+
reads++;
|
|
75
|
+
if (page.hasMore !== false || page.items.length > maxRows) throw new RangeError('Lexical source exceeds row credits');
|
|
76
|
+
const source = canonicalizeJson(page.items);
|
|
77
|
+
if (utf8ByteLength(source) > maxBytes) throw new RangeError('Lexical source exceeds byte credits');
|
|
78
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
|
|
79
|
+
const revision = `${options.source}:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
80
|
+
return { items: page.items, revision, bytes: utf8ByteLength(source), epoch };
|
|
81
|
+
}, { signal: controller.signal });
|
|
82
|
+
if (disposed) return refusal('error', 'disposed');
|
|
83
|
+
if (!loaded) return { state: 'complete', changes: 0, sourceRevision };
|
|
84
|
+
if (loaded.epoch !== epoch) return refusal('invalidated', 'source-changed');
|
|
85
|
+
if (loaded.revision === sourceRevision) {
|
|
86
|
+
if (pendingSnapshot !== null) { writes += (await storage.save(snapshotKey, pendingSnapshot)).changes; pendingSnapshot = null; }
|
|
87
|
+
if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
|
|
88
|
+
dirty = false; return { state: 'complete', changes: 0, sourceRevision };
|
|
89
|
+
}
|
|
90
|
+
const nextRows = new Map(loaded.items.map((row) => [row.id, Object.freeze(row)]));
|
|
91
|
+
if (nextRows.size !== loaded.items.length) throw new TypeError('Duplicate lexical source IDs');
|
|
92
|
+
let result;
|
|
93
|
+
if (!sourceRevision && storage) {
|
|
94
|
+
const saved = await storage.load(snapshotKey);
|
|
95
|
+
if (saved !== null) {
|
|
96
|
+
result = index.restore(saved, { sourceRevision: loaded.revision });
|
|
97
|
+
if (result.state === 'complete') restores++; else recovery = result.reason;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
|
|
101
|
+
if (result?.state !== 'complete') {
|
|
102
|
+
result = index.rebuild(loaded.items, { sourceRevision: loaded.revision }); rebuilds++;
|
|
103
|
+
}
|
|
104
|
+
if (result.state !== 'complete') return { ...result, hits: [], total: null };
|
|
105
|
+
rows = nextRows; sourceRevision = loaded.revision; sourceBytes = loaded.bytes; dirty = false;
|
|
106
|
+
if (storage) { pendingSnapshot = index.snapshot(); writes += (await storage.save(snapshotKey, pendingSnapshot)).changes; pendingSnapshot = null; }
|
|
107
|
+
if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
|
|
108
|
+
return { state: 'complete', changes: result.changes, sourceRevision };
|
|
109
|
+
}
|
|
110
|
+
catch (error) { dirty = true; return refusal(error instanceof RangeError || isCursorBudgetError(error)
|
|
111
|
+
? 'budget-exhausted' : 'error', disposed ? 'disposed' : error?.message ?? String(error)); }
|
|
112
|
+
};
|
|
113
|
+
busy = run().finally(() => { busy = null; }); return busy;
|
|
114
|
+
};
|
|
115
|
+
const service = {
|
|
116
|
+
refresh,
|
|
117
|
+
get sourceRevision() { return sourceRevision; },
|
|
118
|
+
async search(text, spec = {}) {
|
|
119
|
+
const ready = await refresh(); if (ready.state !== 'complete') return ready;
|
|
120
|
+
return createLexicalProvider(index, { row: (id) => rows.get(id) }).compile(spec)(text);
|
|
121
|
+
},
|
|
122
|
+
/** Access a row only under its published search revision. */
|
|
123
|
+
row(id, revision) {
|
|
124
|
+
if (disposed || dirty || revision !== sourceRevision) throw new Error('Lexical source snapshot changed');
|
|
125
|
+
return structuredClone(rows.get(id));
|
|
126
|
+
},
|
|
127
|
+
subscribe(fn) {
|
|
128
|
+
if (disposed || typeof fn !== 'function') throw new TypeError('Invalid lexical observer');
|
|
129
|
+
if (observers.size >= 8) throw new RangeError('Lexical subscription credits');
|
|
130
|
+
observers.add(fn); return () => observers.delete(fn);
|
|
131
|
+
},
|
|
132
|
+
explain() { return { mode: 'resident', nativeFTS: false, reason: 'native-token-rank-parity-unqualified',
|
|
133
|
+
maxRows, maxBytes, capture: store.capabilities.capture, externalChanges: 'dataVersion plus authoritative SHA-256 on refresh' }; },
|
|
134
|
+
stats() { return { ...index.stats(), sourceRows: rows.size, sourceBytes, reads, writes, restores, rebuilds,
|
|
135
|
+
recovery, dirty, pending: busy ? 1 : 0, subscriptions: observers.size }; },
|
|
136
|
+
async dispose() {
|
|
137
|
+
disposed = true; controller.abort(); unsubscribe(); observers.clear();
|
|
138
|
+
await busy; index.dispose(); rows.clear(); sourceBytes = 0; pendingSnapshot = null;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const ready = await refresh();
|
|
142
|
+
if (ready.state !== 'complete') { await service.dispose(); throw new Error(`Lexical source: ${ready.reason}`); }
|
|
143
|
+
return service;
|
|
144
|
+
}
|
package/src/sql.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Trusted prepared statements, restricted to a live transaction's authority. */
|
|
3
|
+
import { sqlTokens } from './dialects/check-read.js';
|
|
4
|
+
import { chain, attempt } from './driver.js';
|
|
5
|
+
import { DbRuntimeError, wrapDriverError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** @param {any} context @returns {any} a scoped SQL capability */
|
|
8
|
+
export function trustedSql({ connection, requireScope, beforeWrite, afterWrite, readOnly }) {
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
/** Compile one statement. Access is explicit; this is trusted application SQL,
|
|
11
|
+
* not a sandbox for untrusted query text or side-effecting host functions.
|
|
12
|
+
* @param {string} sql @param {{ access: 'read' | 'write', affects?: readonly string[] }} options */
|
|
13
|
+
prepare(sql, options) {
|
|
14
|
+
requireScope();
|
|
15
|
+
const refuse = (why) => { throw new DbRuntimeError('JD2095', why); };
|
|
16
|
+
if (typeof sql !== 'string' || !['read', 'write'].includes(options?.access)) refuse('SQL prepare requires text and explicit read/write access');
|
|
17
|
+
const tokens = sqlTokens(sql);
|
|
18
|
+
const words = tokens.filter((t) => t.kind === 'word').map((t) => t.value.toUpperCase());
|
|
19
|
+
if (!tokens.length || tokens[0].kind !== 'word' || !['SELECT', 'WITH', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(words[0])) refuse('trusted SQL accepts a single SELECT or data mutation');
|
|
20
|
+
if (tokens.some((t, i) => t.value === ';' && t.kind === 'symbol' && i !== tokens.length - 1)
|
|
21
|
+
|| words.some((w) => /^(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|ATTACH|DETACH|PRAGMA|CREATE|ALTER|DROP|VACUUM)$/.test(w))) refuse('SQL cannot change transaction ownership, schema or connection configuration');
|
|
22
|
+
const writing = options.access === 'write';
|
|
23
|
+
if (!writing && words.some((w) => ['INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(w))) refuse('a mutation requires write access');
|
|
24
|
+
if (writing && readOnly) refuse('this store grants no SQL write authority');
|
|
25
|
+
if (options.affects !== undefined && (!Array.isArray(options.affects) || options.affects.some((v) => typeof v !== 'string'))) refuse('affects is an array of entity names');
|
|
26
|
+
let closed = false;
|
|
27
|
+
const check = () => {
|
|
28
|
+
requireScope();
|
|
29
|
+
if (closed) refuse('the prepared SQL statement is closed');
|
|
30
|
+
};
|
|
31
|
+
const statement = connection.prepare(sql, { readOnly: !writing });
|
|
32
|
+
const run = (method, params = []) => {
|
|
33
|
+
check();
|
|
34
|
+
if (!Array.isArray(params)) refuse('SQL parameters must be an array');
|
|
35
|
+
return attempt(() => chain(statement, (s) => {
|
|
36
|
+
check();
|
|
37
|
+
if (writing) beforeWrite(options.affects);
|
|
38
|
+
return chain(s[method](params), (result) => {
|
|
39
|
+
if (writing) afterWrite(options.affects);
|
|
40
|
+
return result;
|
|
41
|
+
});
|
|
42
|
+
}), (error) => wrapDriverError(error, { docPath: '/sql' }));
|
|
43
|
+
};
|
|
44
|
+
return Object.freeze({ run: (params) => run('run', params),
|
|
45
|
+
get: (params) => run('get', params), all: (params) => run('all', params),
|
|
46
|
+
close: () => { requireScope(); closed = true; } });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A synchronous transaction ends at callback return, including a thenable return.
|
|
52
|
+
* @param {Function} fn @param {any} tx @returns {any} */
|
|
53
|
+
export function synchronousBody(fn, tx) {
|
|
54
|
+
const value = fn(tx);
|
|
55
|
+
if (value != null && typeof value.then === 'function') {
|
|
56
|
+
Promise.resolve(value).catch(() => {});
|
|
57
|
+
throw new DbRuntimeError('JD2095', 'a synchronous transaction callback must not return a thenable');
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
package/src/store.js
CHANGED
|
@@ -36,6 +36,8 @@ import { createBackup } from './backup.js';
|
|
|
36
36
|
import { normalizeProfile, assertProfileRoots } from './profile.js';
|
|
37
37
|
import { normalizeEntities, explainMapping, joinTableRoots } from './model.js';
|
|
38
38
|
import { entityCore } from './entity.js';
|
|
39
|
+
import { verifyPhysical } from './physical.js';
|
|
40
|
+
import { trustedSql, synchronousBody } from './sql.js';
|
|
39
41
|
import { createTracker, membershipKeys } from './tracker.js';
|
|
40
42
|
import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
|
|
41
43
|
import { createReplicationEngine } from './replication.js';
|
|
@@ -46,7 +48,7 @@ import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js'
|
|
|
46
48
|
import { classifyEntityLive } from './live-join.js';
|
|
47
49
|
import { normalizeEventTime } from './live-time.js';
|
|
48
50
|
import { createJobEngine } from './jobs.js';
|
|
49
|
-
import { introspectModel } from './introspect.js';
|
|
51
|
+
import { introspectModel, readSchema } from './introspect.js';
|
|
50
52
|
import { collectEntityRoots, entityRoot } from './plan.js';
|
|
51
53
|
import {
|
|
52
54
|
DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
|
|
@@ -486,6 +488,7 @@ function immediately(connection, fn, retry = true) {
|
|
|
486
488
|
function ensureShape(connection, collections, plans, readOnly) {
|
|
487
489
|
const dialect = connection.dialect;
|
|
488
490
|
const names = [...collections.keys()];
|
|
491
|
+
if (names.length === 0) return null;
|
|
489
492
|
// a read-only store creates nothing, and cannot take a write lock
|
|
490
493
|
const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
|
|
491
494
|
return bracket(() => {
|
|
@@ -537,6 +540,8 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
|
|
|
537
540
|
const name = names[i];
|
|
538
541
|
const plan = entityPlans.get(name);
|
|
539
542
|
const docPath = entities.get(name)?.docPath ?? `/entities/${name}`;
|
|
543
|
+
if (plan.physical) return chain(readSchema(connection), (schema) =>
|
|
544
|
+
chain(verifyPhysical(connection, plan.physical, schema), () => step(i + 1)));
|
|
540
545
|
return chain(connection.prepare(dialect.introspect.tableExists()), (statement) =>
|
|
541
546
|
chain(statement.get([name]), (row) => {
|
|
542
547
|
if (row === undefined) {
|
|
@@ -824,10 +829,11 @@ function asyncCollection(core, live) {
|
|
|
824
829
|
*/
|
|
825
830
|
function writeSchemaOf(entity) {
|
|
826
831
|
const schema = entity.schema;
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
832
|
+
const generated = new Set(entity.keys.filter((key) => entity.properties.get(key).default === 'auto'));
|
|
833
|
+
for (const column of entity.physical?.columns ?? [])
|
|
834
|
+
if (column.databaseDefault || column.generated) generated.add(column.name);
|
|
835
|
+
if (!Array.isArray(schema?.required) || !schema.required.some((name) => generated.has(name))) return schema;
|
|
836
|
+
const out = { ...schema, required: schema.required.filter((name) => !generated.has(name)) };
|
|
831
837
|
if (out.required.length === 0) delete out.required;
|
|
832
838
|
return out;
|
|
833
839
|
}
|
|
@@ -971,6 +977,10 @@ export function openStore(model, options) {
|
|
|
971
977
|
collections = normalizeModel(model, options.expressions);
|
|
972
978
|
entities = normalizeEntities(model);
|
|
973
979
|
mapping = entities.size > 0 ? explainMapping(model) : null;
|
|
980
|
+
if ([...entities.values()].some((e) => e.physical !== null) && (options.capture || options.replication))
|
|
981
|
+
throw new DbCompileError('JD0051', 'column adoption preserves application triggers; complete capture is not qualified');
|
|
982
|
+
if (options.adopt === true && (options.capture || options.replication))
|
|
983
|
+
throw new DbCompileError('JD0005', 'adoption creates no infrastructure; configure it through an explicit migration');
|
|
974
984
|
if (options.replication !== undefined && [...collections.keys(), ...entities.keys()]
|
|
975
985
|
.some((name) => name.toLowerCase().startsWith('_jaren_replica')))
|
|
976
986
|
throw new DbCompileError('JD0060', 'replication reserves table names beginning with _jaren_replica');
|
|
@@ -1425,8 +1435,8 @@ export function openStore(model, options) {
|
|
|
1425
1435
|
chain(registerExpressionFunctions(connection, expressionNames,
|
|
1426
1436
|
options.expressions ?? {}), () =>
|
|
1427
1437
|
chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
|
|
1428
|
-
chain(ensureShape(connection, collections, plans, readOnly), () =>
|
|
1429
|
-
chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
|
|
1438
|
+
chain(ensureShape(connection, collections, plans, readOnly || options.adopt === true), () =>
|
|
1439
|
+
chain(ensureEntityShape(connection, entityPlans, entities, readOnly || options.adopt === true), () => {
|
|
1430
1440
|
/** @type {Map<string, any>} */
|
|
1431
1441
|
const cores = new Map();
|
|
1432
1442
|
const coreFor = (name) => {
|
|
@@ -1598,6 +1608,7 @@ export function openStore(model, options) {
|
|
|
1598
1608
|
}
|
|
1599
1609
|
const jobsEngine = !jobsRequested ? null : createJobEngine({
|
|
1600
1610
|
connection,
|
|
1611
|
+
adopt: options.adopt === true,
|
|
1601
1612
|
bracket: firstOpen,
|
|
1602
1613
|
// the WORKER's control-plane I/O (claims, renewals, its
|
|
1603
1614
|
// checkpoint stores, its settlements) is root-owned and takes
|
|
@@ -1994,6 +2005,7 @@ export function openStore(model, options) {
|
|
|
1994
2005
|
return next(0);
|
|
1995
2006
|
};
|
|
1996
2007
|
ops = {
|
|
2008
|
+
mutate: (document) => core.mutate(document),
|
|
1997
2009
|
create: (doc) => {
|
|
1998
2010
|
const memberships = membershipsOf(doc);
|
|
1999
2011
|
if (memberships.length === 0)
|
|
@@ -2066,6 +2078,7 @@ export function openStore(model, options) {
|
|
|
2066
2078
|
create: lift((doc) => ops.create(doc)),
|
|
2067
2079
|
get: lift((key) => ops.get(key)),
|
|
2068
2080
|
update: lift((key, changes) => ops.update(key, changes)),
|
|
2081
|
+
mutate: lift((document) => ops.mutate(document)),
|
|
2069
2082
|
delete: lift((key) => ops.delete(key)),
|
|
2070
2083
|
load: lift((spec, loadOptions) => ops.load(spec, loadOptions)),
|
|
2071
2084
|
loadCursor: (spec, cursorOptions) => ops.loadCursor(spec, cursorOptions),
|
|
@@ -2252,7 +2265,8 @@ export function openStore(model, options) {
|
|
|
2252
2265
|
for (const member of names) {
|
|
2253
2266
|
if (typeof handle[member] !== 'function') continue;
|
|
2254
2267
|
out[member] = (/** @type {any[]} */ ...args) =>
|
|
2255
|
-
lift(() => gated(() => handle[member](...args)
|
|
2268
|
+
lift(() => gated(() => handle[member](...args), undefined,
|
|
2269
|
+
member === 'page' ? args[1]?.signal : undefined))();
|
|
2256
2270
|
}
|
|
2257
2271
|
for (const member of valued) {
|
|
2258
2272
|
if (typeof handle[member] !== 'function') continue;
|
|
@@ -2315,7 +2329,7 @@ export function openStore(model, options) {
|
|
|
2315
2329
|
// collection's `query` does: admitted one item at a time,
|
|
2316
2330
|
// never held across the caller's loop
|
|
2317
2331
|
handle = gatedMembers(inner,
|
|
2318
|
-
['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
|
|
2332
|
+
['create', 'get', 'update', 'mutate', 'delete', 'load', 'page', 'explain'],
|
|
2319
2333
|
['execute']);
|
|
2320
2334
|
const untracked = gatedMembers(inner.asNoTracking(), ['get', 'load']);
|
|
2321
2335
|
handle = Object.freeze({
|
|
@@ -2434,6 +2448,7 @@ export function openStore(model, options) {
|
|
|
2434
2448
|
get: lift((...args) => gated(() => jobsEngine.get(...args), 'a root job read')),
|
|
2435
2449
|
counts: lift(() => gated(() => jobsEngine.counts(), 'a root job read')),
|
|
2436
2450
|
claim: lift((...args) => gated(() => jobsEngine.claim(...args), 'a root job claim')),
|
|
2451
|
+
assertLease: lift((...args) => gated(() => jobsEngine.assertLease(...args), 'a root lease check')),
|
|
2437
2452
|
renew: lift((...args) => gated(() => jobsEngine.renew(...args), 'a root lease renewal')),
|
|
2438
2453
|
complete: lift((...args) => gated(() => jobsEngine.complete(...args), 'a root job settlement')),
|
|
2439
2454
|
fail: lift((...args) => gated(() => jobsEngine.fail(...args), 'a root job settlement')),
|
|
@@ -2598,7 +2613,7 @@ export function openStore(model, options) {
|
|
|
2598
2613
|
scopedMembers(identity, inner.asNoTracking(), ['get', 'load']));
|
|
2599
2614
|
return Object.freeze({
|
|
2600
2615
|
...scopedMembers(identity, inner,
|
|
2601
|
-
['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
|
|
2616
|
+
['create', 'get', 'update', 'mutate', 'delete', 'load', 'page', 'explain'],
|
|
2602
2617
|
['execute', 'add', 'put', 'remove', 'discard', 'link', 'unlink']),
|
|
2603
2618
|
cursor: (/** @type {any} */ document, /** @type {any} */ queryOptions) =>
|
|
2604
2619
|
scopedCursor(identity, () => inner.cursor(document, queryOptions)),
|
|
@@ -2771,7 +2786,21 @@ export function openStore(model, options) {
|
|
|
2771
2786
|
});
|
|
2772
2787
|
};
|
|
2773
2788
|
|
|
2789
|
+
const sql = trustedSql({ connection, readOnly, requireScope: () => requireScope(identity),
|
|
2790
|
+
beforeWrite: () => {
|
|
2791
|
+
if ([...entities.values()].some((e) => e.invariants.some((r) => r.enforcement === 'store')))
|
|
2792
|
+
throw new DbRuntimeError('JD2095', 'trusted SQL cannot bypass store-only invariants');
|
|
2793
|
+
if (capture !== null) throw new DbRuntimeError('JD0051', 'trusted SQL writes cannot guarantee complete live/capture/replication coverage');
|
|
2794
|
+
myWork.tracker?.assertSqlWritable();
|
|
2795
|
+
if (rootWork !== myWork) rootWork.tracker?.assertSqlWritable();
|
|
2796
|
+
},
|
|
2797
|
+
afterWrite: () => {
|
|
2798
|
+
myWork.tracker?.invalidate();
|
|
2799
|
+
if (rootWork !== myWork) rootWork.tracker?.invalidate();
|
|
2800
|
+
},
|
|
2801
|
+
});
|
|
2774
2802
|
const members = {
|
|
2803
|
+
sql: override(sql),
|
|
2775
2804
|
transaction: override((/** @type {any} */ fn) => lift(() => nested(fn))()),
|
|
2776
2805
|
collection: override(collectionFor),
|
|
2777
2806
|
entity: override(entityFor),
|
|
@@ -2865,6 +2894,10 @@ export function openStore(model, options) {
|
|
|
2865
2894
|
requireScope(identity);
|
|
2866
2895
|
return jobsEngine.claim(...args);
|
|
2867
2896
|
}),
|
|
2897
|
+
assertLease: lift((/** @type {any[]} */ ...args) => {
|
|
2898
|
+
requireScope(identity);
|
|
2899
|
+
return jobsEngine.assertLease(...args);
|
|
2900
|
+
}),
|
|
2868
2901
|
renew: lift((/** @type {any[]} */ ...args) => {
|
|
2869
2902
|
requireScope(identity);
|
|
2870
2903
|
return jobsEngine.renew(...args);
|
|
@@ -2931,7 +2964,8 @@ export function openStore(model, options) {
|
|
|
2931
2964
|
}
|
|
2932
2965
|
return handle;
|
|
2933
2966
|
},
|
|
2934
|
-
|
|
2967
|
+
sql,
|
|
2968
|
+
transaction: (fn) => nested((tx) => synchronousBody(fn, tx)),
|
|
2935
2969
|
savepoints: Object.freeze({
|
|
2936
2970
|
create: savepointCreate,
|
|
2937
2971
|
rollbackTo: savepointRollbackTo,
|
|
@@ -3011,7 +3045,7 @@ export function openStore(model, options) {
|
|
|
3011
3045
|
}
|
|
3012
3046
|
return handle;
|
|
3013
3047
|
},
|
|
3014
|
-
transaction: (fn) => {
|
|
3048
|
+
transaction: (fn, transactionOptions) => {
|
|
3015
3049
|
// the synchronous surface answers values: while a
|
|
3016
3050
|
// transaction owns the connection it could only QUEUE,
|
|
3017
3051
|
// which handed a Promise back under a value's type
|
|
@@ -3021,7 +3055,9 @@ export function openStore(model, options) {
|
|
|
3021
3055
|
+ 'settle — nest through the store the callback received, or use the '
|
|
3022
3056
|
+ 'asynchronous store.transaction()');
|
|
3023
3057
|
}
|
|
3024
|
-
|
|
3058
|
+
const mode = transactionOptions?.mode;
|
|
3059
|
+
if (mode !== undefined && mode !== 'deferred' && mode !== 'immediate') throw new TypeError('invalid transaction mode');
|
|
3060
|
+
return topLevelTransaction((tx) => synchronousBody(fn, tx), undefined, undefined, mode);
|
|
3025
3061
|
},
|
|
3026
3062
|
entity(name) {
|
|
3027
3063
|
let handle = gatedSyncEntities.get(name);
|
package/src/tracker.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { createJSONPatch } from '@jarenjs/json/patch';
|
|
32
32
|
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
33
33
|
|
|
34
|
-
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
34
|
+
import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
|
|
35
35
|
import { chain, attempt } from './driver.js';
|
|
36
36
|
import { translatePatch } from './patch-sql.js';
|
|
37
37
|
|
|
@@ -383,12 +383,15 @@ export function createTracker(context) {
|
|
|
383
383
|
fallback = true;
|
|
384
384
|
continue;
|
|
385
385
|
}
|
|
386
|
+
if (plan.scalarColumns.some((c) => c.name === first && c.generated))
|
|
387
|
+
throw contractError(entityName, `'${first}' is generated by the database`);
|
|
386
388
|
const value = op.op === 'remove' ? undefined : op.value;
|
|
387
389
|
columnSets.set(first, plan.encodeColumn(first, value));
|
|
388
390
|
// the epoch string ALSO lives in the document
|
|
389
391
|
if (isEpoch) docOps.push(op);
|
|
390
392
|
continue;
|
|
391
393
|
}
|
|
394
|
+
if (plan.document === false) throw contractError(entityName, `'${first}' is not a mapped column`);
|
|
392
395
|
docOps.push(op);
|
|
393
396
|
}
|
|
394
397
|
let docBuild = null;
|
|
@@ -505,6 +508,8 @@ export function createTracker(context) {
|
|
|
505
508
|
for (const record of records.values()) {
|
|
506
509
|
const core = coreFor(record.entity);
|
|
507
510
|
if (record.pendingInsert === true) {
|
|
511
|
+
core.plan.writable?.();
|
|
512
|
+
if (core.plan.document !== false) core.plan.checkMutation?.('insert', null, record.current);
|
|
508
513
|
let list = inserts.get(record.entity);
|
|
509
514
|
if (list === undefined) {
|
|
510
515
|
list = [];
|
|
@@ -522,7 +527,9 @@ export function createTracker(context) {
|
|
|
522
527
|
// probe before stamping: an update stamp must never turn a
|
|
523
528
|
// deep-equal replacement into a phantom write
|
|
524
529
|
if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
|
|
530
|
+
core.plan.writable?.();
|
|
525
531
|
record.stamped = core.stampUpdated(record.current);
|
|
532
|
+
if (core.plan.document !== false) core.plan.checkMutation?.('update', record.snapshot, record.stamped);
|
|
526
533
|
const parts = partitionDiff(record.entity, record);
|
|
527
534
|
for (const member of parts.m2mMembers) {
|
|
528
535
|
joinOps.push(joinDiff(record.entity, record.snapshot, record.stamped,
|
|
@@ -546,6 +553,8 @@ export function createTracker(context) {
|
|
|
546
553
|
/** @type {any[]} */
|
|
547
554
|
const deletes = [];
|
|
548
555
|
for (const removal of removals.values()) {
|
|
556
|
+
coreFor(removal.entity).plan.writable?.();
|
|
557
|
+
coreFor(removal.entity).plan.checkMutation?.('delete', removal.snapshot, null);
|
|
549
558
|
deletes.push(removal);
|
|
550
559
|
if (coreFor(removal.entity).plan.version === null)
|
|
551
560
|
unversioned.add(removal.entity);
|
|
@@ -620,21 +629,21 @@ export function createTracker(context) {
|
|
|
620
629
|
}
|
|
621
630
|
for (const group of shapes.values()) {
|
|
622
631
|
const names = group[0].split.values.map((value) => value.name);
|
|
623
|
-
const paramsPerRow = names.length + 1;
|
|
624
|
-
const rowsPerBatch = Math.max(1, Math.min(BATCH_ROW_BOUND,
|
|
632
|
+
const paramsPerRow = names.length + (plan.document === false ? 0 : 1);
|
|
633
|
+
const rowsPerBatch = names.length === 0 ? 1 : Math.max(1, Math.min(BATCH_ROW_BOUND,
|
|
625
634
|
Math.floor(BATCH_PARAM_BUDGET / paramsPerRow)));
|
|
626
635
|
for (let at = 0; at < group.length; at += rowsPerBatch) {
|
|
627
636
|
const batch = group.slice(at, at + rowsPerBatch);
|
|
628
637
|
const returning = plan.autoKey !== null && !names.includes(plan.autoKey);
|
|
629
638
|
const rowSql = (base) => `(${[...names.map((_, i) => parameterAt(base + i + 1)),
|
|
630
|
-
dialect.jsonEncode(parameterAt(base + names.length + 1))].join(', ')})`;
|
|
631
|
-
const sql = `INSERT INTO ${q(plan.table)} `
|
|
632
|
-
+ `(${[...names.map(q), q('doc')].join(', ')}) VALUES `
|
|
633
|
-
+ batch.map((_, i) => rowSql(i * paramsPerRow)).join(', ')
|
|
634
|
-
+ (returning ? ` RETURNING ${q(plan.autoKey)} AS ${q('key')}` : '');
|
|
639
|
+
...(plan.document === false ? [] : [dialect.jsonEncode(parameterAt(base + names.length + 1))])].join(', ')})`;
|
|
640
|
+
const sql = (plan.document === false && names.length === 0 ? `INSERT INTO ${q(plan.table)} DEFAULT VALUES` : `INSERT INTO ${q(plan.table)} `
|
|
641
|
+
+ `(${[...names.map((n) => q(plan.physicalName(n))), ...(plan.document === false ? [] : [q('doc')])].join(', ')}) VALUES `
|
|
642
|
+
+ batch.map((_, i) => rowSql(i * paramsPerRow)).join(', '))
|
|
643
|
+
+ (returning ? ` RETURNING ${q(plan.physicalName(plan.autoKey))} AS ${q('key')}` : '');
|
|
635
644
|
const params = batch.flatMap(({ split }) => [
|
|
636
645
|
...split.values.map((value) => value.value),
|
|
637
|
-
JSON.stringify(split.rest),
|
|
646
|
+
...(plan.document === false ? [] : [JSON.stringify(split.rest)]),
|
|
638
647
|
]);
|
|
639
648
|
statements.push({
|
|
640
649
|
kind: 'insert', entity: entityName, sql, params, returning,
|
|
@@ -653,15 +662,17 @@ export function createTracker(context) {
|
|
|
653
662
|
const split = plan.split(record.stamped);
|
|
654
663
|
for (const value of split.values) {
|
|
655
664
|
if (value.name === plan.version) continue;
|
|
656
|
-
assignments.push(`${q(value.name)} = ${parameterAt(params.length + 1)}`);
|
|
665
|
+
assignments.push(`${q(plan.physicalName(value.name))} = ${parameterAt(params.length + 1)}`);
|
|
657
666
|
params.push(value.value);
|
|
658
667
|
}
|
|
659
|
-
|
|
660
|
-
|
|
668
|
+
if (plan.document !== false) {
|
|
669
|
+
assignments.push(`${q('doc')} = ${dialect.jsonEncode(parameterAt(params.length + 1))}`);
|
|
670
|
+
params.push(JSON.stringify(split.rest));
|
|
671
|
+
}
|
|
661
672
|
}
|
|
662
673
|
else {
|
|
663
674
|
for (const [name, value] of parts.columnSets) {
|
|
664
|
-
assignments.push(`${q(name)} = ${parameterAt(params.length + 1)}`);
|
|
675
|
+
assignments.push(`${q(plan.physicalName(name))} = ${parameterAt(params.length + 1)}`);
|
|
665
676
|
params.push(value);
|
|
666
677
|
}
|
|
667
678
|
if (parts.docBuild !== null) {
|
|
@@ -673,16 +684,16 @@ export function createTracker(context) {
|
|
|
673
684
|
const snapshotVersion = plan.version === null
|
|
674
685
|
? null : Number(record.snapshot[plan.version]) || 0;
|
|
675
686
|
if (plan.version !== null) {
|
|
676
|
-
assignments.push(`${q(plan.version)} = ${parameterAt(params.length + 1)}`);
|
|
687
|
+
assignments.push(`${q(plan.physicalName(plan.version))} = ${parameterAt(params.length + 1)}`);
|
|
677
688
|
params.push(snapshotVersion + 1);
|
|
678
689
|
}
|
|
679
690
|
const wheres = plan.keys.map((key) => {
|
|
680
|
-
params.push(record.snapshot[key]);
|
|
681
|
-
return `${q(key)} = ${parameterAt(params.length)}`;
|
|
691
|
+
params.push(plan.encodeColumn(key, record.snapshot[key]));
|
|
692
|
+
return `${q(plan.physicalName(key))} = ${parameterAt(params.length)}`;
|
|
682
693
|
});
|
|
683
694
|
if (plan.version !== null) {
|
|
684
695
|
params.push(snapshotVersion);
|
|
685
|
-
wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
|
|
696
|
+
wheres.push(`${q(plan.physicalName(plan.version))} = ${parameterAt(params.length)}`);
|
|
686
697
|
}
|
|
687
698
|
statements.push({
|
|
688
699
|
kind: 'update', entity: record.entity, record,
|
|
@@ -729,13 +740,13 @@ export function createTracker(context) {
|
|
|
729
740
|
const plan = coreFor(entityName).plan;
|
|
730
741
|
for (const removal of deletes) {
|
|
731
742
|
if (removal.entity !== entityName) continue;
|
|
732
|
-
const params =
|
|
733
|
-
const wheres = plan.keys.map((key, i) => `${q(key)} = ${parameterAt(i + 1)}`);
|
|
743
|
+
const params = removal.parts.map((v, i) => plan.encodeColumn(plan.keys[i], v));
|
|
744
|
+
const wheres = plan.keys.map((key, i) => `${q(plan.physicalName(key))} = ${parameterAt(i + 1)}`);
|
|
734
745
|
const snapshotVersion = plan.version !== null && removal.snapshot !== null
|
|
735
746
|
? Number(removal.snapshot[plan.version]) || 0 : null;
|
|
736
747
|
if (snapshotVersion !== null) {
|
|
737
748
|
params.push(snapshotVersion);
|
|
738
|
-
wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
|
|
749
|
+
wheres.push(`${q(plan.physicalName(plan.version))} = ${parameterAt(params.length)}`);
|
|
739
750
|
}
|
|
740
751
|
statements.push({
|
|
741
752
|
kind: 'delete', entity: entityName, removal,
|
|
@@ -772,14 +783,9 @@ export function createTracker(context) {
|
|
|
772
783
|
};
|
|
773
784
|
|
|
774
785
|
const wrapDb = (error, statement) => {
|
|
775
|
-
|
|
776
|
-
&& String((/** @type {any} */ (error)).code).startsWith('JD')) return error;
|
|
777
|
-
return new DbRuntimeError('JD2005',
|
|
778
|
-
`the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
|
|
779
|
-
{
|
|
786
|
+
return wrapDriverError(error, {
|
|
780
787
|
docPath: entities.get(statement.entity)?.docPath,
|
|
781
788
|
collection: statement.entity,
|
|
782
|
-
cause: error,
|
|
783
789
|
});
|
|
784
790
|
};
|
|
785
791
|
|
|
@@ -787,6 +793,24 @@ export function createTracker(context) {
|
|
|
787
793
|
const next = (i) => {
|
|
788
794
|
if (i >= statements.length) return report;
|
|
789
795
|
const statement = statements[i];
|
|
796
|
+
const advance = () => {
|
|
797
|
+
if (!['insert', 'update'].includes(statement.kind) || coreFor(statement.entity).plan.document !== false) return next(i + 1);
|
|
798
|
+
const core = coreFor(statement.entity);
|
|
799
|
+
const records = statement.kind === 'insert' ? statement.records : [statement.record];
|
|
800
|
+
statement.stored = [];
|
|
801
|
+
const refresh = (at) => {
|
|
802
|
+
if (at >= records.length) return next(i + 1);
|
|
803
|
+
const record = records[at];
|
|
804
|
+
const key = statement.returning ? { ...record.current, [core.plan.autoKey]: statement.generatedKeys[at] } : record.current;
|
|
805
|
+
return chain(core.get(key), (stored) => {
|
|
806
|
+
core.validateOnly(stored);
|
|
807
|
+
core.plan.checkMutation(statement.kind, statement.kind === 'insert' ? null : record.snapshot, stored);
|
|
808
|
+
statement.stored.push(deepFreeze(stored));
|
|
809
|
+
return refresh(at + 1);
|
|
810
|
+
});
|
|
811
|
+
};
|
|
812
|
+
return refresh(0);
|
|
813
|
+
};
|
|
790
814
|
// a join row whose own key the save allocates: the INSERT that
|
|
791
815
|
// allocates it has already run (inserts precede join rows), so the
|
|
792
816
|
// key is on the record by now
|
|
@@ -798,13 +822,7 @@ export function createTracker(context) {
|
|
|
798
822
|
}
|
|
799
823
|
return chain(connection.prepare(statement.sql), (prepared) => {
|
|
800
824
|
if (statement.kind === 'insert' && statement.returning === true) {
|
|
801
|
-
|
|
802
|
-
try {
|
|
803
|
-
fetched = prepared.all(statement.params);
|
|
804
|
-
}
|
|
805
|
-
catch (error) {
|
|
806
|
-
throw wrapDb(error, statement);
|
|
807
|
-
}
|
|
825
|
+
const fetched = attempt(() => prepared.all(statement.params), (error) => wrapDb(error, statement));
|
|
808
826
|
return chain(fetched, (rows) => {
|
|
809
827
|
// auto keys allocate monotonically in insertion order —
|
|
810
828
|
// sort ascending to pair rows with records (asserted by
|
|
@@ -816,7 +834,7 @@ export function createTracker(context) {
|
|
|
816
834
|
statement.records.forEach((record, at) => { record.allocatedKey = keys[at]; });
|
|
817
835
|
report.inserted += statement.records.length;
|
|
818
836
|
report.statements.push({ sql: statement.sql, rows: statement.records.length });
|
|
819
|
-
return
|
|
837
|
+
return advance();
|
|
820
838
|
});
|
|
821
839
|
}
|
|
822
840
|
return chain(
|
|
@@ -840,7 +858,7 @@ export function createTracker(context) {
|
|
|
840
858
|
}
|
|
841
859
|
else if (statement.kind === 'join-insert') report.joinInserted += changed;
|
|
842
860
|
else if (statement.kind === 'join-delete') report.joinDeleted += changed;
|
|
843
|
-
return
|
|
861
|
+
return advance();
|
|
844
862
|
});
|
|
845
863
|
});
|
|
846
864
|
});
|
|
@@ -949,9 +967,9 @@ export function createTracker(context) {
|
|
|
949
967
|
// the row this statement wrote is the one the save PLANNED; an
|
|
950
968
|
// edit made to the same record afterwards is not in the database
|
|
951
969
|
const planned = undo.fields.get(record)?.current ?? record.current;
|
|
952
|
-
const doc = statement.returning === true
|
|
970
|
+
const doc = statement.stored?.[i] ?? (statement.returning === true
|
|
953
971
|
? deepFreeze({ ...planned, [plan.autoKey]: statement.generatedKeys[i] })
|
|
954
|
-
: planned;
|
|
972
|
+
: planned);
|
|
955
973
|
// re-key under the real identity
|
|
956
974
|
records.delete(record.pendingKey);
|
|
957
975
|
const key = recordKeyFor(statement.entity, doc);
|
|
@@ -968,9 +986,9 @@ export function createTracker(context) {
|
|
|
968
986
|
const record = statement.record;
|
|
969
987
|
const plan = coreFor(statement.entity).plan;
|
|
970
988
|
const before = record.snapshot;
|
|
971
|
-
const saved = statement.newVersion === null
|
|
989
|
+
const saved = statement.stored?.[0] ?? (statement.newVersion === null
|
|
972
990
|
? record.stamped
|
|
973
|
-
: deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
|
|
991
|
+
: deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion }));
|
|
974
992
|
const pending = untouched(record) ? null : record.current;
|
|
975
993
|
record.snapshot = deepFreeze(saved);
|
|
976
994
|
record.current = pending ?? record.snapshot;
|
|
@@ -1069,7 +1087,13 @@ export function createTracker(context) {
|
|
|
1069
1087
|
}
|
|
1070
1088
|
};
|
|
1071
1089
|
|
|
1090
|
+
const assertSqlWritable = () => {
|
|
1091
|
+
if (removals.size || memberships.size || [...records.values()].some((r) => r.pendingInsert || r.current !== r.snapshot))
|
|
1092
|
+
throw new DbRuntimeError('JD2040', 'trusted SQL requires saving or discarding pending tracked changes first');
|
|
1093
|
+
};
|
|
1094
|
+
const invalidate = () => { assertSqlWritable(); records.clear(); };
|
|
1072
1095
|
return {
|
|
1096
|
+
assertSqlWritable, invalidate,
|
|
1073
1097
|
register, registerGraph, add, put, remove, discard, link, unlink, counts, saveChanges,
|
|
1074
1098
|
};
|
|
1075
1099
|
}
|