@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/store.js
ADDED
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The store: `openStore(model, options)` normalizes the model
|
|
4
|
+
* document, opens a database through the injected driver, applies (or
|
|
5
|
+
* verifies) the physical shape, and gives transactional,
|
|
6
|
+
* schema-validated reads and writes.
|
|
7
|
+
*
|
|
8
|
+
* The public surface is ASYNCHRONOUS — every method returns a promise
|
|
9
|
+
* — because the browser's OPFS story forces it regardless of any other
|
|
10
|
+
* backend. Where the driver is synchronous the same operations exist
|
|
11
|
+
* without the promise under `store.sync`, present only there — never a
|
|
12
|
+
* throwing stub — so portable code pays one declared microsecond and
|
|
13
|
+
* code that wants it back opts in knowingly. Internally everything
|
|
14
|
+
* composes through the sync-capable {@link chain}, so the async
|
|
15
|
+
* surface allocates exactly one promise per call, not one per step.
|
|
16
|
+
*
|
|
17
|
+
* Writes validate through an injected `compileSchema` hook with the
|
|
18
|
+
* `compileTypeTest` signature; absent, writes are unvalidated and
|
|
19
|
+
* `store.capabilities.validated === false` — a declared downgrade,
|
|
20
|
+
* never a silent one. `@jarenjs/validate` is never imported here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
24
|
+
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
25
|
+
|
|
26
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
27
|
+
import { chain, toPromise, isThenable } from './driver.js';
|
|
28
|
+
import { planCollection, planEntity, planJoinTable, verifyShape } from './ddl.js';
|
|
29
|
+
import { translatePatch } from './patch-sql.js';
|
|
30
|
+
import { createQueryEngine, createQueryState, createEntityQueryEngine, createLoadEngine } from './query.js';
|
|
31
|
+
import { normalizeProfile } from './profile.js';
|
|
32
|
+
import { normalizeEntities, explainMapping } from './model.js';
|
|
33
|
+
import { entityCore } from './entity.js';
|
|
34
|
+
import { createTracker } from './tracker.js';
|
|
35
|
+
import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
|
|
36
|
+
import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
|
|
37
|
+
import { createJobEngine } from './jobs.js';
|
|
38
|
+
import { collectEntityRoots } from './plan.js';
|
|
39
|
+
|
|
40
|
+
/** The model format version this store implements. */
|
|
41
|
+
export const MODEL_VERSION = '0.1';
|
|
42
|
+
|
|
43
|
+
const COLLECTION_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
44
|
+
const IDENTITIES = new Set(['uuid', 'integer']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} code
|
|
48
|
+
* @param {string} reason
|
|
49
|
+
* @param {string} docPath
|
|
50
|
+
* @returns {DbCompileError}
|
|
51
|
+
*/
|
|
52
|
+
function modelError(code, reason, docPath) {
|
|
53
|
+
return new DbCompileError(code, reason, docPath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize and check a model document. Every failure is `JD0005` with
|
|
58
|
+
* a `docPath` into the model.
|
|
59
|
+
* @param {any} model
|
|
60
|
+
* @returns {Map<string, any>} collection name -> normalized collection
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeModel(model) {
|
|
63
|
+
if (model === null || typeof model !== 'object' || Array.isArray(model))
|
|
64
|
+
throw modelError('JD0005', 'the model document must be an object', '');
|
|
65
|
+
if (model.$model !== MODEL_VERSION) {
|
|
66
|
+
throw modelError('JD0005',
|
|
67
|
+
`the model must declare "$model": "${MODEL_VERSION}"`, '/$model');
|
|
68
|
+
}
|
|
69
|
+
const collections = model.collections;
|
|
70
|
+
if (collections === undefined && model.entities !== undefined) {
|
|
71
|
+
return new Map(); // an entities-only model (§9)
|
|
72
|
+
}
|
|
73
|
+
if (collections === null || typeof collections !== 'object'
|
|
74
|
+
|| Array.isArray(collections) || Object.keys(collections).length === 0) {
|
|
75
|
+
throw modelError('JD0005',
|
|
76
|
+
'the model must declare at least one collection or entity', '/collections');
|
|
77
|
+
}
|
|
78
|
+
/** @type {Map<string, any>} */
|
|
79
|
+
const normalized = new Map();
|
|
80
|
+
for (const name of Object.keys(collections)) {
|
|
81
|
+
const docPath = `/collections/${name}`;
|
|
82
|
+
if (!COLLECTION_NAME.test(name)) {
|
|
83
|
+
throw modelError('JD0005',
|
|
84
|
+
`collection names are identifiers ([A-Za-z_][A-Za-z0-9_]*), got '${name}'`,
|
|
85
|
+
'/collections');
|
|
86
|
+
}
|
|
87
|
+
const spec = collections[name];
|
|
88
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
|
|
89
|
+
throw modelError('JD0005', 'a collection must be an object', docPath);
|
|
90
|
+
if (spec.schema === null || typeof spec.schema !== 'object'
|
|
91
|
+
|| Array.isArray(spec.schema))
|
|
92
|
+
throw modelError('JD0005', 'a collection needs an object schema', `${docPath}/schema`);
|
|
93
|
+
|
|
94
|
+
let keySegments = null;
|
|
95
|
+
let identity = 'caller';
|
|
96
|
+
if (spec.key === null || spec.key === undefined) {
|
|
97
|
+
identity = spec.identity;
|
|
98
|
+
if (!IDENTITIES.has(identity)) {
|
|
99
|
+
throw modelError('JD0005',
|
|
100
|
+
"a collection without a key pointer must declare identity 'uuid' or 'integer' — allocation is declared, never guessed",
|
|
101
|
+
`${docPath}/identity`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
if (spec.identity !== undefined && spec.identity !== 'caller') {
|
|
106
|
+
throw modelError('JD0005',
|
|
107
|
+
'a collection with a key pointer is caller-keyed; identity does not apply',
|
|
108
|
+
`${docPath}/identity`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof spec.key !== 'string') {
|
|
111
|
+
throw modelError('JD0005',
|
|
112
|
+
'the key must be an RFC 6901 pointer string or null', `${docPath}/key`);
|
|
113
|
+
}
|
|
114
|
+
let names;
|
|
115
|
+
try {
|
|
116
|
+
names = parseJSONPointer(spec.key);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw modelError('JD0005',
|
|
120
|
+
`the key '${spec.key}' is not a valid RFC 6901 pointer`, `${docPath}/key`);
|
|
121
|
+
}
|
|
122
|
+
if (names.length === 0) {
|
|
123
|
+
throw modelError('JD0005',
|
|
124
|
+
'the key pointer must select a member, not the whole document',
|
|
125
|
+
`${docPath}/key`);
|
|
126
|
+
}
|
|
127
|
+
keySegments = names.map((n) => ({ name: n }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const indexes = [];
|
|
131
|
+
const indexNames = new Set();
|
|
132
|
+
const declaredIndexes = spec.indexes ?? [];
|
|
133
|
+
if (!Array.isArray(declaredIndexes)) {
|
|
134
|
+
throw modelError('JD0005', 'indexes must be an array',
|
|
135
|
+
`${docPath}/indexes`);
|
|
136
|
+
}
|
|
137
|
+
for (let i = 0; i < declaredIndexes.length; i++) {
|
|
138
|
+
const index = declaredIndexes[i];
|
|
139
|
+
const indexDocPath = `${docPath}/indexes/${i}`;
|
|
140
|
+
if (index === null || typeof index !== 'object' || Array.isArray(index))
|
|
141
|
+
throw modelError('JD0005', 'an index must be an object', indexDocPath);
|
|
142
|
+
if (typeof index.name !== 'string' || !COLLECTION_NAME.test(index.name)) {
|
|
143
|
+
throw modelError('JD0005',
|
|
144
|
+
'an index needs an identifier name', `${indexDocPath}/name`);
|
|
145
|
+
}
|
|
146
|
+
if (indexNames.has(index.name)) {
|
|
147
|
+
throw modelError('JD0005',
|
|
148
|
+
`duplicate index name '${index.name}'`, `${indexDocPath}/name`);
|
|
149
|
+
}
|
|
150
|
+
indexNames.add(index.name);
|
|
151
|
+
const paths = Array.isArray(index.path) ? index.path : [index.path];
|
|
152
|
+
if (paths.length === 0
|
|
153
|
+
|| paths.some((p) => typeof p !== 'string' || p === '')) {
|
|
154
|
+
throw modelError('JD0005',
|
|
155
|
+
'an index path must be a JSONPath string (a composite index takes a non-empty array of them)',
|
|
156
|
+
`${indexDocPath}/path`);
|
|
157
|
+
}
|
|
158
|
+
indexes.push({
|
|
159
|
+
name: index.name,
|
|
160
|
+
paths,
|
|
161
|
+
unique: index.unique === true,
|
|
162
|
+
docPath: indexDocPath,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
normalized.set(name, {
|
|
167
|
+
name,
|
|
168
|
+
docPath,
|
|
169
|
+
schema: spec.schema,
|
|
170
|
+
key: spec.key ?? null,
|
|
171
|
+
keySegments,
|
|
172
|
+
identity,
|
|
173
|
+
indexes,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return normalized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read a caller-supplied key out of a document along the declared
|
|
181
|
+
* pointer.
|
|
182
|
+
* @param {any} doc
|
|
183
|
+
* @param {{ name: string }[]} keySegments
|
|
184
|
+
* @param {string} pointer
|
|
185
|
+
* @param {string} collection
|
|
186
|
+
* @param {string} docPath
|
|
187
|
+
* @returns {string | number}
|
|
188
|
+
*/
|
|
189
|
+
function extractKey(doc, keySegments, pointer, collection, docPath) {
|
|
190
|
+
let node = doc;
|
|
191
|
+
for (const segment of keySegments) {
|
|
192
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) {
|
|
193
|
+
node = undefined;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
node = node[segment.name];
|
|
197
|
+
}
|
|
198
|
+
if (typeof node !== 'string' && typeof node !== 'number') {
|
|
199
|
+
throw new DbRuntimeError('JD2002',
|
|
200
|
+
`the document carries no scalar key at the declared pointer '${pointer}'`,
|
|
201
|
+
{ docPath, collection });
|
|
202
|
+
}
|
|
203
|
+
return node;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {any} key
|
|
208
|
+
* @param {string} collection
|
|
209
|
+
* @param {string} docPath
|
|
210
|
+
* @returns {string | number}
|
|
211
|
+
*/
|
|
212
|
+
function requireKey(key, collection, docPath) {
|
|
213
|
+
if (typeof key !== 'string' && typeof key !== 'number') {
|
|
214
|
+
throw new DbRuntimeError('JD2002',
|
|
215
|
+
'a key must be a string or a number', { docPath, collection });
|
|
216
|
+
}
|
|
217
|
+
return key;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Is this database error a duplicate on the collection's key column?
|
|
222
|
+
* SQLite reports a rowid-alias conflict as SQLITE_CONSTRAINT_PRIMARYKEY
|
|
223
|
+
* (1555) and a declared-PRIMARY-KEY conflict as
|
|
224
|
+
* SQLITE_CONSTRAINT_UNIQUE naming `table.column` in the message.
|
|
225
|
+
* @param {any} error
|
|
226
|
+
* @param {string} table
|
|
227
|
+
* @param {string} keyColumn
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
function isDuplicateKey(error, table, keyColumn) {
|
|
231
|
+
if (error?.errcode === 1555) return true;
|
|
232
|
+
return typeof error?.message === 'string'
|
|
233
|
+
&& error.message.includes('UNIQUE constraint failed')
|
|
234
|
+
&& error.message.includes(`${table}.${keyColumn}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Wrap a database failure for one collection operation.
|
|
239
|
+
* @param {any} error
|
|
240
|
+
* @param {any} plan
|
|
241
|
+
* @param {string} collection
|
|
242
|
+
* @param {string} docPath
|
|
243
|
+
* @param {string | number} [key]
|
|
244
|
+
* @returns {DbRuntimeError}
|
|
245
|
+
*/
|
|
246
|
+
function wrapWriteError(error, plan, collection, docPath, key) {
|
|
247
|
+
if (isDuplicateKey(error, plan.table, plan.keyColumn)) {
|
|
248
|
+
return new DbRuntimeError('JD2001',
|
|
249
|
+
`a document already exists under key '${String(key)}'`,
|
|
250
|
+
{ docPath, collection, key, cause: error });
|
|
251
|
+
}
|
|
252
|
+
return new DbRuntimeError('JD2005',
|
|
253
|
+
`the database rejected the operation: ${error?.message ?? String(error)}`,
|
|
254
|
+
key === undefined
|
|
255
|
+
? { docPath, collection, cause: error }
|
|
256
|
+
: { docPath, collection, key, cause: error });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Create or verify every collection's physical shape, inside one
|
|
261
|
+
* transaction.
|
|
262
|
+
* @param {any} connection
|
|
263
|
+
* @param {Map<string, any>} collections
|
|
264
|
+
* @param {Map<string, any>} plans
|
|
265
|
+
* @returns {any} value-or-promise
|
|
266
|
+
*/
|
|
267
|
+
function ensureShape(connection, collections, plans, readOnly) {
|
|
268
|
+
const dialect = connection.dialect;
|
|
269
|
+
const names = [...collections.keys()];
|
|
270
|
+
return connection.transaction(() => {
|
|
271
|
+
const step = (i) => {
|
|
272
|
+
if (i >= names.length) return null;
|
|
273
|
+
const name = names[i];
|
|
274
|
+
const collection = collections.get(name);
|
|
275
|
+
const plan = plans.get(name);
|
|
276
|
+
return chain(connection.prepare(dialect.introspect.tableExists()), (statement) =>
|
|
277
|
+
chain(statement.get([name]), (row) => {
|
|
278
|
+
if (row === undefined) {
|
|
279
|
+
if (readOnly) {
|
|
280
|
+
throw new DbCompileError('JD0002',
|
|
281
|
+
`collection '${name}': the table does not exist and a read-only store creates nothing`,
|
|
282
|
+
collection.docPath);
|
|
283
|
+
}
|
|
284
|
+
const run = (j) => (j >= plan.createSql.length
|
|
285
|
+
? null
|
|
286
|
+
: chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
|
|
287
|
+
return chain(run(0), () => step(i + 1));
|
|
288
|
+
}
|
|
289
|
+
return chain(verifyShape(connection, plan, name, collection.docPath),
|
|
290
|
+
() => step(i + 1));
|
|
291
|
+
}));
|
|
292
|
+
};
|
|
293
|
+
return step(0);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Create or verify entity and join tables (the same
|
|
299
|
+
* create-or-verify discipline as collections), including the
|
|
300
|
+
* foreign-key list: source column, target table and the declared
|
|
301
|
+
* on-delete behaviour must match.
|
|
302
|
+
* @param {any} connection
|
|
303
|
+
* @param {Map<string, any>} entityPlans
|
|
304
|
+
* @param {Map<string, any>} entities
|
|
305
|
+
* @param {boolean} readOnly
|
|
306
|
+
* @returns {any} value-or-promise
|
|
307
|
+
*/
|
|
308
|
+
function ensureEntityShape(connection, entityPlans, entities, readOnly) {
|
|
309
|
+
if (entityPlans.size === 0) return null;
|
|
310
|
+
const dialect = connection.dialect;
|
|
311
|
+
const names = [...entityPlans.keys()];
|
|
312
|
+
return connection.transaction(() => {
|
|
313
|
+
const step = (i) => {
|
|
314
|
+
if (i >= names.length) return null;
|
|
315
|
+
const name = names[i];
|
|
316
|
+
const plan = entityPlans.get(name);
|
|
317
|
+
const docPath = entities.get(name)?.docPath ?? `/entities/${name}`;
|
|
318
|
+
return chain(connection.prepare(dialect.introspect.tableExists()), (statement) =>
|
|
319
|
+
chain(statement.get([name]), (row) => {
|
|
320
|
+
if (row === undefined) {
|
|
321
|
+
if (readOnly) {
|
|
322
|
+
throw new DbCompileError('JD0002',
|
|
323
|
+
`entity '${name}': the table does not exist and a read-only store creates nothing`,
|
|
324
|
+
docPath);
|
|
325
|
+
}
|
|
326
|
+
const run = (j) => (j >= plan.createSql.length
|
|
327
|
+
? null
|
|
328
|
+
: chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
|
|
329
|
+
return chain(run(0), () => step(i + 1));
|
|
330
|
+
}
|
|
331
|
+
return chain(verifyShape(connection, plan, name, docPath), () =>
|
|
332
|
+
chain(connection.prepare(dialect.introspect.foreignKeyList(name)), (fkStatement) =>
|
|
333
|
+
chain(fkStatement.all([]), (fkRows) => {
|
|
334
|
+
// the whole TUPLE, not the count: a key that changed
|
|
335
|
+
// `ON DELETE SET NULL` to `ON DELETE CASCADE`, or that now
|
|
336
|
+
// points at a different table or column, keeps the count
|
|
337
|
+
// identical and deletes different rows
|
|
338
|
+
const expectedFks = (plan.expectedForeignKeys ?? []);
|
|
339
|
+
/** @param {any} fk */
|
|
340
|
+
const describe = (fk) => `${fk.column} -> ${fk.references}`
|
|
341
|
+
+ `${fk.targetColumn === null ? '' : `(${fk.targetColumn})`}`
|
|
342
|
+
+ ` ON DELETE ${String(fk.onDelete ?? 'NO ACTION').toUpperCase()}`
|
|
343
|
+
+ ` ON UPDATE ${String(fk.onUpdate ?? 'NO ACTION').toUpperCase()}`;
|
|
344
|
+
const actual = fkRows.map((row) => describe({
|
|
345
|
+
column: String(row.source_column),
|
|
346
|
+
references: String(row.target),
|
|
347
|
+
targetColumn: row.target_column === null ? null : String(row.target_column),
|
|
348
|
+
onDelete: row.on_delete,
|
|
349
|
+
onUpdate: row.on_update,
|
|
350
|
+
})).sort();
|
|
351
|
+
const wanted = expectedFks.map(describe).sort();
|
|
352
|
+
if (actual.join(' | ') !== wanted.join(' | ')) {
|
|
353
|
+
throw new DbCompileError('JD0002',
|
|
354
|
+
`entity '${name}': the foreign keys are [${actual.join(', ')}], `
|
|
355
|
+
+ `the model declares [${wanted.join(', ')}]`,
|
|
356
|
+
docPath);
|
|
357
|
+
}
|
|
358
|
+
return step(i + 1);
|
|
359
|
+
})));
|
|
360
|
+
}));
|
|
361
|
+
};
|
|
362
|
+
return step(0);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build the per-collection operation core. Every function returns a
|
|
368
|
+
* value or a promise depending on the driver; the async surface lifts
|
|
369
|
+
* once, the sync surface passes through.
|
|
370
|
+
* @param {any} connection
|
|
371
|
+
* @param {any} collection - normalized collection
|
|
372
|
+
* @param {any} plan
|
|
373
|
+
* @param {((doc: any) => any) | null} validate
|
|
374
|
+
* @param {any} queryState - the store-wide statement cache and UDF set
|
|
375
|
+
* @param {{ profile: any }} storeProfileRef - the store-level profile
|
|
376
|
+
* @returns {any}
|
|
377
|
+
*/
|
|
378
|
+
function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef) {
|
|
379
|
+
const dialect = connection.dialect;
|
|
380
|
+
const shape = {
|
|
381
|
+
table: plan.table,
|
|
382
|
+
keyColumn: plan.keyColumn,
|
|
383
|
+
docColumn: plan.docColumn,
|
|
384
|
+
};
|
|
385
|
+
/** @type {Map<string, any>} */
|
|
386
|
+
const statements = new Map();
|
|
387
|
+
const prepared = (name, sql) => {
|
|
388
|
+
let statement = statements.get(name);
|
|
389
|
+
if (statement === undefined) {
|
|
390
|
+
statement = connection.prepare(sql);
|
|
391
|
+
statements.set(name, statement);
|
|
392
|
+
}
|
|
393
|
+
return statement;
|
|
394
|
+
};
|
|
395
|
+
const stats = { patchTranslated: 0, patchFallback: 0 };
|
|
396
|
+
const engine = createQueryEngine({
|
|
397
|
+
connection, state: queryState, collection, physicalPlan: plan,
|
|
398
|
+
profile: storeProfileRef.profile,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const checkValid = (doc) => {
|
|
402
|
+
if (validate === null) return;
|
|
403
|
+
const result = validate(doc);
|
|
404
|
+
const valid = result === true || result?.valid === true;
|
|
405
|
+
if (!valid) {
|
|
406
|
+
throw new DbRuntimeError('JD2003',
|
|
407
|
+
`collection '${collection.name}' rejected the document`,
|
|
408
|
+
Array.isArray(result?.errors)
|
|
409
|
+
? { docPath: collection.docPath, collection: collection.name, errors: result.errors }
|
|
410
|
+
: { docPath: collection.docPath, collection: collection.name });
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const resolveWriteKey = (doc, explicitKey) => {
|
|
415
|
+
if (collection.keySegments !== null) {
|
|
416
|
+
return extractKey(doc, collection.keySegments, collection.key,
|
|
417
|
+
collection.name, collection.docPath);
|
|
418
|
+
}
|
|
419
|
+
if (explicitKey !== undefined)
|
|
420
|
+
return requireKey(explicitKey, collection.name, collection.docPath);
|
|
421
|
+
if (collection.identity === 'uuid') return crypto.randomUUID();
|
|
422
|
+
return null; // integer: the database allocates
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const runWrite = (statementName, sql, params, key, reads) => {
|
|
426
|
+
return chain(prepared(statementName, sql), (statement) => {
|
|
427
|
+
let out;
|
|
428
|
+
try {
|
|
429
|
+
out = reads ? statement.get(params) : statement.run(params);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const core = {
|
|
439
|
+
stats: () => ({ ...stats }),
|
|
440
|
+
model: collection,
|
|
441
|
+
queryShape: engine.shape,
|
|
442
|
+
// the D2 provider: value-or-promise, deliberately NOT lifted — a
|
|
443
|
+
// synchronous driver answers a linq chain synchronously
|
|
444
|
+
execute: (document, options) => engine.execute(document, options),
|
|
445
|
+
query: (document, options) => engine.query(document, options),
|
|
446
|
+
explain: (document, options) => engine.explain(document, options),
|
|
447
|
+
get(key) {
|
|
448
|
+
requireKey(key, collection.name, collection.docPath);
|
|
449
|
+
return chain(prepared('get', dialect.dml.get(shape)), (statement) =>
|
|
450
|
+
chain(statement.get([key]),
|
|
451
|
+
(row) => (row === undefined ? undefined : JSON.parse(row.doc))));
|
|
452
|
+
},
|
|
453
|
+
insert(doc) {
|
|
454
|
+
checkValid(doc);
|
|
455
|
+
const key = resolveWriteKey(doc, undefined);
|
|
456
|
+
if (key === null) {
|
|
457
|
+
return chain(
|
|
458
|
+
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
459
|
+
[JSON.stringify(doc)], undefined, true),
|
|
460
|
+
(row) => row.key);
|
|
461
|
+
}
|
|
462
|
+
return chain(
|
|
463
|
+
runWrite('insert', dialect.dml.insert(shape),
|
|
464
|
+
[key, JSON.stringify(doc)], key, false),
|
|
465
|
+
() => key);
|
|
466
|
+
},
|
|
467
|
+
put(doc, explicitKey) {
|
|
468
|
+
checkValid(doc);
|
|
469
|
+
const key = resolveWriteKey(doc, explicitKey);
|
|
470
|
+
if (key === null) {
|
|
471
|
+
return chain(
|
|
472
|
+
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
473
|
+
[JSON.stringify(doc)], undefined, true),
|
|
474
|
+
(row) => row.key);
|
|
475
|
+
}
|
|
476
|
+
return chain(
|
|
477
|
+
runWrite('upsert', dialect.dml.upsert(shape),
|
|
478
|
+
[key, JSON.stringify(doc)], key, false),
|
|
479
|
+
() => key);
|
|
480
|
+
},
|
|
481
|
+
patch(key, ops) {
|
|
482
|
+
requireKey(key, collection.name, collection.docPath);
|
|
483
|
+
return chain(core.get(key), (current) => {
|
|
484
|
+
if (current === undefined) {
|
|
485
|
+
throw new DbRuntimeError('JD2006',
|
|
486
|
+
`no document to patch under key '${String(key)}'`,
|
|
487
|
+
{ docPath: collection.docPath, collection: collection.name, key });
|
|
488
|
+
}
|
|
489
|
+
// the copy-on-write engine validates the RESULT before any SQL
|
|
490
|
+
const next = applyJSONPatch(current, ops);
|
|
491
|
+
checkValid(next);
|
|
492
|
+
const translated = translatePatch(ops, current, dialect);
|
|
493
|
+
if (translated === null) {
|
|
494
|
+
stats.patchFallback++;
|
|
495
|
+
return chain(
|
|
496
|
+
runWrite('patchFallback',
|
|
497
|
+
dialect.dml.updateDoc(shape, dialect.jsonEncode(dialect.parameterRef(1, 'doc')), 2),
|
|
498
|
+
[JSON.stringify(next), key], key, false),
|
|
499
|
+
() => next);
|
|
500
|
+
}
|
|
501
|
+
stats.patchTranslated++;
|
|
502
|
+
const { expression, params } = translated.build(
|
|
503
|
+
dialect.quoteIdentifier(plan.docColumn), 1);
|
|
504
|
+
const sql = dialect.dml.updateDoc(shape, expression, params.length + 1);
|
|
505
|
+
return chain(prepared(`patch:${sql}`, sql), (statement) => {
|
|
506
|
+
try {
|
|
507
|
+
statement.run([...params, key]);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
|
|
511
|
+
}
|
|
512
|
+
return next;
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
delete(key) {
|
|
517
|
+
requireKey(key, collection.name, collection.docPath);
|
|
518
|
+
return chain(
|
|
519
|
+
runWrite('delete', dialect.dml.del(shape), [key], key, false),
|
|
520
|
+
(result) => Number(result?.changes ?? 0) > 0);
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
return core;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Lift a core operation into the asynchronous contract: the returned
|
|
528
|
+
* function ALWAYS rejects, never throws synchronously — a caller-side
|
|
529
|
+
* `catch` must be enough.
|
|
530
|
+
* @param {Function} fn
|
|
531
|
+
* @returns {(...args: any[]) => Promise<any>}
|
|
532
|
+
*/
|
|
533
|
+
function lift(fn) {
|
|
534
|
+
return (...args) => {
|
|
535
|
+
try {
|
|
536
|
+
return toPromise(fn(...args));
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
return Promise.reject(error);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The asynchronous collection surface over a core.
|
|
546
|
+
* @param {any} core
|
|
547
|
+
* @param {Function | null} live - the store-level live registration
|
|
548
|
+
* for this collection (null when the store has no capture)
|
|
549
|
+
* @returns {any}
|
|
550
|
+
*/
|
|
551
|
+
function asyncCollection(core, live) {
|
|
552
|
+
return Object.freeze({
|
|
553
|
+
stats: () => core.stats(),
|
|
554
|
+
get: lift(core.get),
|
|
555
|
+
insert: lift(core.insert),
|
|
556
|
+
put: lift(core.put),
|
|
557
|
+
patch: lift(core.patch),
|
|
558
|
+
delete: lift(core.delete),
|
|
559
|
+
// the provider contract (D2): execute stays value-or-promise so a
|
|
560
|
+
// linq chain over a synchronous driver stays synchronous
|
|
561
|
+
execute: (document, options) => core.execute(document, options),
|
|
562
|
+
query: (document, options) => core.query(document, options),
|
|
563
|
+
explain: lift(core.explain),
|
|
564
|
+
live: lift((document, liveOptions) => {
|
|
565
|
+
if (live === null) {
|
|
566
|
+
throw new DbCompileError('JD0050',
|
|
567
|
+
'live queries require change capture — open the store with { capture: true }');
|
|
568
|
+
}
|
|
569
|
+
return live(core, document, liveOptions);
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolve the store's operator seam (Ring 2) to a single
|
|
576
|
+
* `{ functions, extensions }` or `null`. Accepts `options.operators` (a
|
|
577
|
+
* registry from `@jarenjs/json/jslt`'s `createJsltRegistry()`) and/or
|
|
578
|
+
* raw `options.functions` / `options.extensions`. A registered operator
|
|
579
|
+
* becomes engine vocabulary the query planner recognises and the
|
|
580
|
+
* residual evaluates — it runs correctly in JavaScript over the fetched
|
|
581
|
+
* rows, and is never pushed to SQL in this ring (that is Ring 3). Bad
|
|
582
|
+
* input is API misuse (a synchronous `TypeError`), consistent with the
|
|
583
|
+
* driver check. When no registry is threaded the return is `null`, and
|
|
584
|
+
* the whole query engine is byte-identical to before.
|
|
585
|
+
* @param {any} options
|
|
586
|
+
* @returns {{ functions: any, extensions: any } | null}
|
|
587
|
+
*/
|
|
588
|
+
function resolveOperators(options) {
|
|
589
|
+
const registry = options.operators;
|
|
590
|
+
const hasRegistry = registry !== undefined && registry !== null;
|
|
591
|
+
const hasRaw = options.functions !== undefined || options.extensions !== undefined;
|
|
592
|
+
if (!hasRegistry && !hasRaw) return null;
|
|
593
|
+
let functions = {};
|
|
594
|
+
let extensions = {};
|
|
595
|
+
// the SQL-pushable scalar subset (Ring 3): the registered operators a
|
|
596
|
+
// pack marked `pushable: 'scalar'`, eligible to become deterministic
|
|
597
|
+
// UDFs where the driver supports them. Raw (registry-free) extensions
|
|
598
|
+
// are never pushed — only a registry declares pushability.
|
|
599
|
+
const pushableScalar = new Set();
|
|
600
|
+
if (hasRegistry) {
|
|
601
|
+
if (typeof registry.toOptions !== 'function') {
|
|
602
|
+
throw new TypeError('openStore: operators must be a registry '
|
|
603
|
+
+ '(createJsltRegistry()) exposing toOptions()');
|
|
604
|
+
}
|
|
605
|
+
const resolved = registry.toOptions();
|
|
606
|
+
// reuse the registry's stable frozen maps by reference when there
|
|
607
|
+
// are no raw overrides, so the engine's identity-keyed caches hit
|
|
608
|
+
functions = resolved.functions ?? {};
|
|
609
|
+
extensions = resolved.extensions ?? {};
|
|
610
|
+
if (typeof registry.forSql === 'function') {
|
|
611
|
+
for (const [name, meta] of Object.entries(registry.forSql())) {
|
|
612
|
+
if (meta.pushable === 'scalar') pushableScalar.add(name);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (options.functions !== undefined) functions = { ...functions, ...options.functions };
|
|
617
|
+
if (options.extensions !== undefined) extensions = { ...extensions, ...options.extensions };
|
|
618
|
+
return Object.freeze({ functions, extensions, pushableScalar });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Open (or create) a store described by a model document.
|
|
623
|
+
* @param {any} model - A `jaren-model` document (the 0.1 subset)
|
|
624
|
+
* @param {{ driver: any, path?: string, compileSchema?: Function,
|
|
625
|
+
* busyTimeout?: number, queueTimeout?: number, journalMode?: string,
|
|
626
|
+
* statementCacheBound?: number, profile?: any, operators?: any,
|
|
627
|
+
* functions?: any, extensions?: any,
|
|
628
|
+
* readOnly?: boolean }} options
|
|
629
|
+
* @returns {Promise<any>}
|
|
630
|
+
*/
|
|
631
|
+
export function openStore(model, options) {
|
|
632
|
+
if (options === null || typeof options !== 'object'
|
|
633
|
+
|| options.driver === null || typeof options.driver !== 'object'
|
|
634
|
+
|| typeof options.driver.open !== 'function')
|
|
635
|
+
throw new TypeError('openStore needs { driver } from @jarenjs/db/node, /bun or /wasm');
|
|
636
|
+
if (options.compileSchema !== undefined && typeof options.compileSchema !== 'function')
|
|
637
|
+
throw new TypeError('openStore: compileSchema must be a function when present');
|
|
638
|
+
const operators = resolveOperators(options);
|
|
639
|
+
|
|
640
|
+
// API misuse (above) throws; a defective MODEL rejects, per the
|
|
641
|
+
// asynchronous contract
|
|
642
|
+
let collections;
|
|
643
|
+
let entities;
|
|
644
|
+
let mapping;
|
|
645
|
+
try {
|
|
646
|
+
collections = normalizeModel(model);
|
|
647
|
+
entities = normalizeEntities(model);
|
|
648
|
+
mapping = entities.size > 0 ? explainMapping(model) : null;
|
|
649
|
+
if (collections.size === 0 && entities.size === 0) {
|
|
650
|
+
throw modelError('JD0005',
|
|
651
|
+
'the model must declare at least one collection or entity', '');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
return Promise.reject(error);
|
|
656
|
+
}
|
|
657
|
+
const path = options.path ?? ':memory:';
|
|
658
|
+
const busyTimeout = options.busyTimeout ?? 5000;
|
|
659
|
+
const journalMode = options.journalMode ?? 'wal';
|
|
660
|
+
const memory = path === ':memory:' || path === '';
|
|
661
|
+
const readOnly = options.readOnly === true;
|
|
662
|
+
const storeProfile = options.profile === undefined
|
|
663
|
+
? null
|
|
664
|
+
: normalizeProfile(options.profile);
|
|
665
|
+
|
|
666
|
+
return toPromise(chain(
|
|
667
|
+
options.driver.open(path,
|
|
668
|
+
{ timeout: busyTimeout, readOnly, queueTimeout: options.queueTimeout }),
|
|
669
|
+
(opened) => {
|
|
670
|
+
/**
|
|
671
|
+
* The transaction SCOPE that currently owns the driver connection,
|
|
672
|
+
* or `null`. A top-level `store.transaction()` sets it for the
|
|
673
|
+
* callback's whole lifetime so the collection/entity cores below
|
|
674
|
+
* reach the open transaction instead of queueing behind it.
|
|
675
|
+
* @type {any}
|
|
676
|
+
*/
|
|
677
|
+
let scope = null;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* What the cores talk to: the owning transaction's scope while one
|
|
681
|
+
* is open, the driver connection otherwise. One indirection here
|
|
682
|
+
* instead of a parallel set of handles per transaction — and it is
|
|
683
|
+
* why a write inside a transaction callback runs immediately as the
|
|
684
|
+
* owner rather than waiting for a commit it is part of.
|
|
685
|
+
*/
|
|
686
|
+
const connection = Object.freeze({
|
|
687
|
+
get synchronous() { return opened.synchronous; },
|
|
688
|
+
get capabilities() { return opened.capabilities; },
|
|
689
|
+
get dialect() { return opened.dialect; },
|
|
690
|
+
/** @param {string} sql */
|
|
691
|
+
exec: (sql) => (scope ?? opened).exec(sql),
|
|
692
|
+
/** @param {string} sql */
|
|
693
|
+
prepare: (sql) => (scope ?? opened).prepare(sql),
|
|
694
|
+
/** Internal transaction users (jobs, checkpoints, migrations)
|
|
695
|
+
* nest when a transaction is open and take the gate when not. */
|
|
696
|
+
transaction: (fn) => withScope((scope ?? opened).transaction, fn),
|
|
697
|
+
registerFunction: opened.registerFunction === null ? null
|
|
698
|
+
: (/** @type {string} */ name, /** @type {any} */ o, /** @type {Function} */ fn) =>
|
|
699
|
+
opened.registerFunction(name, o, fn),
|
|
700
|
+
session: opened.session === null ? null
|
|
701
|
+
: (/** @type {any} */ table) => opened.session(table),
|
|
702
|
+
close: () => opened.close(),
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Run `fn` as a transaction opened by `open`, with `scope` bound to
|
|
707
|
+
* it for the callback's whole lifetime and restored afterwards.
|
|
708
|
+
* @param {(inner: (s: any) => any) => any} open - the driver's
|
|
709
|
+
* `transaction`, gated (top level) or nesting (inner)
|
|
710
|
+
* @param {(store: any) => any} fn
|
|
711
|
+
*/
|
|
712
|
+
function withScope(open, fn) {
|
|
713
|
+
return open((inner) => {
|
|
714
|
+
const outer = scope;
|
|
715
|
+
scope = inner;
|
|
716
|
+
const restore = () => { scope = outer; };
|
|
717
|
+
let out;
|
|
718
|
+
try {
|
|
719
|
+
out = fn(scopedStore());
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
restore();
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
if (!isThenable(out)) {
|
|
726
|
+
restore();
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
return out.then(
|
|
730
|
+
(value) => { restore(); return value; },
|
|
731
|
+
(error) => { restore(); throw error; });
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Set once the store object exists; the transaction callback's
|
|
736
|
+
* argument, whose `transaction` NESTS instead of queueing. */
|
|
737
|
+
let scopedStore = () => undefined;
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A TOP-LEVEL store transaction. It takes the connection's gate
|
|
741
|
+
* first, so two of them never share a savepoint stack no matter how
|
|
742
|
+
* their callbacks interleave, and the capture scope opens INSIDE it
|
|
743
|
+
* — a rollback then undoes the translated patch together with the
|
|
744
|
+
* rows it describes.
|
|
745
|
+
* @param {(store: any) => any} fn
|
|
746
|
+
*/
|
|
747
|
+
let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
|
|
748
|
+
|
|
749
|
+
const dialect = connection.dialect;
|
|
750
|
+
/** @type {Map<string, any>} */
|
|
751
|
+
const plans = new Map();
|
|
752
|
+
for (const [name, collection] of collections)
|
|
753
|
+
plans.set(name, planCollection(name, collection, dialect));
|
|
754
|
+
/** @type {Map<string, any>} */
|
|
755
|
+
const entityPlans = new Map();
|
|
756
|
+
if (mapping !== null) {
|
|
757
|
+
for (const name of Object.keys(mapping.entities))
|
|
758
|
+
entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
|
|
759
|
+
for (const joinName of Object.keys(mapping.joinTables)) {
|
|
760
|
+
entityPlans.set(joinName,
|
|
761
|
+
planJoinTable(joinName, mapping.joinTables[joinName], mapping, dialect));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const pragmas = chain(
|
|
766
|
+
memory
|
|
767
|
+
? null
|
|
768
|
+
: chain(connection.exec(dialect.pragma.busyTimeout(busyTimeout)),
|
|
769
|
+
// a journal-mode change writes; a read-only store keeps
|
|
770
|
+
// whatever mode the file already has
|
|
771
|
+
() => (readOnly ? null : connection.exec(dialect.pragma.journalMode(journalMode)))),
|
|
772
|
+
// referential integrity is real only when the pragma is ON —
|
|
773
|
+
// it defaults off, so set it AND verify it per connection
|
|
774
|
+
() => chain(connection.exec(dialect.pragma.foreignKeys(true)), () =>
|
|
775
|
+
chain(connection.prepare(dialect.introspect.foreignKeysOn()), (statement) =>
|
|
776
|
+
chain(statement.get([]), (row) => {
|
|
777
|
+
if (Number(row?.enabled) !== 1) {
|
|
778
|
+
throw new DbCompileError('JD0003',
|
|
779
|
+
'this connection cannot enforce foreign keys (PRAGMA foreign_keys stayed off)');
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}))));
|
|
783
|
+
|
|
784
|
+
// ————— the rejection boundary around an ACQUIRED connection —————
|
|
785
|
+
// Initialization continues for a long way past `driver.open`:
|
|
786
|
+
// pragmas, shape verification, capture, jobs, readiness. Every one
|
|
787
|
+
// of those can refuse, and a refusal that walks away from the open
|
|
788
|
+
// handle leaks it — on Windows the database file simply stays
|
|
789
|
+
// locked, which is how three of these showed up as `EPERM` while
|
|
790
|
+
// a temporary directory was being removed. So: close exactly once
|
|
791
|
+
// on any failure after acquisition, and keep the initialization
|
|
792
|
+
// error primary — a close that also fails is retained beside it
|
|
793
|
+
// rather than replacing the reason the open was refused.
|
|
794
|
+
let closed = false;
|
|
795
|
+
/**
|
|
796
|
+
* Release the handle and re-reject with the original failure.
|
|
797
|
+
* @param {any} error
|
|
798
|
+
* @returns {Promise<never>}
|
|
799
|
+
*/
|
|
800
|
+
const failClosed = (error) => {
|
|
801
|
+
if (closed) return Promise.reject(error);
|
|
802
|
+
closed = true;
|
|
803
|
+
/** @param {any} closeError */
|
|
804
|
+
const both = (closeError) => Promise.reject(new AggregateError([error, closeError],
|
|
805
|
+
'the store failed to open, and closing the acquired connection failed too'));
|
|
806
|
+
let closing;
|
|
807
|
+
try {
|
|
808
|
+
closing = opened.close();
|
|
809
|
+
}
|
|
810
|
+
catch (closeError) {
|
|
811
|
+
return both(closeError);
|
|
812
|
+
}
|
|
813
|
+
return isThenable(closing)
|
|
814
|
+
? closing.then(() => Promise.reject(error), both)
|
|
815
|
+
: Promise.reject(error);
|
|
816
|
+
};
|
|
817
|
+
const opening = () => chain(pragmas, () =>
|
|
818
|
+
chain(ensureShape(connection, collections, plans, readOnly), () =>
|
|
819
|
+
chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
|
|
820
|
+
/** @type {Map<string, any>} */
|
|
821
|
+
const cores = new Map();
|
|
822
|
+
const coreFor = (name) => {
|
|
823
|
+
let core = cores.get(name);
|
|
824
|
+
if (core === undefined) {
|
|
825
|
+
const collection = collections.get(name);
|
|
826
|
+
if (collection === undefined) {
|
|
827
|
+
throw new DbRuntimeError('JD2004',
|
|
828
|
+
`the model declares no collection '${name}'`,
|
|
829
|
+
{ docPath: '/collections', collection: name });
|
|
830
|
+
}
|
|
831
|
+
const validate = options.compileSchema !== undefined
|
|
832
|
+
? options.compileSchema(collection.schema)
|
|
833
|
+
: null;
|
|
834
|
+
if (validate !== null && typeof validate !== 'function')
|
|
835
|
+
throw new TypeError('openStore: compileSchema must return a validation function');
|
|
836
|
+
core = captureCollection(name, collectionCore(connection, collection,
|
|
837
|
+
plans.get(name), validate, queryState,
|
|
838
|
+
{ profile: storeProfile }));
|
|
839
|
+
cores.set(name, core);
|
|
840
|
+
}
|
|
841
|
+
return core;
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// ————— change capture (LIVE-FORMAT §§1–6) —————
|
|
845
|
+
const captureRequested = options.capture === true
|
|
846
|
+
? {}
|
|
847
|
+
: (options.capture === undefined || options.capture === false
|
|
848
|
+
? null : options.capture);
|
|
849
|
+
let captureMode = 'none';
|
|
850
|
+
if (captureRequested !== null) {
|
|
851
|
+
const wanted = captureRequested.mode ?? 'auto';
|
|
852
|
+
const hasSessions = connection.capabilities.sessions === true
|
|
853
|
+
&& typeof connection.session === 'function';
|
|
854
|
+
if (wanted === 'session' && !hasSessions) {
|
|
855
|
+
throw new TypeError(
|
|
856
|
+
"capture mode 'session' is unavailable on this driver "
|
|
857
|
+
+ '(bun:sqlite and some wasm builds ship no session extension) — '
|
|
858
|
+
+ "use mode 'journal' or 'auto'");
|
|
859
|
+
}
|
|
860
|
+
captureMode = wanted === 'auto'
|
|
861
|
+
? (hasSessions ? 'session' : 'journal')
|
|
862
|
+
: wanted;
|
|
863
|
+
}
|
|
864
|
+
const captureShapes = new Map();
|
|
865
|
+
if (captureMode !== 'none') {
|
|
866
|
+
for (const [collectionName, plan] of plans) {
|
|
867
|
+
captureShapes.set(plan.table ?? collectionName, {
|
|
868
|
+
kind: 'collection',
|
|
869
|
+
columns: [
|
|
870
|
+
{ name: plan.keyColumn, role: 'key' },
|
|
871
|
+
{ name: plan.docColumn, role: 'doc' },
|
|
872
|
+
],
|
|
873
|
+
keyIndexes: [0],
|
|
874
|
+
docIndex: 1,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
for (const [entityName, entity] of (mapping === null ? [] : entities)) {
|
|
878
|
+
const em = mapping.entities[entityName];
|
|
879
|
+
const fkNames = new Set(em.foreignKeys.map((fk) => fk.column));
|
|
880
|
+
const columns = [];
|
|
881
|
+
for (const column of em.columns) {
|
|
882
|
+
if (fkNames.has(column.name)) continue;
|
|
883
|
+
columns.push({
|
|
884
|
+
name: column.name,
|
|
885
|
+
role: column.key ? 'key'
|
|
886
|
+
: column.source === 'epoch(document)' ? 'epoch' : 'scalar',
|
|
887
|
+
storage: column.storage,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
for (const fk of em.foreignKeys)
|
|
891
|
+
columns.push({ name: fk.column, role: 'fk' });
|
|
892
|
+
columns.push({ name: 'doc', role: 'doc' });
|
|
893
|
+
captureShapes.set(entityName, {
|
|
894
|
+
kind: 'entity',
|
|
895
|
+
columns,
|
|
896
|
+
keyIndexes: em.keys.map((key) =>
|
|
897
|
+
columns.findIndex((column) => column.name === key)),
|
|
898
|
+
docIndex: columns.length - 1,
|
|
899
|
+
relationNames: [...entity.properties.values()]
|
|
900
|
+
.filter((property) => property.relation !== undefined)
|
|
901
|
+
.map((property) => property.name),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
for (const joinName of Object.keys(mapping?.joinTables ?? {})) {
|
|
905
|
+
const pair = joinName.split('_');
|
|
906
|
+
const columns = pair.map((part) => ({ name: `${part}_key`, role: 'key' }));
|
|
907
|
+
captureShapes.set(joinName, {
|
|
908
|
+
kind: 'join', columns,
|
|
909
|
+
keyIndexes: columns.map((_, i) => i), docIndex: -1,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const capture = captureMode === 'none' ? null : createCaptureEngine({
|
|
914
|
+
connection,
|
|
915
|
+
shapes: captureShapes,
|
|
916
|
+
mode: captureMode,
|
|
917
|
+
log: captureRequested.log === true
|
|
918
|
+
|| (captureRequested.log !== undefined && captureRequested.log !== false),
|
|
919
|
+
retention: captureRequested.log?.retention ?? DEFAULT_RETENTION,
|
|
920
|
+
});
|
|
921
|
+
const guard = capture === null ? (fn) => fn() : capture.wrap;
|
|
922
|
+
if (capture !== null) {
|
|
923
|
+
topLevelTransaction = (fn) => withScope(opened.transaction,
|
|
924
|
+
(tx) => capture.nest(() => fn(tx)));
|
|
925
|
+
}
|
|
926
|
+
// the live registry rides the capture stream; its dispatcher
|
|
927
|
+
// registers FIRST so maintenance sees every record before any
|
|
928
|
+
// user observer can commit a further write (LIVE-FORMAT §8)
|
|
929
|
+
const liveRegistry = capture === null ? null : createLiveRegistry({
|
|
930
|
+
maxQueries: options.live?.maxQueries ?? LIVE_DEFAULTS.maxQueries,
|
|
931
|
+
maxMaintained: options.live?.maxMaintained ?? LIVE_DEFAULTS.maxMaintained,
|
|
932
|
+
});
|
|
933
|
+
if (capture !== null) {
|
|
934
|
+
capture.observe((record) => /** @type {any} */ (liveRegistry).deliver(record));
|
|
935
|
+
}
|
|
936
|
+
// the durable job queue (JOBS-FORMAT), opt-in per store
|
|
937
|
+
const jobsRequested = options.jobs === true
|
|
938
|
+
|| (options.jobs !== undefined && options.jobs !== false);
|
|
939
|
+
const jobsEngine = !jobsRequested ? null : createJobEngine({
|
|
940
|
+
connection,
|
|
941
|
+
now: typeof options.jobs === 'object' ? options.jobs.now : undefined,
|
|
942
|
+
random: typeof options.jobs === 'object' ? options.jobs.random : undefined,
|
|
943
|
+
defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
|
|
944
|
+
});
|
|
945
|
+
/** Register a collection live query (LIVE-FORMAT §7). */
|
|
946
|
+
const registerCollectionLive = (core, document, liveOptions) => {
|
|
947
|
+
const externals = liveOptions?.externals ?? {};
|
|
948
|
+
const keyed = core.model.keySegments !== null;
|
|
949
|
+
const classification = liveOptions?.mode === 'rerun'
|
|
950
|
+
? { strategy: 'rerun', reason: 'rerun was requested' }
|
|
951
|
+
: classifyLiveQuery(document, core.queryShape, keyed);
|
|
952
|
+
return /** @type {any} */ (liveRegistry).register({
|
|
953
|
+
name: core.model.name,
|
|
954
|
+
tables: new Set([core.model.name]),
|
|
955
|
+
document,
|
|
956
|
+
externals,
|
|
957
|
+
demanded: liveOptions?.mode,
|
|
958
|
+
classification,
|
|
959
|
+
execute: (doc, executeOptions) => core.execute(doc, executeOptions),
|
|
960
|
+
readRow: (token) => core.get(token),
|
|
961
|
+
keyOf: (doc) => String(extractKey(doc, core.model.keySegments,
|
|
962
|
+
core.model.key, core.model.name, core.model.docPath)),
|
|
963
|
+
});
|
|
964
|
+
};
|
|
965
|
+
/** Strip relation members before journal diffs — sessions
|
|
966
|
+
* never see them (they are not stored), so the two modes
|
|
967
|
+
* stay identical. */
|
|
968
|
+
const stripRelations = (entityName, doc) => {
|
|
969
|
+
if (doc === null || doc === undefined) return doc;
|
|
970
|
+
const names = captureShapes.get(entityName)?.relationNames;
|
|
971
|
+
if (names === undefined || names.length === 0) return doc;
|
|
972
|
+
const out = { ...doc };
|
|
973
|
+
for (const name of names) delete out[name];
|
|
974
|
+
return out;
|
|
975
|
+
};
|
|
976
|
+
/** Journal mode cannot see ON DELETE CASCADE side effects;
|
|
977
|
+
* the ONE store-shaped case — join-table membership dying
|
|
978
|
+
* with its entity — is read and recorded before the delete.
|
|
979
|
+
* Deeper cascades (child rows) stay documented-invisible. */
|
|
980
|
+
const captureJoinDelete = capture === null || capture.mode !== 'journal'
|
|
981
|
+
? null
|
|
982
|
+
: (entityName, keyParts) => {
|
|
983
|
+
const joins = Object.keys(mapping?.joinTables ?? {})
|
|
984
|
+
.filter((joinName) => joinName.split('_').includes(entityName));
|
|
985
|
+
const nextJoin = (i) => {
|
|
986
|
+
if (i >= joins.length) return null;
|
|
987
|
+
const joinName = joins[i];
|
|
988
|
+
const pair = joinName.split('_');
|
|
989
|
+
const sql = `SELECT ${pair.map((part) => dialect.quoteIdentifier(`${part}_key`)).join(', ')} `
|
|
990
|
+
+ `FROM ${dialect.quoteIdentifier(joinName)} `
|
|
991
|
+
+ `WHERE ${dialect.quoteIdentifier(`${entityName}_key`)} = ${dialect.parameterRef(1, 'v')}`;
|
|
992
|
+
return chain(connection.prepare(sql), (statement) =>
|
|
993
|
+
chain(statement.all([keyParts[0]]), (rows) => {
|
|
994
|
+
for (const row of rows) {
|
|
995
|
+
capture.record(joinName,
|
|
996
|
+
pair.map((part) => row[`${part}_key`]), undefined, null);
|
|
997
|
+
}
|
|
998
|
+
return nextJoin(i + 1);
|
|
999
|
+
}));
|
|
1000
|
+
};
|
|
1001
|
+
return nextJoin(0);
|
|
1002
|
+
};
|
|
1003
|
+
/** Journal-mode write wrappers for a collection core. */
|
|
1004
|
+
const captureCollection = (collectionName, core) => {
|
|
1005
|
+
if (capture === null) return core;
|
|
1006
|
+
const journal = capture.mode === 'journal';
|
|
1007
|
+
return {
|
|
1008
|
+
...core,
|
|
1009
|
+
insert: (doc) => guard(() => chain(core.insert(doc), (key) => {
|
|
1010
|
+
if (journal) capture.record(collectionName, [key], null, doc);
|
|
1011
|
+
return key;
|
|
1012
|
+
})),
|
|
1013
|
+
put: (doc, key) => guard(() => (journal
|
|
1014
|
+
? chain(key === undefined ? undefined : core.get(key), (before) =>
|
|
1015
|
+
chain(core.put(doc, key), (storedKey) => {
|
|
1016
|
+
capture.record(collectionName, [storedKey], before ?? null, doc);
|
|
1017
|
+
return storedKey;
|
|
1018
|
+
}))
|
|
1019
|
+
: core.put(doc, key))),
|
|
1020
|
+
patch: (key, ops) => guard(() => (journal
|
|
1021
|
+
? chain(core.get(key), (before) =>
|
|
1022
|
+
chain(core.patch(key, ops), (after) => {
|
|
1023
|
+
capture.record(collectionName, [key], before ?? null, after);
|
|
1024
|
+
return after;
|
|
1025
|
+
}))
|
|
1026
|
+
: core.patch(key, ops))),
|
|
1027
|
+
delete: (key) => guard(() => (journal
|
|
1028
|
+
? chain(core.get(key), (before) =>
|
|
1029
|
+
chain(core.delete(key), (deleted) => {
|
|
1030
|
+
if (deleted && before !== undefined)
|
|
1031
|
+
capture.record(collectionName, [key], before, null);
|
|
1032
|
+
return deleted;
|
|
1033
|
+
}))
|
|
1034
|
+
: core.delete(key))),
|
|
1035
|
+
};
|
|
1036
|
+
};
|
|
1037
|
+
/** Journal-mode write wrappers for an entity core. */
|
|
1038
|
+
const captureEntity = (entityName, core) => {
|
|
1039
|
+
if (capture === null) return core;
|
|
1040
|
+
const journal = capture.mode === 'journal';
|
|
1041
|
+
const keysOf = (doc) => core.plan.keys.map((key) => doc[key]);
|
|
1042
|
+
return {
|
|
1043
|
+
...core,
|
|
1044
|
+
create: (doc) => guard(() => chain(core.create(doc), (made) => {
|
|
1045
|
+
if (journal) {
|
|
1046
|
+
capture.record(entityName, keysOf(made), null,
|
|
1047
|
+
stripRelations(entityName, made));
|
|
1048
|
+
}
|
|
1049
|
+
return made;
|
|
1050
|
+
})),
|
|
1051
|
+
update: (key, changes) => guard(() => (journal
|
|
1052
|
+
? chain(core.get(key), (before) =>
|
|
1053
|
+
chain(core.update(key, changes), (next) => {
|
|
1054
|
+
capture.record(entityName, keysOf(next),
|
|
1055
|
+
stripRelations(entityName, before ?? null),
|
|
1056
|
+
stripRelations(entityName, next));
|
|
1057
|
+
return next;
|
|
1058
|
+
}))
|
|
1059
|
+
: core.update(key, changes))),
|
|
1060
|
+
delete: (key) => guard(() => (journal
|
|
1061
|
+
? chain(captureJoinDelete(entityName, core.normalizeKey(key)), () =>
|
|
1062
|
+
chain(core.get(key), (before) =>
|
|
1063
|
+
chain(core.delete(key), (deleted) => {
|
|
1064
|
+
if (deleted && before !== undefined) {
|
|
1065
|
+
capture.record(entityName, core.normalizeKey(key),
|
|
1066
|
+
stripRelations(entityName, before), null);
|
|
1067
|
+
}
|
|
1068
|
+
return deleted;
|
|
1069
|
+
})))
|
|
1070
|
+
: core.delete(key))),
|
|
1071
|
+
};
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
const capabilities = Object.freeze({
|
|
1075
|
+
...connection.capabilities,
|
|
1076
|
+
validated: options.compileSchema !== undefined,
|
|
1077
|
+
busyTimeoutMs: memory ? null : busyTimeout,
|
|
1078
|
+
journalMode: memory || readOnly ? null : journalMode,
|
|
1079
|
+
readOnly,
|
|
1080
|
+
profiled: storeProfile !== null,
|
|
1081
|
+
// the registered operator vocabulary (Ring 2): the names a
|
|
1082
|
+
// query may use; they run in the residual by default
|
|
1083
|
+
operators: operators === null
|
|
1084
|
+
? Object.freeze([])
|
|
1085
|
+
: Object.freeze(Object.keys(operators.extensions)),
|
|
1086
|
+
// the subset this driver actually pushes into SQL as
|
|
1087
|
+
// deterministic UDFs (Ring 3): `pushable:'scalar'` operators
|
|
1088
|
+
// when the driver has user functions; `[]` on bun:sqlite
|
|
1089
|
+
// (no UDF API — always the residual) and without a registry
|
|
1090
|
+
pushableOperators: operators === null || connection.capabilities.userFunctions !== true
|
|
1091
|
+
? Object.freeze([])
|
|
1092
|
+
: Object.freeze([...operators.pushableScalar]),
|
|
1093
|
+
capture: captureMode,
|
|
1094
|
+
captureLog: captureMode !== 'none'
|
|
1095
|
+
&& (captureRequested.log === true
|
|
1096
|
+
|| (captureRequested.log !== undefined && captureRequested.log !== false)),
|
|
1097
|
+
live: captureMode !== 'none',
|
|
1098
|
+
jobs: options.jobs === true
|
|
1099
|
+
|| (options.jobs !== undefined && options.jobs !== false),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
const queryState = createQueryState(options.statementCacheBound, operators);
|
|
1103
|
+
const entityEngine = entities.size > 0
|
|
1104
|
+
? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
|
|
1105
|
+
: null;
|
|
1106
|
+
/** @type {Map<string, any>} */
|
|
1107
|
+
const loadEngines = new Map();
|
|
1108
|
+
const loadEngineFor = (name) => {
|
|
1109
|
+
let engine = loadEngines.get(name);
|
|
1110
|
+
if (engine === undefined) {
|
|
1111
|
+
if (!entities.has(name)) {
|
|
1112
|
+
throw new DbRuntimeError('JD2004',
|
|
1113
|
+
`the model declares no entity '${name}'`,
|
|
1114
|
+
{ docPath: '/entities', collection: name });
|
|
1115
|
+
}
|
|
1116
|
+
engine = createLoadEngine(
|
|
1117
|
+
{ connection, entities, mapping, state: queryState }, name);
|
|
1118
|
+
loadEngines.set(name, engine);
|
|
1119
|
+
}
|
|
1120
|
+
return engine;
|
|
1121
|
+
};
|
|
1122
|
+
/** @type {Map<string, any>} */
|
|
1123
|
+
const entityCores = new Map();
|
|
1124
|
+
const entityCoreFor = (name) => {
|
|
1125
|
+
let core = entityCores.get(name);
|
|
1126
|
+
if (core === undefined) {
|
|
1127
|
+
const entity = entities.get(name);
|
|
1128
|
+
if (entity === undefined) {
|
|
1129
|
+
throw new DbRuntimeError('JD2004',
|
|
1130
|
+
`the model declares no entity '${name}'`,
|
|
1131
|
+
{ docPath: '/entities', collection: name });
|
|
1132
|
+
}
|
|
1133
|
+
const validate = options.compileSchema !== undefined
|
|
1134
|
+
? options.compileSchema(entity.schema)
|
|
1135
|
+
: null;
|
|
1136
|
+
core = captureEntity(name, entityCore(connection, entity,
|
|
1137
|
+
mapping.entities[name], validate));
|
|
1138
|
+
entityCores.set(name, core);
|
|
1139
|
+
}
|
|
1140
|
+
return core;
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const tracker = entities.size > 0
|
|
1144
|
+
? createTracker({
|
|
1145
|
+
connection, entities, mapping, coreFor: entityCoreFor,
|
|
1146
|
+
captureRecord: capture === null || capture.mode !== 'journal'
|
|
1147
|
+
? undefined
|
|
1148
|
+
: (table, keyParts, before, after) => capture.record(table, keyParts,
|
|
1149
|
+
before === undefined ? undefined : stripRelations(table, before),
|
|
1150
|
+
stripRelations(table, after)),
|
|
1151
|
+
captureJoinDelete: captureJoinDelete ?? undefined,
|
|
1152
|
+
})
|
|
1153
|
+
: null;
|
|
1154
|
+
/** @type {Map<string, any>} */
|
|
1155
|
+
const trackedOps = new Map();
|
|
1156
|
+
// the unit-of-work surface (§11): reads register frozen
|
|
1157
|
+
// snapshots; add/put/remove are LOCAL bookkeeping (no
|
|
1158
|
+
// database round trip, deliberately synchronous on both
|
|
1159
|
+
// surfaces); asNoTracking() reads retain nothing
|
|
1160
|
+
const trackedOpsFor = (name) => {
|
|
1161
|
+
let ops = trackedOps.get(name);
|
|
1162
|
+
if (ops !== undefined) return ops;
|
|
1163
|
+
const core = entityCoreFor(name);
|
|
1164
|
+
const loads = loadEngineFor(name);
|
|
1165
|
+
ops = {
|
|
1166
|
+
create: (doc) => chain(core.create(doc),
|
|
1167
|
+
(made) => tracker.register(name, made)),
|
|
1168
|
+
get: (key) => chain(core.get(key), (doc) =>
|
|
1169
|
+
(doc === undefined ? undefined : tracker.register(name, doc))),
|
|
1170
|
+
update: (key, changes) => chain(core.update(key, changes),
|
|
1171
|
+
(next) => tracker.register(name, next)),
|
|
1172
|
+
delete: (key) => chain(core.delete(key), (done) => {
|
|
1173
|
+
tracker.discard(name, key);
|
|
1174
|
+
return done;
|
|
1175
|
+
}),
|
|
1176
|
+
load: (spec) => chain(loads.load(spec),
|
|
1177
|
+
(docs) => tracker.registerGraph(loads.treeFor(spec), docs)),
|
|
1178
|
+
explainLoad: (spec) => loads.explainLoad(spec),
|
|
1179
|
+
add: (doc) => tracker.add(name, doc),
|
|
1180
|
+
put: (next) => tracker.put(name, next),
|
|
1181
|
+
remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
|
|
1182
|
+
discard: (keyOrDoc) => tracker.discard(name, keyOrDoc),
|
|
1183
|
+
noTracking: {
|
|
1184
|
+
get: (key) => core.get(key),
|
|
1185
|
+
load: (spec) => loads.load(spec),
|
|
1186
|
+
},
|
|
1187
|
+
};
|
|
1188
|
+
trackedOps.set(name, ops);
|
|
1189
|
+
return ops;
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
/** @type {Map<string, any>} */
|
|
1193
|
+
const asyncHandles = new Map();
|
|
1194
|
+
/** @type {Map<string, any>} */
|
|
1195
|
+
const asyncEntityHandles = new Map();
|
|
1196
|
+
const store = {
|
|
1197
|
+
capabilities,
|
|
1198
|
+
stats: () => ({
|
|
1199
|
+
statementCache: { ...queryState.counters },
|
|
1200
|
+
udfRegistrations: queryState.registered.size,
|
|
1201
|
+
tracker: tracker === null ? null : tracker.counts(),
|
|
1202
|
+
liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
|
|
1203
|
+
}),
|
|
1204
|
+
dialect,
|
|
1205
|
+
collection(name) {
|
|
1206
|
+
let handle = asyncHandles.get(name);
|
|
1207
|
+
if (handle === undefined) {
|
|
1208
|
+
handle = asyncCollection(coreFor(name),
|
|
1209
|
+
liveRegistry === null ? null : registerCollectionLive);
|
|
1210
|
+
asyncHandles.set(name, handle);
|
|
1211
|
+
}
|
|
1212
|
+
return handle;
|
|
1213
|
+
},
|
|
1214
|
+
entity(name) {
|
|
1215
|
+
let handle = asyncEntityHandles.get(name);
|
|
1216
|
+
if (handle === undefined) {
|
|
1217
|
+
const ops = trackedOpsFor(name);
|
|
1218
|
+
const untracked = Object.freeze({
|
|
1219
|
+
get: lift((key) => ops.noTracking.get(key)),
|
|
1220
|
+
load: lift((spec) => ops.noTracking.load(spec)),
|
|
1221
|
+
});
|
|
1222
|
+
handle = Object.freeze({
|
|
1223
|
+
create: lift((doc) => ops.create(doc)),
|
|
1224
|
+
get: lift((key) => ops.get(key)),
|
|
1225
|
+
update: lift((key, changes) => ops.update(key, changes)),
|
|
1226
|
+
delete: lift((key) => ops.delete(key)),
|
|
1227
|
+
load: lift((spec) => ops.load(spec)),
|
|
1228
|
+
explainLoad: ops.explainLoad,
|
|
1229
|
+
add: ops.add,
|
|
1230
|
+
put: ops.put,
|
|
1231
|
+
remove: ops.remove,
|
|
1232
|
+
discard: ops.discard,
|
|
1233
|
+
asNoTracking: () => untracked,
|
|
1234
|
+
});
|
|
1235
|
+
asyncEntityHandles.set(name, handle);
|
|
1236
|
+
}
|
|
1237
|
+
return handle;
|
|
1238
|
+
},
|
|
1239
|
+
saveChanges: entities.size === 0 ? undefined
|
|
1240
|
+
: lift(() => guard(() => tracker.saveChanges())),
|
|
1241
|
+
// entity DOCUMENTS query the multi-entity root at the store
|
|
1242
|
+
execute: entityEngine === null ? undefined
|
|
1243
|
+
: (document, queryOptions) => entityEngine.execute(document, queryOptions),
|
|
1244
|
+
explain: entityEngine === null ? undefined
|
|
1245
|
+
: lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
|
|
1246
|
+
// entity live queries re-run on invalidation — declared,
|
|
1247
|
+
// not attempted (LIVE-FORMAT §7)
|
|
1248
|
+
live: entityEngine === null ? undefined
|
|
1249
|
+
: lift((document, liveOptions) => {
|
|
1250
|
+
if (liveRegistry === null) {
|
|
1251
|
+
throw new DbCompileError('JD0050',
|
|
1252
|
+
'live queries require change capture — open the store with { capture: true }');
|
|
1253
|
+
}
|
|
1254
|
+
const roots = collectEntityRoots(document, entities);
|
|
1255
|
+
if (roots.size === 0) {
|
|
1256
|
+
throw new TypeError(
|
|
1257
|
+
'store.live takes an entity-root document — for a collection, '
|
|
1258
|
+
+ 'use store.collection(name).live');
|
|
1259
|
+
}
|
|
1260
|
+
return liveRegistry.register({
|
|
1261
|
+
name: [...roots].join('+'),
|
|
1262
|
+
tables: roots,
|
|
1263
|
+
document,
|
|
1264
|
+
externals: liveOptions?.externals ?? {},
|
|
1265
|
+
demanded: liveOptions?.mode,
|
|
1266
|
+
classification: {
|
|
1267
|
+
strategy: 'rerun',
|
|
1268
|
+
reason: 'entity queries re-run in this version',
|
|
1269
|
+
},
|
|
1270
|
+
execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
|
|
1271
|
+
readRow: null,
|
|
1272
|
+
keyOf: null,
|
|
1273
|
+
});
|
|
1274
|
+
}),
|
|
1275
|
+
// A TOP-LEVEL transaction: it takes the connection's gate, so
|
|
1276
|
+
// it never shares a savepoint stack with another one. To nest,
|
|
1277
|
+
// use the store the callback RECEIVES — the outer store cannot
|
|
1278
|
+
// tell an inner transaction from an unrelated caller, and an
|
|
1279
|
+
// unrelated caller must wait for the commit.
|
|
1280
|
+
transaction: lift((fn) => topLevelTransaction(fn)),
|
|
1281
|
+
observe: (fn) => {
|
|
1282
|
+
if (capture === null) {
|
|
1283
|
+
throw new TypeError(
|
|
1284
|
+
'observe needs capture — open the store with { capture: true }');
|
|
1285
|
+
}
|
|
1286
|
+
return capture.observe(fn);
|
|
1287
|
+
},
|
|
1288
|
+
changesSince: capture === null ? undefined
|
|
1289
|
+
: lift((after) => capture.changesSince(after)),
|
|
1290
|
+
dataVersion: lift(() => chain(
|
|
1291
|
+
connection.prepare(dialect.introspect.dataVersion()),
|
|
1292
|
+
(statement) => chain(statement.get([]), (row) => Number(row.v)))),
|
|
1293
|
+
jobs: jobsEngine === null ? undefined : Object.freeze({
|
|
1294
|
+
enqueue: lift(jobsEngine.enqueue),
|
|
1295
|
+
get: lift(jobsEngine.get),
|
|
1296
|
+
counts: lift(jobsEngine.counts),
|
|
1297
|
+
claim: lift(jobsEngine.claim),
|
|
1298
|
+
complete: lift(jobsEngine.complete),
|
|
1299
|
+
fail: lift(jobsEngine.fail),
|
|
1300
|
+
checkpointsFor: jobsEngine.checkpointsFor,
|
|
1301
|
+
createWorker: jobsEngine.createWorker,
|
|
1302
|
+
}),
|
|
1303
|
+
/**
|
|
1304
|
+
* Close the store. Job workers are asked to stop and given a
|
|
1305
|
+
* bounded grace period; the connection is then closed WHETHER
|
|
1306
|
+
* OR NOT a handler wound up. That bound is the point: an
|
|
1307
|
+
* unbounded wait let one handler that never settles hold the
|
|
1308
|
+
* database file open for the life of the process, which is how
|
|
1309
|
+
* an abandoned worker in the suite left a locked file behind.
|
|
1310
|
+
* @param {{ graceMs?: number }} [closeOptions]
|
|
1311
|
+
*/
|
|
1312
|
+
close: lift((closeOptions) => {
|
|
1313
|
+
if (liveRegistry !== null) liveRegistry.closeAll();
|
|
1314
|
+
return chain(
|
|
1315
|
+
jobsEngine === null ? null : jobsEngine.stopAll(closeOptions),
|
|
1316
|
+
(stopped) => chain(connection.close(), () => {
|
|
1317
|
+
const stuck = (stopped ?? []).filter(
|
|
1318
|
+
(/** @type {any} */ outcome) => outcome.drained === false);
|
|
1319
|
+
if (stuck.length === 0) return undefined;
|
|
1320
|
+
// reported, not swallowed: the handle is released, but
|
|
1321
|
+
// handlers are still running against a closed connection
|
|
1322
|
+
throw new DbRuntimeError('JD2062',
|
|
1323
|
+
`the store closed with ${stuck.reduce(
|
|
1324
|
+
(/** @type {number} */ n, /** @type {any} */ o) => n + o.inFlight, 0)} `
|
|
1325
|
+
+ `job handler(s) still in flight across ${stuck.length} worker(s); `
|
|
1326
|
+
+ 'they were signalled to abort and did not settle within the grace period');
|
|
1327
|
+
}));
|
|
1328
|
+
}),
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
// The transaction callback's argument. It is the store, with one
|
|
1332
|
+
// difference that matters: its `transaction` NESTS through the
|
|
1333
|
+
// owning savepoint instead of queueing behind it. Everything
|
|
1334
|
+
// else already reaches the open transaction, because the cores
|
|
1335
|
+
// read the active scope.
|
|
1336
|
+
/** @type {any} */
|
|
1337
|
+
let txStore = null;
|
|
1338
|
+
/** Nest through the savepoint that owns the connection now. The
|
|
1339
|
+
* capture scope goes INSIDE the savepoint, so a rollback undoes
|
|
1340
|
+
* the translated patch with the rows it describes. */
|
|
1341
|
+
const nested = (/** @type {any} */ fn) => withScope(scope.transaction,
|
|
1342
|
+
(tx) => (capture === null ? fn(tx) : capture.nest(() => fn(tx))));
|
|
1343
|
+
/** The overriding member on a view of the FROZEN store: plain
|
|
1344
|
+
* assignment cannot shadow a non-writable inherited property. */
|
|
1345
|
+
const override = (/** @type {any} */ value) =>
|
|
1346
|
+
({ value, writable: false, enumerable: true, configurable: false });
|
|
1347
|
+
scopedStore = () => {
|
|
1348
|
+
if (txStore === null) {
|
|
1349
|
+
const members = { transaction: override(nested) };
|
|
1350
|
+
if (store.sync !== undefined) {
|
|
1351
|
+
members.sync = override(Object.create(store.sync,
|
|
1352
|
+
{ transaction: override(nested) }));
|
|
1353
|
+
}
|
|
1354
|
+
txStore = Object.freeze(Object.create(store, members));
|
|
1355
|
+
}
|
|
1356
|
+
return txStore;
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
if (connection.synchronous) {
|
|
1360
|
+
/** @type {Map<string, any>} */
|
|
1361
|
+
const syncHandles = new Map();
|
|
1362
|
+
store.sync = Object.freeze({
|
|
1363
|
+
collection(name) {
|
|
1364
|
+
let handle = syncHandles.get(name);
|
|
1365
|
+
if (handle === undefined) {
|
|
1366
|
+
const core = coreFor(name);
|
|
1367
|
+
handle = Object.freeze({
|
|
1368
|
+
stats: () => core.stats(),
|
|
1369
|
+
get: (key) => core.get(key),
|
|
1370
|
+
insert: (doc) => core.insert(doc),
|
|
1371
|
+
put: (doc, key) => core.put(doc, key),
|
|
1372
|
+
patch: (key, ops) => core.patch(key, ops),
|
|
1373
|
+
delete: (key) => core.delete(key),
|
|
1374
|
+
execute: (document, options) => core.execute(document, options),
|
|
1375
|
+
explain: (document, options) => core.explain(document, options),
|
|
1376
|
+
});
|
|
1377
|
+
syncHandles.set(name, handle);
|
|
1378
|
+
}
|
|
1379
|
+
return handle;
|
|
1380
|
+
},
|
|
1381
|
+
transaction: (fn) => topLevelTransaction(fn),
|
|
1382
|
+
entity(name) {
|
|
1383
|
+
const ops = trackedOpsFor(name);
|
|
1384
|
+
const untracked = Object.freeze({
|
|
1385
|
+
get: (key) => ops.noTracking.get(key),
|
|
1386
|
+
load: (spec) => ops.noTracking.load(spec),
|
|
1387
|
+
});
|
|
1388
|
+
return Object.freeze({
|
|
1389
|
+
create: (doc) => ops.create(doc),
|
|
1390
|
+
get: (key) => ops.get(key),
|
|
1391
|
+
update: (key, changes) => ops.update(key, changes),
|
|
1392
|
+
delete: (key) => ops.delete(key),
|
|
1393
|
+
load: (spec) => ops.load(spec),
|
|
1394
|
+
explainLoad: ops.explainLoad,
|
|
1395
|
+
add: ops.add,
|
|
1396
|
+
put: ops.put,
|
|
1397
|
+
remove: ops.remove,
|
|
1398
|
+
discard: ops.discard,
|
|
1399
|
+
asNoTracking: () => untracked,
|
|
1400
|
+
});
|
|
1401
|
+
},
|
|
1402
|
+
saveChanges: entities.size === 0 ? undefined
|
|
1403
|
+
: () => guard(() => tracker.saveChanges()),
|
|
1404
|
+
execute: entityEngine === null ? undefined
|
|
1405
|
+
: (document, queryOptions) => entityEngine.execute(document, queryOptions),
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
return chain(capture === null ? null : capture.ready,
|
|
1409
|
+
() => chain(jobsEngine === null ? null : jobsEngine.ready,
|
|
1410
|
+
() => Object.freeze(store)));
|
|
1411
|
+
})));
|
|
1412
|
+
|
|
1413
|
+
let opened_;
|
|
1414
|
+
try {
|
|
1415
|
+
opened_ = opening();
|
|
1416
|
+
}
|
|
1417
|
+
catch (error) {
|
|
1418
|
+
return failClosed(error);
|
|
1419
|
+
}
|
|
1420
|
+
return isThenable(opened_) ? opened_.then((value) => value, failClosed) : opened_;
|
|
1421
|
+
}));
|
|
1422
|
+
}
|