@jarenjs/db 0.56.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/src/pragmas.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The closed set of connection pragmas a store configures, in
|
|
4
|
+
* ONE place: each entry names the `openStore` option, the SQL pragma it
|
|
5
|
+
* spells, the validator its value must pass before anything reaches
|
|
6
|
+
* SQL, and how the engine's read-back answer is compared with the
|
|
7
|
+
* request and reported.
|
|
8
|
+
*
|
|
9
|
+
* Three rules, each the answer to a way a configured store can lie:
|
|
10
|
+
*
|
|
11
|
+
* - **the set is closed.** An option that names a pragma outside it is
|
|
12
|
+
* a coded refusal (`JD0006`) naming the option, never a member that
|
|
13
|
+
* is accepted and quietly not applied — SQLite itself accepts
|
|
14
|
+
* `PRAGMA synchronous = bogus` without a word and leaves the old
|
|
15
|
+
* value in place, which is exactly why a value is validated here and
|
|
16
|
+
* only a member of a closed word set or a checked integer is spelled;
|
|
17
|
+
* - **a driver applies what it declares.** A binding's capability
|
|
18
|
+
* table lists the pragmas it can apply; a request the binding does
|
|
19
|
+
* not declare is refused (`JD0007`), as is a journal-mode write on a
|
|
20
|
+
* read-only connection, which the engine answers with an I/O error;
|
|
21
|
+
* - **setting is not applying.** Every pragma is read back after the
|
|
22
|
+
* open sequence and the read value is what the capability report
|
|
23
|
+
* carries; a requested value the engine did not take is a refusal
|
|
24
|
+
* (`JD0008`), because a store that believes a configuration it does
|
|
25
|
+
* not have is worse than one that failed to open.
|
|
26
|
+
*
|
|
27
|
+
* `foreign_keys` is deliberately NOT here: the model requires it ON and
|
|
28
|
+
* the open path verifies it per connection (`JD0003` when it stays
|
|
29
|
+
* off). It is an invariant, not a preference.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { DbCompileError } from './errors.js';
|
|
33
|
+
import { chain } from './driver.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A closed word set with the engine's numeric spelling beside each
|
|
37
|
+
* word, for the pragmas whose read-back answer is a number.
|
|
38
|
+
* @param {string[]} words - in the engine's numeric order
|
|
39
|
+
* @returns {{ words: readonly string[],
|
|
40
|
+
* normalize: (value: any, option: string) => string,
|
|
41
|
+
* read: (value: any) => string | null }}
|
|
42
|
+
*/
|
|
43
|
+
function wordSet(words) {
|
|
44
|
+
const frozen = Object.freeze([...words]);
|
|
45
|
+
return {
|
|
46
|
+
words: frozen,
|
|
47
|
+
normalize: (value, option) => {
|
|
48
|
+
const word = typeof value === 'string' ? value.toLowerCase() : null;
|
|
49
|
+
if (word === null || !frozen.includes(word)) {
|
|
50
|
+
throw new TypeError(`openStore: ${option} is one of ${
|
|
51
|
+
frozen.map((w) => `'${w}'`).join(', ')}, got ${JSON.stringify(value)}`);
|
|
52
|
+
}
|
|
53
|
+
return word;
|
|
54
|
+
},
|
|
55
|
+
// the engine answers the word for journal_mode and the number for
|
|
56
|
+
// synchronous/temp_store; both are read back to the word
|
|
57
|
+
read: (value) => {
|
|
58
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < frozen.length)
|
|
59
|
+
return frozen[value];
|
|
60
|
+
const word = typeof value === 'string' ? value.toLowerCase() : null;
|
|
61
|
+
return word !== null && frozen.includes(word) ? word : null;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An integer bound: the request must be a safe integer within it, and
|
|
68
|
+
* the read-back is the engine's number.
|
|
69
|
+
* @param {number} min
|
|
70
|
+
* @param {number} [max]
|
|
71
|
+
* @returns {{ normalize: (value: any, option: string) => number,
|
|
72
|
+
* read: (value: any) => number | null }}
|
|
73
|
+
*/
|
|
74
|
+
function integer(min, max = Number.MAX_SAFE_INTEGER) {
|
|
75
|
+
return {
|
|
76
|
+
normalize: (value, option) => {
|
|
77
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
78
|
+
throw new TypeError(`openStore: ${option} is an integer ${
|
|
79
|
+
max === Number.MAX_SAFE_INTEGER ? `>= ${min}` : `between ${min} and ${max}`}, got ${
|
|
80
|
+
JSON.stringify(value)}`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
},
|
|
84
|
+
read: (value) => (typeof value === 'number' && Number.isFinite(value) ? value
|
|
85
|
+
: typeof value === 'bigint' ? Number(value) : null),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The closed table, in the order the open sequence applies it. The
|
|
91
|
+
* busy timeout goes first because every later statement may wait on
|
|
92
|
+
* it; the journal mode next because it must run outside a transaction.
|
|
93
|
+
*
|
|
94
|
+
* `memoryFixed` marks a pragma a `:memory:` database cannot take — its
|
|
95
|
+
* journal mode is `memory` whatever is asked, and it has no file to
|
|
96
|
+
* map — so the open sequence does not write it there and the report
|
|
97
|
+
* carries what the engine answers instead. `fileWrite` marks a pragma
|
|
98
|
+
* whose write touches the file, refused on a read-only connection.
|
|
99
|
+
*/
|
|
100
|
+
export const PRAGMAS = Object.freeze({
|
|
101
|
+
busyTimeout: Object.freeze({
|
|
102
|
+
pragma: 'busy_timeout', default: 5000, ...integer(0, 0x7fffffff),
|
|
103
|
+
memoryFixed: false, fileWrite: false,
|
|
104
|
+
}),
|
|
105
|
+
journalMode: Object.freeze({
|
|
106
|
+
pragma: 'journal_mode', default: 'wal',
|
|
107
|
+
...wordSet(['delete', 'truncate', 'persist', 'memory', 'wal', 'off']),
|
|
108
|
+
memoryFixed: true, fileWrite: true,
|
|
109
|
+
}),
|
|
110
|
+
synchronous: Object.freeze({
|
|
111
|
+
pragma: 'synchronous', default: undefined,
|
|
112
|
+
...wordSet(['off', 'normal', 'full', 'extra']),
|
|
113
|
+
memoryFixed: false, fileWrite: false,
|
|
114
|
+
}),
|
|
115
|
+
walAutocheckpoint: Object.freeze({
|
|
116
|
+
pragma: 'wal_autocheckpoint', default: undefined, ...integer(0, 0x7fffffff),
|
|
117
|
+
memoryFixed: false, fileWrite: false,
|
|
118
|
+
}),
|
|
119
|
+
journalSizeLimit: Object.freeze({
|
|
120
|
+
pragma: 'journal_size_limit', default: undefined, ...integer(-1),
|
|
121
|
+
memoryFixed: false, fileWrite: false,
|
|
122
|
+
}),
|
|
123
|
+
cacheSize: Object.freeze({
|
|
124
|
+
pragma: 'cache_size', default: undefined, ...integer(-0x7fffffff, 0x7fffffff),
|
|
125
|
+
memoryFixed: false, fileWrite: false,
|
|
126
|
+
}),
|
|
127
|
+
mmapSize: Object.freeze({
|
|
128
|
+
pragma: 'mmap_size', default: undefined, ...integer(0),
|
|
129
|
+
memoryFixed: true, fileWrite: false,
|
|
130
|
+
}),
|
|
131
|
+
tempStore: Object.freeze({
|
|
132
|
+
pragma: 'temp_store', default: undefined,
|
|
133
|
+
...wordSet(['default', 'file', 'memory']),
|
|
134
|
+
memoryFixed: false, fileWrite: false,
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** The option names of the closed set, in application order. */
|
|
139
|
+
export const PRAGMA_NAMES = Object.freeze(Object.keys(PRAGMAS));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* SQLite pragmas an operator might reasonably name on `openStore` that
|
|
143
|
+
* this store does not configure — each refused by name rather than
|
|
144
|
+
* ignored. Spelled as the engine spells them; the camel-case twin of
|
|
145
|
+
* each is refused too, and so is the snake-case spelling of a member
|
|
146
|
+
* of the closed set.
|
|
147
|
+
*/
|
|
148
|
+
const NOT_CONFIGURABLE = Object.freeze([
|
|
149
|
+
'foreign_keys', 'locking_mode', 'page_size', 'auto_vacuum', 'secure_delete',
|
|
150
|
+
'automatic_index', 'cell_size_check', 'checkpoint_fullfsync', 'fullfsync',
|
|
151
|
+
'query_only', 'read_uncommitted', 'recursive_triggers', 'reverse_unordered_selects',
|
|
152
|
+
'threads', 'trusted_schema', 'analysis_limit', 'application_id', 'user_version',
|
|
153
|
+
'encoding', 'max_page_count', 'soft_heap_limit', 'hard_heap_limit',
|
|
154
|
+
'defer_foreign_keys', 'ignore_check_constraints', 'legacy_alter_table',
|
|
155
|
+
'writable_schema', 'case_sensitive_like', 'schema_version', 'incremental_vacuum',
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
/** @param {string} snake */
|
|
159
|
+
const camelOf = (snake) => snake.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
160
|
+
|
|
161
|
+
/** Option name → the reason it is refused, for every spelling refused. */
|
|
162
|
+
const REFUSED = (() => {
|
|
163
|
+
/** @type {Map<string, string>} */
|
|
164
|
+
const out = new Map();
|
|
165
|
+
const set = PRAGMA_NAMES.map((name) => `'${name}'`).join(', ');
|
|
166
|
+
for (const snake of NOT_CONFIGURABLE) {
|
|
167
|
+
const reason = snake === 'foreign_keys'
|
|
168
|
+
? "names PRAGMA foreign_keys, which the model requires ON and verifies at open; it is not configurable"
|
|
169
|
+
: `names PRAGMA ${snake}, which this store does not configure; the configurable set is ${set}`;
|
|
170
|
+
out.set(snake, reason);
|
|
171
|
+
out.set(camelOf(snake), reason);
|
|
172
|
+
}
|
|
173
|
+
for (const name of PRAGMA_NAMES) {
|
|
174
|
+
const snake = PRAGMAS[/** @type {keyof typeof PRAGMAS} */ (name)].pragma;
|
|
175
|
+
if (snake !== name) out.set(snake, `is spelled '${name}' on openStore`);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Refuse an open option that names a pragma outside the closed set
|
|
182
|
+
* (`JD0006`). The option bag's other members are the store's own and
|
|
183
|
+
* are not judged here.
|
|
184
|
+
* @param {Record<string, any>} options
|
|
185
|
+
*/
|
|
186
|
+
export function refuseUnsupportedPragmaKeys(options) {
|
|
187
|
+
for (const key of Object.keys(options)) {
|
|
188
|
+
const reason = REFUSED.get(key);
|
|
189
|
+
if (reason !== undefined) {
|
|
190
|
+
throw new DbCompileError('JD0006', `openStore option '${key}' ${reason}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The validated requests an open makes: every explicitly given option
|
|
197
|
+
* of the closed set, normalized, plus the two defaults (busy timeout
|
|
198
|
+
* 5000 ms; journal mode `wal` on a writable file). A request the
|
|
199
|
+
* store kind cannot take is settled here: an explicit journal mode on
|
|
200
|
+
* a read-only store is `JD0007` (the engine refuses the write), and a
|
|
201
|
+
* `memoryFixed` pragma on a `:memory:` store is dropped from the
|
|
202
|
+
* requests — the report then carries what the engine answers.
|
|
203
|
+
*
|
|
204
|
+
* A request is EXPLICIT when the operator spelled it and a DEFAULT
|
|
205
|
+
* otherwise, and the read-back treats the two differently: an explicit
|
|
206
|
+
* value the engine did not take refuses the open (`JD0008`), while a
|
|
207
|
+
* default is the store's preference — WAL where the engine can — and a
|
|
208
|
+
* build that cannot take it (the wasm build's file systems have no
|
|
209
|
+
* shared memory for a WAL) opens with the report saying what the
|
|
210
|
+
* connection actually reads. Nothing is ever reported as effective
|
|
211
|
+
* without having been read back; only the refusal is reserved for what
|
|
212
|
+
* was asked for.
|
|
213
|
+
* @param {Record<string, any>} options
|
|
214
|
+
* @param {{ memory: boolean, readOnly: boolean }} kind
|
|
215
|
+
* @returns {Map<string, { value: number | string, explicit: boolean }>}
|
|
216
|
+
* option name → the normalized value and whether the operator asked
|
|
217
|
+
* for it, in application order
|
|
218
|
+
*/
|
|
219
|
+
export function resolvePragmaRequests(options, kind) {
|
|
220
|
+
/** @type {Map<string, { value: number | string, explicit: boolean }>} */
|
|
221
|
+
const requests = new Map();
|
|
222
|
+
for (const name of PRAGMA_NAMES) {
|
|
223
|
+
const entry = PRAGMAS[/** @type {keyof typeof PRAGMAS} */ (name)];
|
|
224
|
+
const given = options[name];
|
|
225
|
+
if (given === undefined) {
|
|
226
|
+
if (entry.default === undefined) continue;
|
|
227
|
+
// the journal-mode default is for a file this connection may
|
|
228
|
+
// write; a read-only store keeps whatever mode the file has
|
|
229
|
+
if (entry.fileWrite && kind.readOnly) continue;
|
|
230
|
+
if (entry.memoryFixed && kind.memory) continue;
|
|
231
|
+
requests.set(name, { value: entry.default, explicit: false });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const value = entry.normalize(given, name);
|
|
235
|
+
if (entry.fileWrite && kind.readOnly) {
|
|
236
|
+
throw new DbCompileError('JD0007',
|
|
237
|
+
`${name} cannot be applied on a read-only store: PRAGMA ${entry.pragma} writes the file`);
|
|
238
|
+
}
|
|
239
|
+
if (entry.memoryFixed && kind.memory) continue;
|
|
240
|
+
requests.set(name, { value, explicit: true });
|
|
241
|
+
}
|
|
242
|
+
return requests;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The one column a `PRAGMA name` read answers, whatever the engine
|
|
247
|
+
* names it (`busy_timeout` answers a `timeout` column).
|
|
248
|
+
* @param {any} row
|
|
249
|
+
*/
|
|
250
|
+
const firstValue = (row) => {
|
|
251
|
+
if (row === undefined || row === null || typeof row !== 'object') return undefined;
|
|
252
|
+
const values = Object.values(row);
|
|
253
|
+
return values.length === 0 ? undefined : values[0];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Apply the requests on an open connection and read every declared
|
|
258
|
+
* pragma back. Refuses `JD0007` for a request the binding does not
|
|
259
|
+
* declare and `JD0008` for an EXPLICIT request the engine did not take;
|
|
260
|
+
* answers the effective record — every option name of the closed set,
|
|
261
|
+
* the read-back value, or `null` where the binding declares the pragma
|
|
262
|
+
* absent or the engine answered nothing.
|
|
263
|
+
* @param {any} connection - the probed connection (`capabilities.configurablePragmas`)
|
|
264
|
+
* @param {Map<string, { value: number | string, explicit: boolean }>} requests
|
|
265
|
+
* - from {@link resolvePragmaRequests}
|
|
266
|
+
* @returns {any} value-or-promise of the frozen effective record
|
|
267
|
+
*/
|
|
268
|
+
export function configurePragmas(connection, requests) {
|
|
269
|
+
const declared = new Set(connection.capabilities.configurablePragmas ?? []);
|
|
270
|
+
const dialect = connection.dialect;
|
|
271
|
+
for (const [name, request] of requests) {
|
|
272
|
+
if (declared.has(name)) continue;
|
|
273
|
+
// Only an EXPLICIT request refuses. A default is the store's own
|
|
274
|
+
// preference — a busy timeout, WAL where the engine has one — and a
|
|
275
|
+
// connection whose vocabulary does not include it (another engine
|
|
276
|
+
// entirely, or a build that compiled the pragma out) opens with the
|
|
277
|
+
// effective record saying `null` rather than failing on a value
|
|
278
|
+
// nobody asked for.
|
|
279
|
+
if (!request.explicit) continue;
|
|
280
|
+
throw new DbCompileError('JD0007',
|
|
281
|
+
`the driver cannot apply pragma '${name}': its binding declares ${
|
|
282
|
+
declared.size === 0 ? 'no configurable pragma' : [...declared].map((n) => `'${n}'`).join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
const names = PRAGMA_NAMES.filter((name) => declared.has(name));
|
|
285
|
+
const apply = (i) => {
|
|
286
|
+
if (i >= names.length) return null;
|
|
287
|
+
const name = names[i];
|
|
288
|
+
const request = requests.get(name);
|
|
289
|
+
if (request === undefined) return apply(i + 1);
|
|
290
|
+
const entry = PRAGMAS[/** @type {keyof typeof PRAGMAS} */ (name)];
|
|
291
|
+
return chain(connection.exec(dialect.pragma.set(entry.pragma, request.value)), () => apply(i + 1));
|
|
292
|
+
};
|
|
293
|
+
/** @type {Record<string, number | string | null>} */
|
|
294
|
+
const effective = {};
|
|
295
|
+
for (const name of PRAGMA_NAMES) effective[name] = null;
|
|
296
|
+
const read = (i) => {
|
|
297
|
+
if (i >= names.length) return null;
|
|
298
|
+
const name = names[i];
|
|
299
|
+
const entry = PRAGMAS[/** @type {keyof typeof PRAGMAS} */ (name)];
|
|
300
|
+
return chain(connection.prepare(dialect.introspect.pragma(entry.pragma)), (statement) =>
|
|
301
|
+
chain(statement.get([]), (row) => {
|
|
302
|
+
const value = entry.read(firstValue(row));
|
|
303
|
+
effective[name] = value;
|
|
304
|
+
const request = requests.get(name);
|
|
305
|
+
if (request !== undefined && request.explicit && value !== request.value) {
|
|
306
|
+
throw new DbCompileError('JD0008',
|
|
307
|
+
`pragma '${name}' did not take: ${JSON.stringify(request.value)} was requested and the `
|
|
308
|
+
+ `connection reads back ${JSON.stringify(value)}`);
|
|
309
|
+
}
|
|
310
|
+
return read(i + 1);
|
|
311
|
+
}));
|
|
312
|
+
};
|
|
313
|
+
return chain(apply(0), () => chain(read(0), () => Object.freeze(effective)));
|
|
314
|
+
}
|
package/src/profile.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* from a tenant, a remote client or a language model can reach a
|
|
5
5
|
* database, and injection being structurally impossible (parameter
|
|
6
6
|
* binding) says nothing about resource exhaustion or cross-tenant
|
|
7
|
-
* reads. A profile composes
|
|
7
|
+
* reads. A profile composes five INDEPENDENT bounds:
|
|
8
8
|
*
|
|
9
9
|
* 1. engine limits — `{sequenceItems, resultItems, steps, depth}`
|
|
10
10
|
* wired into every residual compilation, so the JavaScript
|
|
@@ -18,7 +18,15 @@
|
|
|
18
18
|
* database narrative shows a full-table SCAN is refused;
|
|
19
19
|
* 4. mandatory predicates — a per-collection predicate conjoined into
|
|
20
20
|
* EVERY plan at its root, after translation, so no document shape
|
|
21
|
-
* can produce a fetch without it
|
|
21
|
+
* can produce a fetch without it;
|
|
22
|
+
* 5. the member allow-list — per ROOT (a collection or an entity), the
|
|
23
|
+
* members a document may read. It is a policy over what the caller
|
|
24
|
+
* may OBTAIN, so it is checked against every member path the
|
|
25
|
+
* document references, and reading a root item whole (a bare
|
|
26
|
+
* binding, a wildcard with no singular prefix) is refused rather
|
|
27
|
+
* than narrowed: a list of allowed members cannot cover the whole
|
|
28
|
+
* item, and answering a narrowed document nobody asked for would be
|
|
29
|
+
* the wrong answer, not a safer one.
|
|
22
30
|
*
|
|
23
31
|
* The non-claims are part of the contract and live in
|
|
24
32
|
* MODEL-FORMAT.md §8: no statement timeout exists on the SQLite
|
|
@@ -27,8 +35,10 @@
|
|
|
27
35
|
* rows, not database-internal work.
|
|
28
36
|
*/
|
|
29
37
|
|
|
30
|
-
import { planQuery } from './plan.js';
|
|
38
|
+
import { planQuery, collectMemberReads } from './plan.js';
|
|
31
39
|
import { conjoin } from './algebra.js';
|
|
40
|
+
import { compileIndexPath } from './ddl.js';
|
|
41
|
+
import { DbCompileError } from './errors.js';
|
|
32
42
|
|
|
33
43
|
/** The `'safe'` profile: the documented defaults. */
|
|
34
44
|
export const SAFE_PROFILE = Object.freeze({
|
|
@@ -45,8 +55,32 @@ export const SAFE_PROFILE = Object.freeze({
|
|
|
45
55
|
collections: null,
|
|
46
56
|
predicates: Object.freeze({}),
|
|
47
57
|
refuseFullScan: false,
|
|
58
|
+
// the graph bounds (MODEL-FORMAT §8, §10.4): a cap on any include's
|
|
59
|
+
// per-root rows, on the include depth, and on one item's serialised
|
|
60
|
+
// bytes — `null` leaves the include's own declaration and the
|
|
61
|
+
// store defaults in force
|
|
62
|
+
maxIncludedRows: null,
|
|
63
|
+
maxDepth: null,
|
|
64
|
+
maxBytes: null,
|
|
65
|
+
// the member allow-list (§8): `null` is no member policy at all, the
|
|
66
|
+
// long-standing behaviour — every declared member of an allowed root
|
|
67
|
+
// is readable
|
|
68
|
+
members: null,
|
|
48
69
|
});
|
|
49
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A bound member: a positive integer, or `null`/`Infinity` for none.
|
|
73
|
+
* @param {any} value
|
|
74
|
+
* @param {string} member
|
|
75
|
+
* @returns {number | null}
|
|
76
|
+
*/
|
|
77
|
+
function boundMember(value, member) {
|
|
78
|
+
if (value === undefined || value === null || value === Infinity) return null;
|
|
79
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
80
|
+
throw new TypeError(`profile.${member} must be a positive integer, or null for no bound`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
50
84
|
/**
|
|
51
85
|
* Normalize a profile option: the string `'safe'` is the default
|
|
52
86
|
* table; an object overrides individual members over those defaults
|
|
@@ -70,6 +104,10 @@ export function normalizeProfile(profile) {
|
|
|
70
104
|
: profile.collections === null ? null : Object.freeze([...profile.collections]),
|
|
71
105
|
predicates: Object.freeze({ ...profile.predicates }),
|
|
72
106
|
refuseFullScan: profile.refuseFullScan === true,
|
|
107
|
+
maxIncludedRows: boundMember(profile.maxIncludedRows, 'maxIncludedRows'),
|
|
108
|
+
maxDepth: boundMember(profile.maxDepth, 'maxDepth'),
|
|
109
|
+
maxBytes: boundMember(profile.maxBytes, 'maxBytes'),
|
|
110
|
+
members: memberLists(profile.members),
|
|
73
111
|
};
|
|
74
112
|
if (typeof merged.maxRows !== 'number' || !Number.isInteger(merged.maxRows)
|
|
75
113
|
|| merged.maxRows < 1)
|
|
@@ -77,6 +115,116 @@ export function normalizeProfile(profile) {
|
|
|
77
115
|
return Object.freeze(merged);
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Normalize the member allow-list: `{ Root: ['$.a', '$.b.c'] }` into
|
|
120
|
+
* `{ Root: { declared, canonical } }`. The spelling is the model's own
|
|
121
|
+
* index-path spelling, compiled by the same function, so a profile
|
|
122
|
+
* member and a declared index name the same path — and the CANONICAL is
|
|
123
|
+
* injective, which a dotted string is not (a member literally named
|
|
124
|
+
* `a.b` and the nested path `a` → `b` spell alike). A malformed list is
|
|
125
|
+
* host configuration, so it is a `TypeError` like every other bound
|
|
126
|
+
* here, not a coded document failure.
|
|
127
|
+
* @param {any} members
|
|
128
|
+
* @returns {any}
|
|
129
|
+
*/
|
|
130
|
+
function memberLists(members) {
|
|
131
|
+
if (members === undefined || members === null) return SAFE_PROFILE.members;
|
|
132
|
+
if (typeof members !== 'object' || Array.isArray(members))
|
|
133
|
+
throw new TypeError('profile.members must be an object of root → member paths');
|
|
134
|
+
/** @type {any} */
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const root of Object.keys(members)) {
|
|
137
|
+
const paths = members[root];
|
|
138
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
139
|
+
throw new TypeError(`profile.members['${root}'] must be a non-empty array of member `
|
|
140
|
+
+ "paths ('$.name'); an empty list allows nothing, which is spelled by omitting the root "
|
|
141
|
+
+ 'from profile.collections');
|
|
142
|
+
}
|
|
143
|
+
const canonical = [];
|
|
144
|
+
for (const path of paths) {
|
|
145
|
+
if (typeof path !== 'string')
|
|
146
|
+
throw new TypeError(`profile.members['${root}'] must contain member paths as strings`);
|
|
147
|
+
let compiled;
|
|
148
|
+
try {
|
|
149
|
+
compiled = compileIndexPath(path, `/profile/members/${root}`);
|
|
150
|
+
}
|
|
151
|
+
catch (cause) {
|
|
152
|
+
throw new TypeError(`profile.members['${root}'] path '${path}' must be a singular `
|
|
153
|
+
+ `member path over the stored document ('$.name'): ${
|
|
154
|
+
/** @type {any} */ (cause).reason ?? /** @type {any} */ (cause).message}`);
|
|
155
|
+
}
|
|
156
|
+
canonical.push(compiled.canonical);
|
|
157
|
+
}
|
|
158
|
+
out[root] = Object.freeze({
|
|
159
|
+
declared: Object.freeze([...paths]),
|
|
160
|
+
canonical: Object.freeze(canonical),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return Object.freeze(out);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Refuse a profile whose member allow-list names a root the model does
|
|
168
|
+
* not declare — before any statement is prepared, because a typo in a
|
|
169
|
+
* policy that silently applies to nothing is the policy failing open.
|
|
170
|
+
* @param {any} profile - a normalized profile, or `null`
|
|
171
|
+
* @param {readonly string[]} roots - every declared root name
|
|
172
|
+
* @param {string} [docPath]
|
|
173
|
+
*/
|
|
174
|
+
export function assertProfileRoots(profile, roots, docPath = '/profile') {
|
|
175
|
+
if (profile === null || profile.members === null) return;
|
|
176
|
+
for (const root of Object.keys(profile.members)) {
|
|
177
|
+
if (!roots.includes(root)) {
|
|
178
|
+
throw new DbCompileError('JD0011',
|
|
179
|
+
`the profile's member allow-list names '${root}', which the model does not declare `
|
|
180
|
+
+ `(declared roots: ${roots.join(', ')})`, docPath);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a read canonical is inside an allowed one: the member itself,
|
|
187
|
+
* or anything under it. Allowing `a` allows `a.b`; allowing `a.b` does
|
|
188
|
+
* NOT allow `a`, which would expose its siblings.
|
|
189
|
+
* @param {string} read
|
|
190
|
+
* @param {string} allowed
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
function inside(read, allowed) {
|
|
194
|
+
return read === allowed || read.startsWith(`${allowed}.`) || read.startsWith(`${allowed}[`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The member allow-list's verdict on one document, as the refusal
|
|
199
|
+
* sentence or `null`. Names the root, the member and the place in the
|
|
200
|
+
* caller's document, because a policy refusal a caller cannot locate is
|
|
201
|
+
* a policy refusal they will disable.
|
|
202
|
+
* @param {any} profile - a normalized profile, or `null`
|
|
203
|
+
* @param {string} root - the collection or entity being read
|
|
204
|
+
* @param {any} analysisRoot - the document's analysis root node
|
|
205
|
+
* @param {(expr: any) => boolean} isRootSource
|
|
206
|
+
* @returns {string | null}
|
|
207
|
+
*/
|
|
208
|
+
export function memberDenial(profile, root, analysisRoot, isRootSource) {
|
|
209
|
+
const policy = profile === null || profile.members === null
|
|
210
|
+
? undefined : profile.members[root];
|
|
211
|
+
if (policy === undefined) return null;
|
|
212
|
+
const reads = collectMemberReads(analysisRoot, isRootSource);
|
|
213
|
+
const allowed = policy.declared.join(', ');
|
|
214
|
+
if (reads.whole !== null) {
|
|
215
|
+
return `the profile allows only the members (${allowed}) of '${root}', and `
|
|
216
|
+
+ `'${reads.whole.construct}' at ${reads.whole.docPath} reads the whole item — `
|
|
217
|
+
+ 'project the members the policy allows';
|
|
218
|
+
}
|
|
219
|
+
for (const read of reads.members) {
|
|
220
|
+
if (!policy.canonical.some((entry) => inside(read.canonical, entry))) {
|
|
221
|
+
return `the profile does not allow the member '${read.member}' of '${root}' `
|
|
222
|
+
+ `(allowed: ${allowed}) at ${read.docPath}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
80
228
|
/**
|
|
81
229
|
* Translate a profile's mandatory predicate for one collection into a
|
|
82
230
|
* plan predicate. The predicate is HOST-authored configuration, so a
|