@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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. 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 four INDEPENDENT bounds:
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