@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/emit.js ADDED
@@ -0,0 +1,393 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Plan → SQL through a dialect. This is the query layer's shared
4
+ * emitter, the same division of labour as `createDialect`'s DDL/DML
5
+ * builders: structural SQL composition lives here, every
6
+ * dialect-varying spelling (identifiers, parameters, string literals,
7
+ * JSON access, `json_type`, `typeof`, string-operator forms, NULLS
8
+ * placement, EXPLAIN phrasing) comes from the dialect. Values are
9
+ * NEVER interpolated into the text: every literal and every external
10
+ * becomes an ordered parameter slot, which is what makes injection
11
+ * structurally impossible.
12
+ *
13
+ * Every emitted predicate is TOTAL (two-valued) by construction — the
14
+ * `json_type` guards from the truth table in ARCHITECTURE.md — so
15
+ * `NOT`/`AND`/`OR` compose classically and SQL's three-valued NULL
16
+ * logic never decides a row.
17
+ */
18
+
19
+ /**
20
+ * @typedef {{ external: string } | { literal: unknown }} ParamSlot
21
+ */
22
+
23
+ /**
24
+ * Emit one plan as SQL plus its ordered parameter slots.
25
+ * @param {import('./algebra.js').Plan} plan
26
+ * @param {any} dialect
27
+ * @param {{ table: string, keyColumn: string, docColumn: string }} physical
28
+ * @returns {{ sql: string, slots: ParamSlot[] }}
29
+ */
30
+ export function emitPlan(plan, dialect, physical) {
31
+ const q = dialect.quoteIdentifier;
32
+ const docColumn = q(physical.docColumn);
33
+ /** @type {ParamSlot[]} */
34
+ const slots = [];
35
+ const param = (slot) => {
36
+ slots.push(slot);
37
+ return dialect.parameterRef(slots.length, 'external' in slot ? slot.external : 'value');
38
+ };
39
+
40
+ /** SQL for a ref's VALUE: the generated column when one exists. */
41
+ const valueOf = (ref) =>
42
+ (ref.column !== null ? q(ref.column) : dialect.jsonExtract(docColumn, pathTextOf(ref)));
43
+ const pathTextOf = (ref) => {
44
+ const text = dialect.jsonPathText(ref.segments);
45
+ if (text === null) {
46
+ // the planner never promotes an unrepresentable path; reaching
47
+ // this is an internal inconsistency, not a user error
48
+ throw new Error('emit: a promoted path is not representable in the dialect JSON path grammar');
49
+ }
50
+ return text;
51
+ };
52
+ /** The presence/type discriminator, always over the document column. */
53
+ const typeOf = (ref) => dialect.jsonTypeOf(docColumn, pathTextOf(ref));
54
+
55
+ const sl = dialect.stringLiteral;
56
+ const NUMERIC = () => `(${sl('integer')}, ${sl('real')})`;
57
+
58
+ /**
59
+ * The guarded, total comparison forms of the truth table.
60
+ * @param {any} pred
61
+ * @returns {string}
62
+ */
63
+ const emitCmp = (pred) => {
64
+ const jt = typeOf(pred.ref);
65
+ const value = valueOf(pred.ref);
66
+ const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
67
+ if ('lit' in pred.operand) {
68
+ const lit = pred.operand.lit;
69
+ const kind = typeof lit === 'number' ? 'number' : 'string';
70
+ const typeGuard = kind === 'number'
71
+ ? `${jt} IN ${NUMERIC()}`
72
+ : `${jt} = ${sl('text')}`;
73
+ if (pred.op === 'ne') {
74
+ // present AND (other type OR value differs): cross-type ne is
75
+ // true for a PRESENT value, false for a missing one
76
+ const notType = kind === 'number'
77
+ ? `${jt} NOT IN ${NUMERIC()}`
78
+ : `${jt} <> ${sl('text')}`;
79
+ return `(${jt} IS NOT NULL AND (${notType} OR ${value} <> ${param({ literal: lit })}))`;
80
+ }
81
+ // the presence prefix keeps the form TOTAL: `NULL IN (...)` is
82
+ // NULL, and a NULL escaping through a NOT flips a row's fate
83
+ return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
84
+ }
85
+ // external operand: its JSON type is only knowable at bind time —
86
+ // guard BOTH sides per branch (text with text, number with number)
87
+ const name = pred.operand.ext;
88
+ const textBranch = `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND `
89
+ + `${dialect.valueTypeOf(param({ external: name }))} = ${sl('text')} AND `
90
+ + `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
91
+ const numberBranch = `(${jt} IS NOT NULL AND ${jt} IN ${NUMERIC()} AND `
92
+ + `${dialect.valueTypeOf(param({ external: name }))} IN ${NUMERIC()} AND `
93
+ + `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
94
+ const equalInSomeBranch = `(${textBranch} OR ${numberBranch})`;
95
+ return pred.op === 'ne'
96
+ ? `(${jt} IS NOT NULL AND NOT ${equalInSomeBranch})`
97
+ : equalInSomeBranch;
98
+ };
99
+
100
+ /**
101
+ * @param {import('./algebra.js').PlanPredicate} pred
102
+ * @returns {string}
103
+ */
104
+ const emitPred = (pred) => {
105
+ switch (pred.p) {
106
+ case 'and':
107
+ return `(${pred.items.map(emitPred).join(' AND ')})`;
108
+ case 'or':
109
+ return `(${pred.items.map(emitPred).join(' OR ')})`;
110
+ case 'not':
111
+ return `NOT ${emitPred(pred.item)}`;
112
+ case 'const':
113
+ return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
114
+ case 'cmp':
115
+ return emitCmp(pred);
116
+ case 'typeIs': {
117
+ const jt = typeOf(pred.ref);
118
+ if (pred.types.length === 0) {
119
+ // bare existence: positive is $exists, negative is $empty
120
+ return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
121
+ }
122
+ const list = pred.types.map(sl).join(', ');
123
+ return pred.positive
124
+ ? (pred.types.length === 1
125
+ ? `(${jt} IS NOT NULL AND ${jt} = ${sl(pred.types[0])})`
126
+ : `(${jt} IS NOT NULL AND ${jt} IN (${list}))`)
127
+ : `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
128
+ }
129
+ case 'udf':
130
+ // the registered deterministic predicate: reads the row's
131
+ // document as JSON text, answers 1 or 0 (always total)
132
+ return `${pred.name}(${dialect.jsonText(docColumn)})`;
133
+ case 'strop': {
134
+ const jt = typeOf(pred.ref);
135
+ const value = valueOf(pred.ref);
136
+ const bind = () => param({ literal: /** @type {any} */ (pred.operand).lit });
137
+ const form = pred.kind === 'starts'
138
+ ? dialect.strStartsWith(value, bind(), bind())
139
+ : pred.kind === 'ends'
140
+ ? dialect.strEndsWith(value, bind(), bind(), bind())
141
+ : dialect.strContains(value, bind());
142
+ return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
143
+ }
144
+ default:
145
+ throw new Error(`emit: unknown predicate node '${/** @type {any} */ (pred).p}'`);
146
+ }
147
+ };
148
+
149
+ const selection = plan.aggregate === null
150
+ ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
151
+ : plan.aggregate.fn === 'count'
152
+ ? `COUNT(*) AS ${q('value')}`
153
+ : `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
154
+
155
+ let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
156
+ if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
157
+ if (plan.aggregate === null) {
158
+ const terms = (plan.order ?? []).map((term) => {
159
+ // Jaren's default sorts an empty key least: NULLS FIRST when
160
+ // ascending, NULLS LAST when descending — and mirrored for
161
+ // $empty: 'greatest' (probed against the engine)
162
+ const nullsFirst = term.emptyGreatest === term.desc;
163
+ return `${valueOf(term.ref)} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
164
+ });
165
+ // the collection is a SEQUENCE: its order is insertion (row
166
+ // identity) order, and the engine's sort is stable — the identity
167
+ // tiebreaker reproduces both, and without it the database is free
168
+ // to answer in index order
169
+ terms.push(dialect.rowIdentity());
170
+ sql += ` ORDER BY ${terms.join(', ')}`;
171
+ }
172
+ if (plan.window !== null && plan.aggregate === null) {
173
+ sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
174
+ }
175
+ return { sql, slots };
176
+ }
177
+
178
+ // ————— The entity document kind (one emitter layer, two kinds) —————
179
+
180
+ /**
181
+ * The entity predicate emitters, shared by the entity plan emitter
182
+ * and the graph-load builder: given an alias and its document column,
183
+ * emit one predicate with the flavor-correct forms.
184
+ * @param {any} dialect
185
+ * @param {(slot: ParamSlot) => string} param
186
+ * @returns {{ emitPred: (aliasSql: string, docSql: string, pred: any) => string }}
187
+ */
188
+ export function createEntityPredicateEmitters(dialect, param) {
189
+ const q = dialect.quoteIdentifier;
190
+ const sl = dialect.stringLiteral;
191
+ const NUMERIC = () => `(${sl('integer')}, ${sl('real')})`;
192
+ const pathTextOf = (ref) => {
193
+ const text = dialect.jsonPathText(ref.segments);
194
+ if (text === null)
195
+ throw new Error('emit: a promoted entity path is not representable');
196
+ return text;
197
+ };
198
+
199
+ const emitDocPred = (docSql, pred) => {
200
+ const jt = dialect.jsonTypeOf(docSql, pathTextOf(pred.ref));
201
+ const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
202
+ if (pred.p === 'typeIs') {
203
+ if (pred.types.length === 0)
204
+ return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
205
+ const list = pred.types.map(sl).join(', ');
206
+ return pred.positive
207
+ ? `(${jt} IS NOT NULL AND ${pred.types.length === 1
208
+ ? `${jt} = ${sl(pred.types[0])}` : `${jt} IN (${list})`})`
209
+ : `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
210
+ }
211
+ if (pred.p === 'strop') {
212
+ const bind = () => param({ literal: pred.operand.lit });
213
+ const form = pred.kind === 'starts'
214
+ ? dialect.strStartsWith(value, bind(), bind())
215
+ : pred.kind === 'ends'
216
+ ? dialect.strEndsWith(value, bind(), bind(), bind())
217
+ : dialect.strContains(value, bind());
218
+ return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
219
+ }
220
+ const lit = pred.operand.lit;
221
+ const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
222
+ const kind = typeof lit === 'number' ? 'number' : 'string';
223
+ if (pred.op === 'ne') {
224
+ const notType = kind === 'number'
225
+ ? `${jt} NOT IN ${NUMERIC()}` : `${jt} <> ${sl('text')}`;
226
+ return `(${jt} IS NOT NULL AND (${notType} OR ${value} <> ${param({ literal: lit })}))`;
227
+ }
228
+ const typeGuard = kind === 'number'
229
+ ? `${jt} IN ${NUMERIC()}` : `${jt} = ${sl('text')}`;
230
+ return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
231
+ };
232
+
233
+ const emitColumnPred = (aliasSql, pred) => {
234
+ const column = `${aliasSql}.${q(pred.ref.column)}`;
235
+ if (pred.p === 'typeIs') {
236
+ if (pred.types.length === 0)
237
+ return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
238
+ if (pred.types[0] === 'null')
239
+ return pred.positive ? dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
240
+ const wanted = pred.types[0] === 'true' ? 1 : 0;
241
+ return pred.positive
242
+ ? `(${column} IS NOT NULL AND ${column} = ${param({ literal: wanted })})`
243
+ : `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
244
+ }
245
+ if (pred.p === 'strop') {
246
+ const bind = () => param({ literal: pred.operand.lit });
247
+ const form = pred.kind === 'starts'
248
+ ? dialect.strStartsWith(column, bind(), bind())
249
+ : pred.kind === 'ends'
250
+ ? dialect.strEndsWith(column, bind(), bind(), bind())
251
+ : dialect.strContains(column, bind());
252
+ return `(${column} IS NOT NULL AND ${form})`;
253
+ }
254
+ const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
255
+ if ('ext' in pred.operand) {
256
+ const guard = pred.ref.storage === 'string'
257
+ ? `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} = ${sl('text')}`
258
+ : `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} IN ${NUMERIC()}`;
259
+ return `(${column} IS NOT NULL AND ${guard} AND ${column} ${pred.op === 'ne' ? '<>' : symbol} ${param({ external: pred.operand.ext })})`;
260
+ }
261
+ const lit = pred.operand.lit;
262
+ const litKind = typeof lit === 'number' ? 'number' : typeof lit === 'string' ? 'string' : 'other';
263
+ const storageKind = pred.ref.storage === 'string' ? 'string'
264
+ : pred.ref.storage === 'boolean' ? 'boolean' : 'number';
265
+ if (storageKind === 'boolean' || litKind === 'other' || storageKind !== litKind)
266
+ return pred.op === 'ne' ? `${column} IS NOT NULL` : dialect.booleanLiteral(false);
267
+ return `(${column} IS NOT NULL AND ${column} ${symbol} ${param({ literal: lit })})`;
268
+ };
269
+
270
+ // an epoch comparison: the derived integer column narrows through
271
+ // its index with ±1s slack (Z-normalized strings sharing a second
272
+ // prefix sit within one second, so the range is a superset of the
273
+ // codepoint comparison), and the document string decides exactly —
274
+ // the engine's lexicographic semantics, whatever precision the
275
+ // stored values carry
276
+ const emitEpochPred = (aliasSql, docSql, pred) => {
277
+ const column = `${aliasSql}.${q(pred.ref.column)}`;
278
+ const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
279
+ const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
280
+ const range = pred.op === 'gt' || pred.op === 'ge'
281
+ ? `${column} >= ${param({ literal: pred.epoch - 1000 })}`
282
+ : pred.op === 'lt' || pred.op === 'le'
283
+ ? `${column} <= ${param({ literal: pred.epoch + 1000 })}`
284
+ : `${column} >= ${param({ literal: pred.epoch - 1000 })} AND ${column} <= ${param({ literal: pred.epoch + 1000 })}`;
285
+ return `(${column} IS NOT NULL AND ${range} AND ${value} ${symbol} ${param({ literal: pred.operand.lit })})`;
286
+ };
287
+
288
+ const emitPred = (aliasSql, docSql, pred) => {
289
+ if (pred.p === 'and')
290
+ return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' AND ')})`;
291
+ if (pred.p === 'or')
292
+ return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' OR ')})`;
293
+ if (pred.p === 'not') return `NOT ${emitPred(aliasSql, docSql, pred.item)}`;
294
+ if (pred.p === 'const')
295
+ return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
296
+ if (pred.ref?.flavor === 'entity-column')
297
+ return emitColumnPred(aliasSql, pred);
298
+ if (pred.ref?.flavor === 'entity-epoch') {
299
+ // only an instant comparison uses the column; presence, type
300
+ // tests, string operators and non-instant literals ride the
301
+ // guarded document forms, which stay sound for any stored value
302
+ if (pred.p === 'cmp' && 'epoch' in pred)
303
+ return emitEpochPred(aliasSql, docSql, pred);
304
+ return emitDocPred(docSql, pred);
305
+ }
306
+ return emitDocPred(docSql, pred);
307
+ };
308
+ return { emitPred };
309
+ }
310
+
311
+ /**
312
+ * Emit an entity plan (`entity-select` or `entity-join`) as SQL plus
313
+ * ordered parameter slots. Entity-COLUMN refs compare real typed
314
+ * columns with TOTAL forms and no `json_type` guard — a column-mapped
315
+ * property has no present-`null` (§9.3), so presence IS `IS NOT
316
+ * NULL`; entity-EPOCH refs compare the derived integer column against
317
+ * a plan-time epoch translation; entity-DOC refs ride the phase-A
318
+ * guarded truth table over the entity's JSONB column. Join emission
319
+ * appends BOTH bindings' row identities in binding order, which is
320
+ * exactly the engine's nested-loop order — determinism the oracle
321
+ * depends on.
322
+ * @param {any} plan - from `planEntityQuery`
323
+ * @param {any} dialect
324
+ * @param {(entity: string) => { table: string }} physicalOf
325
+ * @returns {{ sql: string, slots: ParamSlot[] }}
326
+ */
327
+ export function emitEntityPlan(plan, dialect, physicalOf) {
328
+ const q = dialect.quoteIdentifier;
329
+ /** @type {ParamSlot[]} */
330
+ const slots = [];
331
+ const param = (slot) => {
332
+ slots.push(slot);
333
+ return dialect.parameterRef(slots.length, 'external' in slot ? slot.external : 'value');
334
+ };
335
+
336
+ const aliases = new Map(plan.bindings.map((binding, i) => [
337
+ binding.name, { alias: q(`t${i}`), entity: binding.entity },
338
+ ]));
339
+ const aliasOf = (bindingName) => aliases.get(bindingName).alias;
340
+ const docOf = (bindingName) => `${aliasOf(bindingName)}.${q('doc')}`;
341
+
342
+ const pathTextOf = (ref) => {
343
+ const text = dialect.jsonPathText(ref.segments);
344
+ if (text === null)
345
+ throw new Error('emit: a promoted entity path is not representable');
346
+ return text;
347
+ };
348
+
349
+ const emitters = createEntityPredicateEmitters(dialect, param);
350
+ const emitPred = (bindingName, pred) =>
351
+ emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
352
+
353
+ const ret = plan.ret;
354
+ // every returned column plus the document rendered to text; the
355
+ // caller merges them back into the entity shape
356
+ const selection = plan.aggregate === 'count'
357
+ ? `COUNT(*) AS ${q('value')}`
358
+ : `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
359
+
360
+ let sql = `SELECT ${selection} FROM `;
361
+ sql += plan.bindings
362
+ .map((binding) => `${q(physicalOf(binding.entity).table)} AS ${aliasOf(binding.name)}`)
363
+ .join(' JOIN ');
364
+ if (plan.joinOn !== null) {
365
+ sql += ` ON ${aliasOf(plan.joinOn.left.binding)}.${q(plan.joinOn.left.column)}`
366
+ + ` = ${aliasOf(plan.joinOn.right.binding)}.${q(plan.joinOn.right.column)}`;
367
+ }
368
+ const filterSql = plan.filters
369
+ .filter((entry) => entry.filter !== null)
370
+ .map((entry) => emitPred(entry.binding, entry.filter));
371
+ if (filterSql.length > 0) sql += ` WHERE ${filterSql.join(' AND ')}`;
372
+
373
+ if (plan.aggregate === null) {
374
+ const terms = (plan.order ?? []).map((term) => {
375
+ // only a plain mapped column orders by its column; an epoch
376
+ // path orders by the document string — codepoint order, exactly
377
+ // the engine's — because mixed stored precisions would let the
378
+ // integer column sort differently
379
+ const value = term.ref.flavor === 'entity-column'
380
+ ? `${aliasOf(term.binding)}.${q(term.ref.column)}`
381
+ : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref));
382
+ const nullsFirst = term.emptyGreatest === term.desc;
383
+ return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
384
+ });
385
+ // the engine's nested-loop order: binding-order row identities
386
+ for (const binding of plan.bindings)
387
+ terms.push(`${aliasOf(binding.name)}.${dialect.rowIdentity()}`);
388
+ sql += ` ORDER BY ${terms.join(', ')}`;
389
+ if (plan.window !== null)
390
+ sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
391
+ }
392
+ return { sql, slots };
393
+ }