@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.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/query.js ADDED
@@ -0,0 +1,1010 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The query surface over one collection: the D2 provider
4
+ * (`execute(document, options)` — how a linq chain runs here with no
5
+ * import edge), the streaming cursor (`query`), and `explain()`.
6
+ *
7
+ * The statement cache is a CALLER of the core primitives:
8
+ * `createSemanticCache` keyed by the whole discriminating tuple —
9
+ * document plus collection, dialect, strictness, pushdown and profile.
10
+ * The identity is the tuple's COMPLETE serialization, never a
11
+ * fingerprint of it: a 32-bit content hash collides after tens of
12
+ * thousands of documents, and a collision here answers one query with
13
+ * another query's plan and rows. `store.stats()` exposes hits, misses
14
+ * and evictions so the cache is proven rather than assumed.
15
+ *
16
+ * Bind-time diversion: if any referenced external is missing or not a
17
+ * string or finite number, the call runs the always-compilable set
18
+ * residual over the full collection instead of the native statement —
19
+ * SQLite cannot bind a boolean, a `null` needs Jaren's semantics, and
20
+ * a missing external must raise the ENGINE's error, not a driver's.
21
+ */
22
+
23
+ import { createSemanticCache } from '@jarenjs/core/cache';
24
+ import { compileJsonQuery, analyzeQuery } from '@jarenjs/json/query';
25
+
26
+ import { DbCompileError, DbRuntimeError } from './errors.js';
27
+ import { chain } from './driver.js';
28
+ import {
29
+ planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
30
+ } from './plan.js';
31
+ import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
32
+ import { selectPlan } from './algebra.js';
33
+ import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
34
+ import { deterministicFragment, registerFragment } from './udf.js';
35
+ import {
36
+ normalizeProfile, translateProfilePredicate,
37
+ applyMandatoryPredicate, applyRowBound,
38
+ } from './profile.js';
39
+
40
+ /**
41
+ * The store-wide query state shared by every collection's engine: one
42
+ * bounded statement cache, its counters, the UDF registration set, and
43
+ * the store's resolved registered operators (Ring 2 — `{ functions,
44
+ * extensions }` or `null`), threaded to every engine that builds a
45
+ * residual.
46
+ * @param {number} [bound]
47
+ * @param {{ functions?: any, extensions?: any } | null} [operators]
48
+ * @returns {any}
49
+ */
50
+ export function createQueryState(bound = undefined, operators = null) {
51
+ return {
52
+ cache: createSemanticCache(bound ?? 128),
53
+ counters: { hits: 0, misses: 0, evictions: 0 },
54
+ /** Fragment identity → the SQL function name registered for it. */
55
+ registered: new Map(),
56
+ operators: operators ?? null,
57
+ };
58
+ }
59
+
60
+ /** @param {any} value - a bindable native parameter? */
61
+ function bindable(value) {
62
+ return typeof value === 'string'
63
+ || (typeof value === 'number' && Number.isFinite(value));
64
+ }
65
+
66
+ /**
67
+ * The query engine for one collection.
68
+ * @param {{ connection: any, state: any, collection: any,
69
+ * physicalPlan: any, profile?: any }} context - `collection` is the
70
+ * normalized collection; `physicalPlan` is the DDL plan
71
+ * (columns, indexes); `profile` is the store-level normalized
72
+ * profile, if one was opened with
73
+ * @returns {{ execute: Function, query: Function, explain: Function }}
74
+ */
75
+ export function createQueryEngine(context) {
76
+ const { connection, state, collection, physicalPlan } = context;
77
+ const storeProfile = context.profile ?? null;
78
+ // the store's registered operators (Ring 2): recognised by the planner
79
+ // as vocabulary, evaluated in the residual, threaded into every
80
+ // residual compilation here. `null` when the store opened with no
81
+ // registry — the whole engine is then byte-identical to before.
82
+ const operators = state.operators ?? null;
83
+ const dialect = connection.dialect;
84
+ const shape = {
85
+ collection: collection.name,
86
+ schema: collection.schema,
87
+ columnByCanonical: physicalPlan.columnByCanonical,
88
+ operators,
89
+ };
90
+ const physical = {
91
+ table: physicalPlan.table,
92
+ keyColumn: physicalPlan.keyColumn,
93
+ docColumn: physicalPlan.docColumn,
94
+ };
95
+
96
+ /** The `compileJsonQuery` options for an inline residual: the
97
+ * profile's engine limits plus the store's registered operators. */
98
+ const residualCompileOptions = (limits) => {
99
+ const functions = operators?.functions;
100
+ const extensions = operators?.extensions;
101
+ if (limits === undefined && functions === undefined && extensions === undefined)
102
+ return undefined;
103
+ /** @type {any} */
104
+ const options = {};
105
+ if (limits !== undefined) options.limits = limits;
106
+ if (functions !== undefined) options.functions = functions;
107
+ if (extensions !== undefined) options.extensions = extensions;
108
+ return options;
109
+ };
110
+
111
+ const udfHook = connection.capabilities.userFunctions
112
+ ? (fragment) => {
113
+ // Ring 3: admit the registry's pushable:'scalar' operators too
114
+ const qualified = deterministicFragment(fragment, operators);
115
+ if (qualified === null) return null;
116
+ // the store owns the final name: a fingerprint clash between two
117
+ // distinct fragments is disambiguated at registration
118
+ return { ...qualified, name: registerFragment(connection, state.registered, qualified) };
119
+ }
120
+ : undefined;
121
+
122
+ /**
123
+ * The profile's compile-time refusal.
124
+ * @param {string} reason
125
+ * @returns {DbCompileError}
126
+ */
127
+ const profileRefusal = (reason) =>
128
+ new DbCompileError('JD0011', reason, collection.docPath);
129
+
130
+ /**
131
+ * Build (or fetch) the cached entry for one document, under one
132
+ * resolved profile and pushdown setting.
133
+ * @param {any} document
134
+ * @param {boolean} strict
135
+ * @param {any} profile - normalized profile or null
136
+ * @param {boolean} pushdown - false forces the whole document to the
137
+ * set residual (the oracle's forced-residual mode)
138
+ */
139
+ const entryFor = (document, strict, profile, pushdown) => {
140
+ // one TUPLE, not a `|`-joined string: a document may itself contain
141
+ // the separator, so concatenation is not injective and the tuple is
142
+ const key = ['C', document, collection.name, dialect.name, strict, pushdown, profile];
143
+ const cached = state.cache.get(key);
144
+ if (cached !== undefined) {
145
+ state.counters.hits++;
146
+ return cached;
147
+ }
148
+ state.counters.misses++;
149
+
150
+ if (profile !== null && profile.collections !== null
151
+ && !profile.collections.includes(collection.name)) {
152
+ throw profileRefusal(
153
+ `the profile does not allow querying collection '${collection.name}'`);
154
+ }
155
+
156
+ // no UDF registration under a profile: a foreign document must not
157
+ // cause host-side function registration
158
+ let planned = planQuery(document, shape,
159
+ { udf: profile === null && pushdown ? udfHook : undefined });
160
+ if (!pushdown) {
161
+ planned = {
162
+ ...planned,
163
+ plan: null,
164
+ mode: 'set',
165
+ reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }],
166
+ rowReturn: null,
167
+ udfs: [],
168
+ };
169
+ }
170
+
171
+ if (profile !== null) {
172
+ const deps = planned.analysis.dependencies;
173
+ for (const name of deps.functions) {
174
+ if (!profile.functions.includes(name))
175
+ throw profileRefusal(`the profile does not allow the host function '${name}'`);
176
+ }
177
+ for (const name of deps.collations) {
178
+ if (!profile.collations.includes(name))
179
+ throw profileRefusal(`the profile does not allow the collation '${name}'`);
180
+ }
181
+ for (const external of planned.analysis.externals) {
182
+ if (!profile.externals.includes(external.name))
183
+ throw profileRefusal(`the profile does not declare the external '${external.name}'`);
184
+ }
185
+ }
186
+
187
+ if (strict && planned.mode !== 'native') {
188
+ const forcing = planned.reasons[0]
189
+ ?? { construct: 'residual', reason: 'the document did not translate' };
190
+ throw new DbCompileError('JD0010',
191
+ `strict mode refused a residual: '${forcing.construct}' — ${forcing.reason}`,
192
+ collection.docPath);
193
+ }
194
+
195
+ const mandatory = profile !== null
196
+ && profile.predicates[collection.name] !== undefined
197
+ ? translateProfilePredicate(profile.predicates[collection.name], shape)
198
+ : null;
199
+ const maxRows = profile === null ? null : profile.maxRows;
200
+ const shapePlan = (base) => {
201
+ let out = applyMandatoryPredicate(base, mandatory);
202
+ if (maxRows !== null) out = applyRowBound(out, maxRows);
203
+ return out;
204
+ };
205
+
206
+ const plan = shapePlan(planned.plan ?? selectPlan(collection.name));
207
+ const emitted = emitPlan(plan, dialect, physical);
208
+ const externalNames = planned.analysis.externals.map((e) => e.name);
209
+ const limits = profile === null ? undefined : profile.limits;
210
+ const entry = {
211
+ planned,
212
+ plan,
213
+ sql: emitted.sql,
214
+ slots: emitted.slots,
215
+ externalNames,
216
+ dependencies: planned.analysis.dependencies,
217
+ limits: planned.analysis.limits,
218
+ residualLimits: limits,
219
+ rowBound: maxRows,
220
+ needsScanCheck: profile !== null && profile.refuseFullScan === true,
221
+ scanChecked: false,
222
+ statement: null,
223
+ setResidual: null,
224
+ packedResidual: null,
225
+ rowResidual: planned.mode === 'row'
226
+ ? compileRowResidual(planned.rowReturn, limits, operators)
227
+ : null,
228
+ fullScanSql: null,
229
+ fullScanShape: () => shapePlan(selectPlan(collection.name)),
230
+ };
231
+
232
+ const sizeBefore = state.cache.size();
233
+ if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
234
+ state.counters.evictions++;
235
+ return entry;
236
+ };
237
+
238
+ const statementOf = (entry) => {
239
+ if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
240
+ return entry.statement;
241
+ };
242
+ const setResidualOf = (entry, document) => {
243
+ if (entry.setResidual === null)
244
+ entry.setResidual = compileSetResidual(document, entry.residualLimits, operators);
245
+ return entry.setResidual;
246
+ };
247
+ /** The item-packing variant for cursors: `[document]` packs the
248
+ * whole result sequence into one unambiguous array. */
249
+ const packedResidualOf = (entry, document) => {
250
+ if (entry.packedResidual === null) {
251
+ const compiled = compileJsonQuery([document], residualCompileOptions(entry.residualLimits));
252
+ entry.packedResidual = (candidates, externals) => compiled(candidates, externals);
253
+ }
254
+ return entry.packedResidual;
255
+ };
256
+ /**
257
+ * The diversion fetch: the whole collection, still wearing the
258
+ * profile's mandatory predicate and row bound — a diverted call must
259
+ * not escape either.
260
+ */
261
+ const fullScanOf = (entry) => {
262
+ if (entry.fullScanSql === null) {
263
+ const emitted = emitPlan(entry.fullScanShape(), dialect, physical);
264
+ entry.fullScanSql = { sql: emitted.sql, slots: emitted.slots, statement: null };
265
+ }
266
+ if (entry.fullScanSql.statement === null)
267
+ entry.fullScanSql.statement = connection.prepare(entry.fullScanSql.sql);
268
+ return entry.fullScanSql.statement;
269
+ };
270
+ const fullScanParams = (entry) =>
271
+ entry.fullScanSql.slots.map((slot) => ('literal' in slot ? slot.literal : null));
272
+
273
+ /** Refuse a fetch that crossed the profile's row bound (JD2007). */
274
+ const checkRowBound = (entry, rows) => {
275
+ if (entry.rowBound !== null && rows.length > entry.rowBound) {
276
+ throw new DbRuntimeError('JD2007',
277
+ `the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
278
+ { docPath: collection.docPath, collection: collection.name });
279
+ }
280
+ return rows;
281
+ };
282
+
283
+ /** The optional plan-shape refusal: a full-table SCAN of a profiled
284
+ * collection is refused when the profile says so, verified against
285
+ * the database's own plan output. */
286
+ const guardScan = (entry) => {
287
+ if (!entry.needsScanCheck || entry.scanChecked) return null;
288
+ const eqpParams = entry.slots.map((slot) => ('literal' in slot ? slot.literal : null));
289
+ return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
290
+ chain(statement.all(eqpParams), (rows) => {
291
+ const fullScan = rows.some((row) => {
292
+ const detail = String(row.detail);
293
+ return detail.startsWith(`SCAN ${physical.table}`)
294
+ && !detail.includes('USING INDEX');
295
+ });
296
+ if (fullScan) {
297
+ throw profileRefusal(
298
+ `the profile refuses a full-table scan of '${collection.name}' `
299
+ + `(${rows.map((row) => String(row.detail)).join('; ')})`);
300
+ }
301
+ entry.scanChecked = true;
302
+ return null;
303
+ }));
304
+ };
305
+
306
+ /** Bind slots against the call's externals. */
307
+ const bindParams = (entry, externals) =>
308
+ entry.slots.map((slot) => ('literal' in slot ? slot.literal : externals[slot.external]));
309
+
310
+ /** Must this call divert to the residual? */
311
+ const mustDivert = (entry, externals) =>
312
+ entry.externalNames.some((name) => !bindable(externals[name]));
313
+
314
+ const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
315
+
316
+ const aggregateResult = (entry, row) => {
317
+ const fn = entry.plan.aggregate.fn;
318
+ const value = row?.value ?? null;
319
+ if (fn === 'count') return value ?? 0;
320
+ if (fn === 'sum') return value === null ? 0 : value;
321
+ return value === null ? undefined : value;
322
+ };
323
+
324
+ /**
325
+ * Resolve the profile and pushdown switches for one call.
326
+ * @param {any} options
327
+ */
328
+ const callState = (options) => ({
329
+ externals: options?.externals ?? {},
330
+ strict: options?.strict === true,
331
+ profile: options?.profile !== undefined
332
+ ? normalizeProfile(options.profile)
333
+ : storeProfile,
334
+ pushdown: options?.pushdown !== false,
335
+ });
336
+
337
+ /**
338
+ * Run the document and answer in the ENGINE's result shape.
339
+ * @param {any} document
340
+ * @param {{ externals?: any, strict?: boolean, profile?: any,
341
+ * pushdown?: boolean }} [options]
342
+ * @returns {any} value-or-promise (the provider contract keeps a
343
+ * synchronous driver synchronous)
344
+ */
345
+ const execute = (document, options = undefined) => {
346
+ const { externals, strict, profile, pushdown } = callState(options);
347
+ const entry = entryFor(document, strict, profile, pushdown);
348
+
349
+ return chain(guardScan(entry), () => {
350
+ if (mustDivert(entry, externals)) {
351
+ return chain(fullScanOf(entry), (statement) =>
352
+ chain(statement.all(fullScanParams(entry)), (rows) =>
353
+ setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
354
+ }
355
+ if (entry.planned.mode === 'set') {
356
+ // the narrowed statement fetches candidates; the full document
357
+ // then re-applies its own predicates (idempotent narrowing)
358
+ return chain(statementOf(entry), (statement) =>
359
+ chain(statement.all(bindParams(entry, externals)), (rows) =>
360
+ setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
361
+ }
362
+ if (entry.planned.mode === 'row') {
363
+ return chain(statementOf(entry), (statement) =>
364
+ chain(statement.all(bindParams(entry, externals)), (rows) => {
365
+ const items = [];
366
+ for (const row of checkRowBound(entry, rows))
367
+ items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
368
+ return sequenceResult(items);
369
+ }));
370
+ }
371
+ return chain(statementOf(entry), (statement) => {
372
+ if (entry.plan.aggregate !== null) {
373
+ return chain(statement.get(bindParams(entry, externals)),
374
+ (row) => aggregateResult(entry, row));
375
+ }
376
+ return chain(statement.all(bindParams(entry, externals)),
377
+ (rows) => sequenceResult(rowsToDocs(checkRowBound(entry, rows))));
378
+ });
379
+ });
380
+ };
381
+
382
+ /**
383
+ * A streaming cursor over the document's result ITEMS (`next()` /
384
+ * `return()` plus `Symbol.asyncIterator`).
385
+ * Native and row modes stream row by row; a set residual
386
+ * materializes first (it is a barrier and `explain()` says so).
387
+ * @param {any} document
388
+ * @param {{ externals?: any, strict?: boolean }} [options]
389
+ */
390
+ const query = (document, options = undefined) => {
391
+ const { externals, strict, profile, pushdown } = callState(options);
392
+ const entry = entryFor(document, strict, profile, pushdown);
393
+ let pulledRows = 0;
394
+
395
+ /** @type {any} */
396
+ let underlying = null;
397
+ /** @type {any[]} */
398
+ let buffered = [];
399
+ let bufferedAt = 0;
400
+ let materialized = null;
401
+ let done = false;
402
+
403
+ const nextFromBuffer = () => ({ done: false, value: buffered[bufferedAt++] });
404
+
405
+ const pull = () => {
406
+ if (done) return Promise.resolve({ done: true, value: undefined });
407
+ if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
408
+
409
+ if (entry.planned.mode === 'set' || mustDivert(entry, externals)) {
410
+ // the barrier: materialize candidates, pack the result items
411
+ if (materialized === null) {
412
+ const diverted = mustDivert(entry, externals);
413
+ materialized = Promise.resolve(chain(guardScan(entry), () => chain(
414
+ diverted ? fullScanOf(entry) : statementOf(entry),
415
+ (statement) => chain(
416
+ statement.all(diverted ? fullScanParams(entry) : bindParams(entry, externals)),
417
+ (rows) => {
418
+ buffered = packedResidualOf(entry, document)(
419
+ rowsToDocs(checkRowBound(entry, rows)), externals);
420
+ bufferedAt = 0;
421
+ }))));
422
+ }
423
+ return materialized.then(() => {
424
+ if (bufferedAt < buffered.length) return nextFromBuffer();
425
+ done = true;
426
+ return { done: true, value: undefined };
427
+ });
428
+ }
429
+ if (entry.plan.aggregate !== null) {
430
+ // a native aggregate yields exactly one item
431
+ if (materialized === null) {
432
+ materialized = Promise.resolve(chain(guardScan(entry), () =>
433
+ chain(statementOf(entry), (statement) =>
434
+ chain(statement.get(bindParams(entry, externals)), (row) => {
435
+ const value = aggregateResult(entry, row);
436
+ buffered = value === undefined ? [] : [value];
437
+ bufferedAt = 0;
438
+ }))));
439
+ }
440
+ return materialized.then(() => {
441
+ if (bufferedAt < buffered.length) return nextFromBuffer();
442
+ done = true;
443
+ return { done: true, value: undefined };
444
+ });
445
+ }
446
+
447
+ return Promise.resolve(chain(underlying === null
448
+ ? chain(guardScan(entry), () => chain(statementOf(entry),
449
+ (statement) => { underlying = statement.iterate(bindParams(entry, externals)); return underlying; }))
450
+ : underlying, (iterator) => chain(iterator.next(), (step) => {
451
+ if (step.done === true) {
452
+ done = true;
453
+ return { done: true, value: undefined };
454
+ }
455
+ pulledRows++;
456
+ if (entry.rowBound !== null && pulledRows > entry.rowBound) {
457
+ done = true;
458
+ if (typeof iterator.return === 'function') iterator.return(undefined);
459
+ throw new DbRuntimeError('JD2007',
460
+ `the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
461
+ { docPath: collection.docPath, collection: collection.name });
462
+ }
463
+ const doc = JSON.parse(step.value.doc);
464
+ if (entry.rowResidual === null) return { done: false, value: doc };
465
+ buffered = entry.rowResidual(doc, externals);
466
+ bufferedAt = 0;
467
+ return bufferedAt < buffered.length ? nextFromBuffer() : pull();
468
+ })));
469
+ };
470
+
471
+ const close = () => {
472
+ done = true;
473
+ if (underlying !== null && typeof underlying.return === 'function')
474
+ underlying.return(undefined);
475
+ return Promise.resolve({ done: true, value: undefined });
476
+ };
477
+
478
+ return {
479
+ next: () => pull(),
480
+ return: () => close(),
481
+ [Symbol.asyncIterator]() { return this; },
482
+ };
483
+ };
484
+
485
+ /**
486
+ * The explanation record: the engine explain shape plus the pushdown
487
+ * facts. `estimatedRows` is deliberately ABSENT — the capability
488
+ * slot is empty on SQLite and no number is fabricated; the
489
+ * database's own plan prose rides in `scanNarrative` instead.
490
+ * @param {any} document
491
+ * @param {{ externals?: any, strict?: boolean }} [options]
492
+ */
493
+ const explain = (document, options = undefined) => {
494
+ const { externals, strict, profile, pushdown } = callState(options);
495
+ const entry = entryFor(document, strict, profile, pushdown);
496
+
497
+ const touchedColumns = new Set();
498
+ const collectColumns = (pred) => {
499
+ if (pred === null) return;
500
+ if (pred.p === 'and' || pred.p === 'or') pred.items.forEach(collectColumns);
501
+ else if (pred.p === 'not') collectColumns(pred.item);
502
+ else if ('ref' in pred && pred.ref?.column) touchedColumns.add(pred.ref.column);
503
+ };
504
+ collectColumns(entry.plan.filter);
505
+ for (const term of entry.plan.order ?? []) {
506
+ if (term.ref.column !== null) touchedColumns.add(term.ref.column);
507
+ }
508
+ if (entry.plan.aggregate?.ref?.column) touchedColumns.add(entry.plan.aggregate.ref.column);
509
+ const indexes = physicalPlan.expected.indexes
510
+ .filter((index) => index.columns.some((column) => touchedColumns.has(column)))
511
+ .map((index) => index.name);
512
+
513
+ const params = entry.slots.map((slot) =>
514
+ ('external' in slot ? { external: slot.external } : { literal: slot.literal }));
515
+ const eqpParams = entry.slots.map((slot) => ('literal' in slot
516
+ ? slot.literal
517
+ : bindable(externals[slot.external]) ? externals[slot.external] : null));
518
+
519
+ return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
520
+ chain(statement.all(eqpParams), (rows) => ({
521
+ externals: [...entry.externalNames],
522
+ operators: [...entry.dependencies.operators],
523
+ functions: [...entry.dependencies.functions],
524
+ collations: [...entry.dependencies.collations],
525
+ limits: entry.limits,
526
+ sql: entry.sql,
527
+ params,
528
+ indexes,
529
+ residual: entry.planned.mode === 'native'
530
+ ? null
531
+ : { mode: entry.planned.mode, reasons: entry.planned.reasons },
532
+ barriers: entry.planned.mode === 'set'
533
+ ? entry.planned.reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
534
+ : [],
535
+ udfs: [...entry.planned.udfs],
536
+ scanNarrative: rows.map((row) => String(row.detail)).join('; '),
537
+ })));
538
+ };
539
+
540
+ return { execute, query, explain, shape };
541
+ }
542
+
543
+ // ————— The entity query surface (the second document kind) —————
544
+
545
+ import { mergeEntityRow, parseGraphRow } from './graph.js';
546
+
547
+ /** The default include depth bound (D14: printed, never silent). */
548
+ export const INCLUDE_DEPTH_DEFAULT = 3;
549
+
550
+ /**
551
+ * The store-level entity query engine: documents over the
552
+ * multi-entity root (`$.<Entity>[*]` bindings), planned to guarded
553
+ * selections and INNER equijoins, with the set residual running the
554
+ * whole document over the fetched root — the same honesty contract as
555
+ * phase A.
556
+ * @param {{ connection: any, entities: Map<string, any>, mapping: any,
557
+ * state: any }} context
558
+ * @returns {any}
559
+ */
560
+ export function createEntityQueryEngine(context) {
561
+ const { connection, entities, mapping, state } = context;
562
+ const operators = state.operators ?? null;
563
+ const dialect = connection.dialect;
564
+ const q = dialect.quoteIdentifier;
565
+ const physicalOf = (name) => ({ table: mapping.entities[name].table });
566
+
567
+ const entryFor = (document, pushdown) => {
568
+ const key = ['E', document, dialect.name, pushdown];
569
+ const cached = state.cache.get(key);
570
+ if (cached !== undefined) {
571
+ state.counters.hits++;
572
+ return cached;
573
+ }
574
+ state.counters.misses++;
575
+ let planned = planEntityQuery(document, entities, mapping, operators);
576
+ if (!pushdown) {
577
+ planned = { ...planned, mode: 'set', plan: null,
578
+ reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }] };
579
+ }
580
+ const entry = {
581
+ planned,
582
+ sql: null,
583
+ slots: null,
584
+ statement: null,
585
+ setResidual: null,
586
+ fetchers: null,
587
+ };
588
+ if (planned.mode === 'native') {
589
+ const emitted = emitEntityPlan(planned.plan, dialect, physicalOf);
590
+ entry.sql = emitted.sql;
591
+ entry.slots = emitted.slots;
592
+ }
593
+ const sizeBefore = state.cache.size();
594
+ if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
595
+ state.counters.evictions++;
596
+ return entry;
597
+ };
598
+
599
+ /** Fetch every referenced entity's rows and build the in-memory root. */
600
+ const fetchRoot = (entry) => {
601
+ if (entry.fetchers === null) {
602
+ entry.fetchers = [...(entry.planned.referenced.length === 0
603
+ ? entities.keys() : entry.planned.referenced)].map((name) => ({
604
+ name,
605
+ sql: `SELECT ${q('t')}.*, ${dialect.jsonText(`${q('t')}.${q('doc')}`)} AS ${q('__doc')} `
606
+ + `FROM ${q(mapping.entities[name].table)} AS ${q('t')} ORDER BY ${q('t')}.${dialect.rowIdentity()}`,
607
+ statement: null,
608
+ }));
609
+ }
610
+ /** @type {any} */
611
+ const root = {};
612
+ const next = (i) => {
613
+ if (i >= entry.fetchers.length) return root;
614
+ const fetcher = entry.fetchers[i];
615
+ if (fetcher.statement === null) fetcher.statement = connection.prepare(fetcher.sql);
616
+ return chain(fetcher.statement, (statement) =>
617
+ chain(statement.all([]), (rows) => {
618
+ root[fetcher.name] = rows.map((row) =>
619
+ mergeEntityRow(mapping.entities[fetcher.name], row, '__doc'));
620
+ return next(i + 1);
621
+ }));
622
+ };
623
+ return next(0);
624
+ };
625
+
626
+ const runResidual = (entry, document, externals) => {
627
+ if (entry.setResidual === null)
628
+ entry.setResidual = compileSetResidual(document, undefined, operators);
629
+ return chain(fetchRoot(entry), (root) => entry.setResidual(root, externals));
630
+ };
631
+
632
+ const execute = (document, options = undefined) => {
633
+ const externals = options?.externals ?? {};
634
+ const pushdown = options?.pushdown !== false;
635
+ const entry = entryFor(document, pushdown);
636
+ if (entry.planned.mode !== 'native') {
637
+ if (options?.strict === true) {
638
+ const forcing = entry.planned.reasons[0];
639
+ throw new DbCompileError('JD0010',
640
+ `strict mode refused a residual: '${forcing.construct}' — ${forcing.reason}`);
641
+ }
642
+ return runResidual(entry, document, externals);
643
+ }
644
+ // bind-time diversion, exactly phase A's: a missing external must
645
+ // raise the ENGINE's error, a boolean or null cannot bind natively
646
+ for (const slot of entry.slots) {
647
+ if ('external' in slot && !bindable(externals[slot.external]))
648
+ return runResidual(entry, document, externals);
649
+ }
650
+ if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
651
+ const params = entry.slots.map((slot) =>
652
+ ('literal' in slot ? slot.literal : externals[slot.external]));
653
+ return chain(entry.statement, (statement) => {
654
+ if (entry.planned.plan.aggregate === 'count')
655
+ return chain(statement.get(params), (row) => row?.value ?? 0);
656
+ return chain(statement.all(params), (rows) => {
657
+ const retEntity = entry.planned.plan.bindings
658
+ .find((binding) => binding.name === entry.planned.plan.ret).entity;
659
+ return sequenceResult(rows.map((row) =>
660
+ mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
661
+ });
662
+ });
663
+ };
664
+
665
+ const explain = (document, options = undefined) => {
666
+ const pushdown = options?.pushdown !== false;
667
+ const entry = entryFor(document, pushdown);
668
+ const base = {
669
+ mode: entry.planned.mode,
670
+ referenced: [...entry.planned.referenced],
671
+ reasons: entry.planned.reasons,
672
+ sql: entry.sql,
673
+ residual: entry.planned.mode === 'native'
674
+ ? null
675
+ : { mode: 'set', reasons: entry.planned.reasons },
676
+ };
677
+ if (entry.planned.mode !== 'native') return base;
678
+ return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
679
+ chain(statement.all(entry.slots.map((slot) =>
680
+ ('literal' in slot ? slot.literal : null))), (rows) => ({
681
+ ...base,
682
+ join: entry.planned.plan.joinOn,
683
+ scanNarrative: rows.map((row) => String(row.detail)).join('; '),
684
+ })));
685
+ };
686
+
687
+ return { execute, explain };
688
+ }
689
+
690
+ /**
691
+ * The one-statement graph loader: `entity.load(spec)` compiles an
692
+ * include tree to correlated subqueries projected as JSON — one
693
+ * statement regardless of depth (asserted by a counting driver in the
694
+ * tests, because N+1 is a test, not a promise). Per-relation `where`,
695
+ * `orderBy` and `take` are applied INSIDE the subquery; depth is
696
+ * bounded with the default printed in the refusal; cycles in the
697
+ * specification are rejected; and keyset pagination is chosen over a
698
+ * growing OFFSET whenever the top-level ordering is a single unique
699
+ * column, with the choice reported by `explainLoad`.
700
+ * @param {{ connection: any, entities: Map<string, any>, mapping: any,
701
+ * state: any }} context
702
+ * @param {string} entityName
703
+ * @returns {any}
704
+ */
705
+ export function createLoadEngine(context, entityName) {
706
+ const { connection, entities, mapping, state } = context;
707
+ // a registered operator (Ring 2) is recognised as vocabulary so a
708
+ // where/orderBy that uses one refuses cleanly (JD0032 — the load path
709
+ // is all-SQL, with no residual), never as an unknown operator
710
+ const analyzeOpts = state.operators ?? undefined;
711
+ const dialect = connection.dialect;
712
+ const q = dialect.quoteIdentifier;
713
+
714
+ const refuse = (reason, path) => new DbCompileError('JD0032',
715
+ `${reason} (include path: ${path.join('.') || '<root>'})`,
716
+ entities.get(entityName)?.docPath);
717
+
718
+ /** Compile a where EXPRESSION over `$it` against one entity. */
719
+ const compileWhere = (expression, entity, path) => {
720
+ const wrapper = { $for: { it: '$[*]' }, $where: expression, $return: '$it' };
721
+ let analysis;
722
+ try {
723
+ analysis = analyzeQuery(wrapper, analyzeOpts);
724
+ }
725
+ catch (cause) {
726
+ throw new DbCompileError('JD0032',
727
+ `the where expression does not compile (include path: ${path.join('.')})`,
728
+ entity.docPath, /** @type {Error} */ (cause));
729
+ }
730
+ const flwor = analysis.root;
731
+ const slot = flwor.forBindings[0].slot;
732
+ const shape = entityShape(entity, mapping.entities[entity.name]);
733
+ const conjuncts = flwor.where.kind === 'op' && flwor.where.name === '$and'
734
+ ? flwor.where.args : [flwor.where];
735
+ let filter = null;
736
+ for (const conjunct of conjuncts) {
737
+ const outcome = planEntityPredicate(conjunct, slot, shape);
738
+ if ('refusal' in outcome) {
739
+ throw refuse(`the where expression is not translatable: ${outcome.refusal.reason}`, path);
740
+ }
741
+ filter = filter === null ? outcome.pred
742
+ : filter.p === 'and'
743
+ ? { p: 'and', items: [...filter.items, outcome.pred] }
744
+ : { p: 'and', items: [filter, outcome.pred] };
745
+ }
746
+ return filter;
747
+ };
748
+
749
+ const compileOrder = (orderBy, entity, path) => {
750
+ const specs = Array.isArray(orderBy) ? orderBy : [orderBy];
751
+ const wrapper = { $for: { it: '$[*]' }, $orderby: specs, $return: '$it' };
752
+ let analysis;
753
+ try {
754
+ analysis = analyzeQuery(wrapper, analyzeOpts);
755
+ }
756
+ catch (cause) {
757
+ throw new DbCompileError('JD0032',
758
+ `the orderBy does not compile (include path: ${path.join('.')})`,
759
+ entity.docPath, /** @type {Error} */ (cause));
760
+ }
761
+ const flwor = analysis.root;
762
+ const slot = flwor.forBindings[0].slot;
763
+ const shape = entityShape(entity, mapping.entities[entity.name]);
764
+ const terms = [];
765
+ for (const spec of flwor.orderby.specs) {
766
+ const ref = entityPathRef(spec.key, slot, shape);
767
+ if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown'))
768
+ throw refuse('orderBy must address typed entity paths', path);
769
+ terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
770
+ }
771
+ return terms;
772
+ };
773
+
774
+ /** Build the include tree, validating names, depth and cycles. */
775
+ const buildTree = (name, spec, depth, maxDepth, path, seen) => {
776
+ const entity = entities.get(name);
777
+ if (depth > maxDepth) {
778
+ throw refuse(`the include graph exceeds its depth bound of ${maxDepth} `
779
+ + '(raise it explicitly with maxDepth)', path);
780
+ }
781
+ const node = {
782
+ entity,
783
+ entityMapping: mapping.entities[name],
784
+ where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
785
+ order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
786
+ take: spec?.take,
787
+ includes: [],
788
+ };
789
+ const includeSpec = spec?.include;
790
+ if (includeSpec === undefined) return node;
791
+ if (seen.has(includeSpec))
792
+ throw refuse('the include specification cycles', path);
793
+ seen.add(includeSpec);
794
+ for (const relationName of Object.keys(includeSpec)) {
795
+ const property = entity.properties.get(relationName);
796
+ const relation = property?.relation;
797
+ if (relation === undefined) {
798
+ throw refuse(`'${name}' declares no relation '${relationName}'`,
799
+ [...path, relationName]);
800
+ }
801
+ const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
802
+ const childName = relation.to;
803
+ const include = {
804
+ name: relationName,
805
+ field: `__${relationName}`,
806
+ relation,
807
+ many: relation.kind !== 'oneToOne',
808
+ count: childSpec.count === true,
809
+ // the join kind is derivable from the schema: a required
810
+ // foreign key means the parent always exists
811
+ kind: relation.kind === 'oneToOne'
812
+ ? ((entity.schema.required ?? []).includes(relation.via)
813
+ ? 'inner (fk required)' : 'left (fk optional)')
814
+ : relation.kind,
815
+ child: childSpec.count === true
816
+ ? null
817
+ : buildTree(childName, childSpec, depth + 1, maxDepth,
818
+ [...path, relationName], seen),
819
+ };
820
+ node.includes.push(include);
821
+ }
822
+ return node;
823
+ };
824
+
825
+ /** Render one node's subquery-projection SQL. */
826
+ const render = (node, alias, param, emitters) => {
827
+ const aliasSql = q(alias);
828
+ const docSql = `${aliasSql}.${q('doc')}`;
829
+ const projection = () => {
830
+ const parts = [];
831
+ const named = new Set();
832
+ for (const column of node.entityMapping.columns) {
833
+ named.add(column.name);
834
+ parts.push(`${slText(column.name)}, ${aliasSql}.${q(column.name)}`);
835
+ }
836
+ for (const fk of node.entityMapping.foreignKeys) {
837
+ if (named.has(fk.column)) continue; // a declared via property
838
+ parts.push(`${slText(fk.column)}, ${aliasSql}.${q(fk.column)}`);
839
+ }
840
+ parts.push(`${slText('__doc')}, ${dialect.jsonText(docSql)}`);
841
+ for (const include of node.includes)
842
+ parts.push(`${slText(include.field)}, ${renderInclude(node, include, alias, param, emitters)}`);
843
+ return parts.join(', ');
844
+ };
845
+ return { aliasSql, docSql, projection };
846
+ };
847
+ const slText = (s) => dialect.stringLiteral(s);
848
+
849
+ const renderInclude = (parentNode, include, parentAlias, param, emitters) => {
850
+ const relation = include.relation;
851
+ const childAlias = `${parentAlias}_${include.name}`;
852
+ const parentKey = parentNode.entityMapping.keys[0];
853
+ if (include.count === true) {
854
+ const childTable = mapping.entities[relation.to].table;
855
+ return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
856
+ + `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
857
+ }
858
+ const child = include.child;
859
+ const childTable = child.entityMapping.table;
860
+ const childKey = child.entityMapping.keys[0];
861
+ const rendered = render(child, childAlias, param, emitters);
862
+ const conditions = [];
863
+ if (relation.kind === 'oneToMany') {
864
+ conditions.push(`${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)}`);
865
+ }
866
+ else if (relation.kind === 'oneToOne') {
867
+ conditions.push(`${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)}`);
868
+ }
869
+ if (child.where !== null)
870
+ conditions.push(emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where));
871
+ const orderSql = (child.order ?? []).map((term) => {
872
+ // epoch paths order by the document string (codepoint = the
873
+ // engine's order); only plain mapped columns order natively
874
+ const value = term.ref.flavor === 'entity-column'
875
+ ? `${rendered.aliasSql}.${q(term.ref.column)}`
876
+ : dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments));
877
+ const nullsFirst = term.emptyGreatest === term.desc;
878
+ return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
879
+ });
880
+ orderSql.push(`${rendered.aliasSql}.${dialect.rowIdentity()}`);
881
+ const inner = relation.kind === 'manyToMany'
882
+ ? `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
883
+ + `JOIN ${q(relation.joinTable)} AS ${q(`${childAlias}_j`)} `
884
+ + `ON ${q(`${childAlias}_j`)}.${q(`${relation.to}_key`)} = ${q(childAlias)}.${q(childKey)} `
885
+ + `WHERE ${q(`${childAlias}_j`)}.${q(`${parentNode.entity.name}_key`)} = ${q(parentAlias)}.${q(parentKey)}`
886
+ + (child.where !== null
887
+ ? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
888
+ + ` ORDER BY ${orderSql.join(', ')}`
889
+ + (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '')
890
+ : `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
891
+ + `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
892
+ + (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '');
893
+ if (relation.kind === 'oneToOne') {
894
+ return `(SELECT json_object(${rendered.projection()}) FROM `
895
+ + `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
896
+ }
897
+ return `(SELECT ${dialect.jsonAgg(`json_object(${rendered.projection()})`)} `
898
+ + `FROM (${inner}) AS ${q(childAlias)})`;
899
+ };
900
+
901
+ const buildLoad = (spec) => {
902
+ // a cyclic specification cannot be keyed, so the cache reports a
903
+ // permanent miss and buildTree gets to NAME the cycle
904
+ const key = ['L', entityName, spec ?? {}, dialect.name];
905
+ const cached = state.cache.get(key);
906
+ if (cached !== undefined) {
907
+ state.counters.hits++;
908
+ return cached;
909
+ }
910
+ state.counters.misses++;
911
+ /** @type {ParamCollector} */
912
+ const slots = [];
913
+ const param = (slot) => {
914
+ slots.push(slot);
915
+ return dialect.parameterRef(slots.length, 'v');
916
+ };
917
+ const emitters = createEntityPredicateEmitters(dialect, param);
918
+ const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
919
+ const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set());
920
+ const rendered = render(tree, 'r', param, emitters);
921
+
922
+ // anonymous placeholders bind by position, so slots must be
923
+ // collected in SQL text order: the SELECT-list include subqueries
924
+ // come before the root WHERE
925
+ const includeSql = tree.includes.map((include) =>
926
+ `, ${renderInclude(tree, include, 'r', param, emitters)} AS ${q(include.field)}`).join('');
927
+
928
+ const conditions = [];
929
+ if (tree.where !== null)
930
+ conditions.push(emitters.emitPred(rendered.aliasSql, rendered.docSql, tree.where));
931
+
932
+ // pagination: keyset over a single unique ordering column beats a
933
+ // growing OFFSET; the choice is reported, never silent
934
+ let pagination = 'none';
935
+ const order = tree.order ?? [];
936
+ const uniqueColumns = new Set([
937
+ tree.entityMapping.keys.length === 1 ? tree.entityMapping.keys[0] : null,
938
+ ...tree.entityMapping.indexes.filter((index) => index.unique)
939
+ .map((index) => index.property),
940
+ ]);
941
+ if (spec?.after !== undefined) {
942
+ const term = order.length === 1 ? order[0] : null;
943
+ if (term === null || term.ref.flavor === 'entity-doc'
944
+ || !uniqueColumns.has(term.ref.column)) {
945
+ throw refuse("'after' (keyset pagination) needs a single orderBy over a unique column", []);
946
+ }
947
+ pagination = 'keyset';
948
+ conditions.push(`${rendered.aliasSql}.${q(term.ref.column)} `
949
+ + `${term.desc ? '<' : '>'} ${param({ literal: spec.after })}`);
950
+ }
951
+ else if (spec?.skip !== undefined && spec.skip > 0) {
952
+ pagination = 'offset';
953
+ }
954
+
955
+ let sql = `SELECT ${rendered.aliasSql}.*, ${dialect.jsonText(rendered.docSql)} AS ${q('__doc')}`
956
+ + includeSql
957
+ + ` FROM ${q(tree.entityMapping.table)} AS ${rendered.aliasSql}`;
958
+ if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`;
959
+ const orderSql = order.map((term) => {
960
+ const value = term.ref.flavor === 'entity-column'
961
+ ? `${rendered.aliasSql}.${q(term.ref.column)}`
962
+ : dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments));
963
+ const nullsFirst = term.emptyGreatest === term.desc;
964
+ return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
965
+ });
966
+ orderSql.push(`${rendered.aliasSql}.${dialect.rowIdentity()}`);
967
+ sql += ` ORDER BY ${orderSql.join(', ')}`;
968
+ if (spec?.take !== undefined || pagination === 'offset') {
969
+ sql += ` ${dialect.limitClause(spec?.take ?? null,
970
+ pagination === 'offset' ? spec.skip : undefined)}`;
971
+ }
972
+
973
+ const entry = { sql, slots, tree, pagination, statement: null };
974
+ const sizeBefore = state.cache.size();
975
+ if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
976
+ state.counters.evictions++;
977
+ return entry;
978
+ };
979
+
980
+ return {
981
+ treeFor(spec) {
982
+ return buildLoad(spec).tree;
983
+ },
984
+ load(spec) {
985
+ const entry = buildLoad(spec);
986
+ if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
987
+ const params = entry.slots.map((slot) => slot.literal);
988
+ return chain(entry.statement, (statement) =>
989
+ chain(statement.all(params), (rows) =>
990
+ rows.map((row) => parseGraphRow(entry.tree, row, '__doc'))));
991
+ },
992
+ explainLoad(spec) {
993
+ const entry = buildLoad(spec);
994
+ const describe = (node, path) => node.includes.flatMap((include) => [
995
+ { path: [...path, include.name].join('.'), kind: include.kind,
996
+ count: include.count === true },
997
+ ...(include.child === null ? [] : describe(include.child, [...path, include.name])),
998
+ ]);
999
+ return {
1000
+ sql: entry.sql,
1001
+ pagination: entry.pagination,
1002
+ includes: describe(entry.tree, []),
1003
+ };
1004
+ },
1005
+ };
1006
+ }
1007
+
1008
+ /**
1009
+ * @typedef {{ literal?: any, external?: string }[]} ParamCollector
1010
+ */