@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/emit.js CHANGED
@@ -18,11 +18,20 @@
18
18
 
19
19
  import { codePointPrefixSuccessor } from '@jarenjs/core/string';
20
20
 
21
+ /**
22
+ * A promoted path the dialect's JSON path grammar cannot spell (a
23
+ * member name holding a double quote or a control character). The
24
+ * planner promotes by SCHEMA, not by grammar, so the query engine
25
+ * catches this and runs the document in the set residual instead.
26
+ */
27
+ export class UnrepresentablePath extends Error {}
28
+
21
29
  /**
22
30
  * @typedef {{ external: string } | { literal: unknown } |
23
31
  * { derived: { kind: 'bboxAxis', external: string,
24
- * axis: 'w' | 's' | 'e' | 'n' } }} ParamSlot
25
- * Three kinds, closed. A DERIVED slot is the escape for a value SQL
32
+ * axis: 'w' | 's' | 'e' | 'n' } } |
33
+ * { typed: { seek: string, type: 'number' | 'text' } }} ParamSlot
34
+ * Four kinds, closed. A DERIVED slot is the escape for a value SQL
26
35
  * cannot bind at all: a GeoJSON region arrives as an external object,
27
36
  * and what the statement needs is one edge of its bounding box, so
28
37
  * the binder computes that edge from the bound value. It is the same
@@ -30,6 +39,14 @@ import { codePointPrefixSuccessor } from '@jarenjs/core/string';
30
39
  * cannot, bind an ordinary parameter — and it stays closed on
31
40
  * purpose: a general expression slot would be a second query language
32
41
  * living in the emitter.
42
+ *
43
+ * A TYPED slot is the other direction: a scalar whose JSON type is
44
+ * PROVEN before the statement binds — the database's own answer to
45
+ * one of the plan's seeks, read from a column of declared type. An
46
+ * external's type is only knowable at bind time, so its comparison
47
+ * carries a text branch beside a number branch; a typed slot carries
48
+ * one guarded comparison, the same shape a literal gets, and the
49
+ * binder refuses a value of the wrong type before any SQL runs.
33
50
  */
34
51
 
35
52
  /**
@@ -69,6 +86,7 @@ function stropForm(dialect, param, valueSql, pred) {
69
86
  function slotName(slot) {
70
87
  if ('external' in slot) return slot.external;
71
88
  if ('derived' in slot) return slot.derived.external;
89
+ if ('typed' in slot) return slot.typed.seek;
72
90
  return 'value';
73
91
  }
74
92
 
@@ -106,28 +124,112 @@ export function emitPlan(plan, dialect, physical) {
106
124
  const docColumn = q(physical.docColumn);
107
125
  /** @type {ParamSlot[]} */
108
126
  const slots = [];
127
+ // the statement being emitted owns its slots: a positional dialect
128
+ // numbers by that statement's own text order, so a SEEK emitted
129
+ // beside the main statement numbers from one again
130
+ let sink = slots;
109
131
  const param = (slot) => {
110
- slots.push(slot);
111
- return dialect.parameterRef(slots.length, slotName(slot));
132
+ sink.push(slot);
133
+ return dialect.parameterRef(sink.length, slotName(slot));
112
134
  };
113
135
 
114
- /** SQL for a ref's VALUE: the generated column when one exists. */
115
- const valueOf = (ref) =>
116
- (ref.column !== null ? q(ref.column) : dialect.jsonExtract(docColumn, pathTextOf(ref)));
136
+ /**
137
+ * SQL for a ref's VALUE: the generated column when one exists AND
138
+ * this dialect can compare that column's declared type against a
139
+ * value of `kind`. Where it cannot — an engine whose columns carry a
140
+ * real SQL type, asked to compare a text column with a number — the
141
+ * member is read out of the document instead. Same answer, unindexed,
142
+ * and never a type error the row's own type guard already excludes.
143
+ * @param {any} ref
144
+ * @param {'any' | 'text' | 'number' | 'boolean'} [kind]
145
+ */
146
+ const valueOf = (ref, kind = 'any') =>
147
+ (ref.column !== null && dialect.columnUsableFor(ref.type, kind)
148
+ ? q(ref.column)
149
+ : dialect.jsonExtract(docColumn, pathTextOf(ref), kind));
117
150
  const pathTextOf = (ref) => {
118
151
  const text = dialect.jsonPathText(ref.segments);
119
152
  if (text === null) {
120
- // the planner never promotes an unrepresentable path; reaching
121
- // this is an internal inconsistency, not a user error
122
- throw new Error('emit: a promoted path is not representable in the dialect JSON path grammar');
153
+ throw new UnrepresentablePath('a member name the dialect\'s JSON path grammar cannot '
154
+ + 'carry (a double quote or a control character) runs in the residual');
123
155
  }
124
156
  return text;
125
157
  };
126
158
  /** The presence/type discriminator, always over the document column. */
127
159
  const typeOf = (ref) => dialect.jsonTypeOf(docColumn, pathTextOf(ref));
160
+ /** One projected member: its value beside its JSON type, under a
161
+ * suffixed pair of names the decoder reads back. */
162
+ const projectedPair = (ref, suffix) =>
163
+ `CASE WHEN ${typeOf(ref)} IN (${sl('object')}, ${sl('array')}) `
164
+ + `THEN ${dialect.jsonText(dialect.jsonExtract(docColumn, pathTextOf(ref)))} `
165
+ // a SCALAR leaf comes back as the value the decoder reads: the
166
+ // engine's own scalar where it has one, its text where every
167
+ // member is one JSON type and a CASE could not answer two
168
+ + `ELSE ${dialect.jsonExtract(docColumn, pathTextOf(ref), 'scalar')} `
169
+ + `END AS ${q(`v${suffix}`)}, ${typeOf(ref)} AS ${q(`t${suffix}`)}`;
128
170
 
129
171
  const sl = dialect.stringLiteral;
130
- const NUMERIC = () => `(${sl('integer')}, ${sl('real')})`;
172
+ /** The discriminator's spellings for a JSON NUMBER, as this engine
173
+ * answers them: SQLite keeps `integer` and `real` apart, an engine
174
+ * with one JSON number type answers one name. */
175
+ const NUMERIC = () => `(${dialect.numericTypeNames.map(sl).join(', ')})`;
176
+
177
+ /** The comparison kind a member's DECLARED schema type implies —
178
+ * what a fold or an ordering over it reads the member as. An
179
+ * undeclared type has none, and the member is read whole. */
180
+ const kindOf = (ref) => {
181
+ switch (ref?.type) {
182
+ case 'integer': case 'number': return 'number';
183
+ case 'string': return 'text';
184
+ case 'boolean': return 'boolean';
185
+ default: return 'any';
186
+ }
187
+ };
188
+
189
+ /**
190
+ * One aggregate's argument, read at the kind the FOLD needs rather
191
+ * than at the member's own: a sum or an average is arithmetic
192
+ * whatever the schema says, an extreme is the member's own ordering.
193
+ *
194
+ * An engine whose columns carry a real SQL type has no fold over a
195
+ * member with no declared type — there is no `SUM` of a JSON value —
196
+ * so the whole document runs in the set residual, named, rather than
197
+ * reaching the database as SQL it will refuse.
198
+ * @param {string} fn
199
+ * @param {any} ref
200
+ * @returns {string | null}
201
+ */
202
+ const foldValue = (fn, ref) => {
203
+ if (ref === null || ref === undefined) return null;
204
+ const kind = fn === 'sum' || fn === 'avg' ? 'number' : kindOf(ref);
205
+ if (kind === 'any' && dialect.capabilities.untypedColumns !== true) {
206
+ throw new UnrepresentablePath(
207
+ `a ${fn} over a member the schema does not type has no fold on this engine`);
208
+ }
209
+ return valueOf(ref, kind);
210
+ };
211
+
212
+ /**
213
+ * One reference to an EXTERNAL's bound value. The slot carries the
214
+ * dialect's encoding with it, because the binder has no dialect: a
215
+ * `json` slot binds the value's JSON text, which is the one encoding
216
+ * a placeholder can hold whatever the member's type turns out to be.
217
+ * @param {string} name
218
+ * @returns {string}
219
+ */
220
+ const externalSlot = (name) => param(dialect.externalEncoding === 'json'
221
+ ? { external: name, json: true }
222
+ : { external: name });
223
+
224
+ /** The slot a bare column comparison binds through: a plan-time
225
+ * literal, or the scalar one of the plan's own seeks answers. An
226
+ * external is not among them — its type is unknowable at plan time,
227
+ * which is exactly what the guarded form exists for. */
228
+ const slotFor = (operand) => {
229
+ if ('lit' in operand) return { literal: operand.lit };
230
+ const seek = (plan.seeks ?? []).find((entry) => entry.name === operand.seek);
231
+ return { typed: { seek: seek.name, type: seek.kind } };
232
+ };
131
233
 
132
234
  /**
133
235
  * The guarded, total comparison forms of the truth table.
@@ -136,11 +238,11 @@ export function emitPlan(plan, dialect, physical) {
136
238
  */
137
239
  const emitCmp = (pred) => {
138
240
  const jt = typeOf(pred.ref);
139
- const value = valueOf(pred.ref);
140
241
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
141
242
  if ('lit' in pred.operand) {
142
243
  const lit = pred.operand.lit;
143
- const kind = typeof lit === 'number' ? 'number' : 'string';
244
+ const kind = typeof lit === 'number' ? 'number' : 'text';
245
+ const value = valueOf(pred.ref, kind);
144
246
  const typeGuard = kind === 'number'
145
247
  ? `${jt} IN ${NUMERIC()}`
146
248
  : `${jt} = ${sl('text')}`;
@@ -156,15 +258,33 @@ export function emitPlan(plan, dialect, physical) {
156
258
  // NULL, and a NULL escaping through a NOT flips a row's fate
157
259
  return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
158
260
  }
261
+ if ('seek' in pred.operand) {
262
+ // a seek's scalar comes from a column of DECLARED type, so its
263
+ // JSON type is known here: one guarded comparison, no branch
264
+ const seek = (plan.seeks ?? []).find((entry) => entry.name === pred.operand.seek);
265
+ const value = valueOf(pred.ref, seek.kind);
266
+ const typeGuard = seek.kind === 'number'
267
+ ? `${jt} IN ${NUMERIC()}`
268
+ : `${jt} = ${sl('text')}`;
269
+ return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} `
270
+ + `${param({ typed: { seek: seek.name, type: seek.kind } })})`;
271
+ }
159
272
  // external operand: its JSON type is only knowable at bind time —
160
- // guard BOTH sides per branch (text with text, number with number)
273
+ // guard BOTH sides per branch (text with text, number with number),
274
+ // and read the member at the branch's own kind. Both sides go
275
+ // through the dialect's external forms, because on an engine whose
276
+ // parameters carry a type the guard does not stop the coercion: the
277
+ // value is bound as JSON there and compared in JSON space
161
278
  const name = pred.operand.ext;
279
+ const external = () => externalSlot(name);
280
+ const compare = (kind) => dialect.externalCompare(valueOf(pred.ref, kind), kind);
281
+ const against = (kind) => dialect.externalRef(external(), kind);
162
282
  const textBranch = `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND `
163
- + `${dialect.valueTypeOf(param({ external: name }))} = ${sl('text')} AND `
164
- + `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
283
+ + `${dialect.valueTypeOf(external())} = ${sl('text')} AND `
284
+ + `${compare('text')} ${pred.op === 'ne' ? '=' : symbol} ${against('text')})`;
165
285
  const numberBranch = `(${jt} IS NOT NULL AND ${jt} IN ${NUMERIC()} AND `
166
- + `${dialect.valueTypeOf(param({ external: name }))} IN ${NUMERIC()} AND `
167
- + `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
286
+ + `${dialect.valueTypeOf(external())} IN ${NUMERIC()} AND `
287
+ + `${compare('number')} ${pred.op === 'ne' ? '=' : symbol} ${against('number')})`;
168
288
  const equalInSomeBranch = `(${textBranch} OR ${numberBranch})`;
169
289
  return pred.op === 'ne'
170
290
  ? `(${jt} IS NOT NULL AND NOT ${equalInSomeBranch})`
@@ -187,6 +307,23 @@ export function emitPlan(plan, dialect, physical) {
187
307
  return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
188
308
  case 'cmp':
189
309
  return emitCmp(pred);
310
+ case 'colCmp': {
311
+ const column = q(pred.column);
312
+ const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
313
+ return `(${column} IS NOT NULL AND ${column} ${symbol} `
314
+ + `${param(slotFor(pred.operand))})`;
315
+ }
316
+ case 'interval': {
317
+ // the declared bounds ARE the values (§8.16's precondition is a
318
+ // schema one), so no `json_type` guard reads the document per
319
+ // row; `IS NOT NULL` keeps the form total for a row with no span
320
+ const start = q(pred.columns.start);
321
+ const end = q(pred.columns.end);
322
+ return `(${start} IS NOT NULL AND ${end} IS NOT NULL AND `
323
+ + `((${start} < ${param({ literal: pred.probe.to })} `
324
+ + `AND ${end} > ${param({ literal: pred.probe.from })}) `
325
+ + `OR ${start} >= ${end}))`;
326
+ }
190
327
  case 'typeIs': {
191
328
  const jt = typeOf(pred.ref);
192
329
  if (pred.types.length === 0) {
@@ -202,11 +339,13 @@ export function emitPlan(plan, dialect, physical) {
202
339
  }
203
340
  case 'udf':
204
341
  // the registered deterministic predicate: reads the row's
205
- // document as JSON text, answers 1 or 0 (always total)
206
- return `${pred.name}(${dialect.jsonText(docColumn)})`;
342
+ // document as JSON text, answers 1 or 0 (always total); the
343
+ // second argument is the conjunct's place in the caller's
344
+ // document, a literal the function reports an engine error at
345
+ return `${pred.name}(${dialect.jsonText(docColumn)}, ${sl(pred.mount ?? '/$where')})`;
207
346
  case 'strop': {
208
347
  const jt = typeOf(pred.ref);
209
- const form = stropForm(dialect, param, valueOf(pred.ref), pred);
348
+ const form = stropForm(dialect, param, valueOf(pred.ref, 'text'), pred);
210
349
  return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
211
350
  }
212
351
  case 'bboxOverlap': {
@@ -290,20 +429,64 @@ export function emitPlan(plan, dialect, physical) {
290
429
  // engine scores, cuts and ranks (measured: every SQL spelling of
291
430
  // the rank loses to fetching the column and ranking in the engine,
292
431
  // and none of them runs where no function can be registered)
293
- ? `${dialect.rowIdentity()} AS ${q('rid')}, ${q(plan.rank.column)} AS ${q('vec')}`
294
- : plan.bucket !== null
295
- ? [`${bucketSql} AS ${q(plan.bucket.as)}`,
432
+ // one alternative per emitted statement: the caller emits the plan
433
+ // once per declared width, and each carries its own column
434
+ ? `${dialect.rowIdentity()} AS ${q('rid')}, `
435
+ + `${q(plan.rank.alternatives[0].column)} AS ${q('vec')}`
436
+ : plan.group !== null
437
+ ? [...plan.group.keys.map((key, i) => projectedPair(key.ref, `k${i}`)),
438
+ ...plan.group.aggregates.map((entry, i) =>
439
+ `${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
440
+ + `AS ${q(`a${i}`)}`)].join(', ')
441
+ : plan.bucket !== null
442
+ ? [`${bucketSql} AS ${q(plan.bucket.as)}`,
296
443
  ...plan.bucket.aggregates.map((entry) =>
297
- `${dialect.groupAggregate(entry.fn,
298
- entry.ref === null ? null : valueOf(entry.ref))} AS ${q(entry.as)}`)].join(', ')
444
+ `${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
445
+ + `AS ${q(entry.as)}`)].join(', ')
299
446
  : plan.aggregate === null
300
- ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
447
+ ? (plan.project === 'document'
448
+ ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
449
+ // one member path: its value and its JSON type. A scalar is
450
+ // the extracted SQL value itself; an object or array is
451
+ // rendered to JSON text, since the binary extraction of a
452
+ // compound is a blob. `NULL` type is an absent member (no
453
+ // item), 'null' a present null, 'true'/'false' a boolean the
454
+ // integer rendering would otherwise lose
455
+ : 'path' in plan.project
456
+ ? projectedPair(plan.project.path, '')
457
+ // a projection TREE: the same value/type pair per DISTINCT
458
+ // leaf, numbered, and nothing else — the document blob is
459
+ // never selected, and a leaf named twice is fetched once
460
+ : plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', '))
301
461
  : plan.aggregate.fn === 'count'
302
462
  ? `COUNT(*) AS ${q('value')}`
303
- : `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
463
+ // a REGISTERED aggregate calls the function the store
464
+ // registered under the plan's name; the fold is the pack's own
465
+ : plan.aggregate.fn === 'registered'
466
+ ? `${plan.aggregate.sql}(${valueOf(plan.aggregate.ref,
467
+ kindOf(plan.aggregate.ref))}) AS ${q('value')}`
468
+ : `${plan.aggregate.fn.toUpperCase()}(`
469
+ + `${foldValue(plan.aggregate.fn, plan.aggregate.ref)}) AS ${q('value')}`;
304
470
 
305
471
  let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
306
472
  if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
473
+ if (plan.group !== null) {
474
+ // BY THE ALIASES the selection named, not by a second spelling of
475
+ // the same member. Two reasons, and the second is the load-bearing
476
+ // one: a key is a value/type PAIR (a JSON `1` and a JSON `"1"` are
477
+ // different keys and render the same text), and an engine that
478
+ // checks its grouping refuses a selected expression the GROUP BY
479
+ // does not cover — which every key's type discriminator would be.
480
+ sql += ` GROUP BY ${plan.group.keys
481
+ .map((key, i) => `${q(`vk${i}`)}, ${q(`tk${i}`)}`).join(', ')}`;
482
+ // the groups' order: the engine's own order of first appearance —
483
+ // over a collection, each group's earliest row identity — or the
484
+ // key ordering an `$orderby` declared
485
+ sql += ` ORDER BY ${plan.group.order === 'first-seen'
486
+ ? dialect.groupAggregate('min', dialect.rowIdentity())
487
+ : plan.group.order.map((term) => `${q(`vk${term.index}`)} `
488
+ + `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`).join(', ')}`;
489
+ }
307
490
  if (plan.bucket !== null) {
308
491
  // `first-seen` is the engine's own group order (§6.5, first
309
492
  // appearance), which over a collection is the group's earliest row
@@ -314,13 +497,15 @@ export function emitPlan(plan, dialect, physical) {
314
497
  : `${alias} ${plan.bucket.order === 'desc' ? 'DESC' : 'ASC'}`;
315
498
  sql += ` GROUP BY ${alias} ORDER BY ${order}`;
316
499
  }
317
- if (plan.aggregate === null && plan.rank === null && plan.bucket === null) {
500
+ if (plan.aggregate === null && plan.rank === null && plan.bucket === null
501
+ && plan.group === null) {
318
502
  const terms = (plan.order ?? []).map((term) => {
319
503
  // Jaren's default sorts an empty key least: NULLS FIRST when
320
504
  // ascending, NULLS LAST when descending — and mirrored for
321
505
  // $empty: 'greatest' (probed against the engine)
322
506
  const nullsFirst = term.emptyGreatest === term.desc;
323
- return `${valueOf(term.ref)} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
507
+ return `${valueOf(term.ref, kindOf(term.ref))} `
508
+ + `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
324
509
  });
325
510
  // the collection is a SEQUENCE: its order is insertion (row
326
511
  // identity) order, and the engine's sort is stable — the identity
@@ -329,10 +514,52 @@ export function emitPlan(plan, dialect, physical) {
329
514
  terms.push(dialect.rowIdentity());
330
515
  sql += ` ORDER BY ${terms.join(', ')}`;
331
516
  }
332
- if (plan.window !== null && plan.aggregate === null) {
517
+ if (plan.window !== null && plan.aggregate === null && plan.group === null) {
333
518
  sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
334
519
  }
335
- return { sql, slots };
520
+ return { sql, slots, seeks: (plan.seeks ?? []).map((seek) => emitSeek(seek)) };
521
+
522
+ /**
523
+ * One seek statement: the extreme instant each group carries on the
524
+ * near side of the probe, folded to the one scalar every group's
525
+ * answer is beyond. Ungrouped, the inner fold IS the answer.
526
+ * @param {import('./algebra.js').PlanSeek} seek
527
+ * @returns {{ name: string, kind: 'number' | 'text',
528
+ * sql: string, slots: ParamSlot[] }}
529
+ */
530
+ function emitSeek(seek) {
531
+ /** @type {ParamSlot[]} */
532
+ const own = [];
533
+ const outer = sink;
534
+ sink = own;
535
+ try {
536
+ // the seek reads the same declared columns the bound it fills does
537
+ const colCmp = (column, op, lit) =>
538
+ ({ p: 'colCmp', op, column, operand: { lit } });
539
+ /** @type {import('./algebra.js').PlanPredicate} */
540
+ let filter = colCmp(seek.ref.column, seek.bound.op, seek.bound.lit);
541
+ if (seek.group !== null && seek.keys !== null && seek.keys.length > 0) {
542
+ filter = { p: 'and', items: [filter, seek.keys.length === 1
543
+ ? colCmp(seek.group.column, 'eq', seek.keys[0])
544
+ : { p: 'or', items: seek.keys.map((key) =>
545
+ colCmp(seek.group.column, 'eq', key)) }] };
546
+ }
547
+ const inner = `${seek.inner.toUpperCase()}(${valueOf(seek.ref, seek.kind)})`;
548
+ const where = ` FROM ${q(physical.table)} WHERE ${emitPred(filter)}`;
549
+ const text = seek.group === null
550
+ ? `SELECT ${inner} AS ${q('anchor')}${where}`
551
+ : `SELECT ${seek.outer.toUpperCase()}(${q('a')}) AS ${q('anchor')} FROM `
552
+ + `(SELECT ${inner} AS ${q('a')}${where} `
553
+ + `GROUP BY ${valueOf(seek.group, kindOf(seek.group))})`;
554
+ // a seek that finds nothing binds its own probe: it proved there
555
+ // is no row on that side, so the bound excludes only what is absent
556
+ return { name: seek.name, kind: seek.kind, fallback: seek.bound.lit,
557
+ sql: text, slots: own };
558
+ }
559
+ finally {
560
+ sink = outer;
561
+ }
562
+ }
336
563
  }
337
564
 
338
565
  // ————— The entity document kind (one emitter layer, two kinds) —————
@@ -348,7 +575,11 @@ export function emitPlan(plan, dialect, physical) {
348
575
  export function createEntityPredicateEmitters(dialect, param) {
349
576
  const q = dialect.quoteIdentifier;
350
577
  const sl = dialect.stringLiteral;
351
- const NUMERIC = () => `(${sl('integer')}, ${sl('real')})`;
578
+ const NUMERIC = () => `(${dialect.numericTypeNames.map(sl).join(', ')})`;
579
+ const externalSlot = (name) => param(dialect.externalEncoding === 'json'
580
+ ? { external: name, json: true }
581
+ : { external: name });
582
+
352
583
  const pathTextOf = (ref) => {
353
584
  const text = dialect.jsonPathText(ref.segments);
354
585
  if (text === null)
@@ -356,9 +587,14 @@ export function createEntityPredicateEmitters(dialect, param) {
356
587
  return text;
357
588
  };
358
589
 
590
+ /** The member at a ref, read as the SQL a comparison of that KIND
591
+ * needs. On a dynamically typed engine every kind is the same read;
592
+ * on one whose columns carry a real type they are four. */
593
+ const memberAt = (docSql, ref, kind) =>
594
+ dialect.jsonExtract(docSql, pathTextOf(ref), kind);
595
+
359
596
  const emitDocPred = (docSql, pred) => {
360
597
  const jt = dialect.jsonTypeOf(docSql, pathTextOf(pred.ref));
361
- const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
362
598
  if (pred.p === 'typeIs') {
363
599
  if (pred.types.length === 0)
364
600
  return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
@@ -369,12 +605,13 @@ export function createEntityPredicateEmitters(dialect, param) {
369
605
  : `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
370
606
  }
371
607
  if (pred.p === 'strop') {
372
- const form = stropForm(dialect, param, value, pred);
608
+ const form = stropForm(dialect, param, memberAt(docSql, pred.ref, 'text'), pred);
373
609
  return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
374
610
  }
375
611
  const lit = pred.operand.lit;
376
612
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
377
- const kind = typeof lit === 'number' ? 'number' : 'string';
613
+ const kind = typeof lit === 'number' ? 'number' : 'text';
614
+ const value = memberAt(docSql, pred.ref, kind);
378
615
  if (pred.op === 'ne') {
379
616
  const notType = kind === 'number'
380
617
  ? `${jt} NOT IN ${NUMERIC()}` : `${jt} <> ${sl('text')}`;
@@ -403,10 +640,13 @@ export function createEntityPredicateEmitters(dialect, param) {
403
640
  }
404
641
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
405
642
  if ('ext' in pred.operand) {
406
- const guard = pred.ref.storage === 'string'
407
- ? `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} = ${sl('text')}`
408
- : `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} IN ${NUMERIC()}`;
409
- return `(${column} IS NOT NULL AND ${guard} AND ${column} ${pred.op === 'ne' ? '<>' : symbol} ${param({ external: pred.operand.ext })})`;
643
+ const kind = pred.ref.storage === 'string' ? 'text' : 'number';
644
+ const guard = kind === 'text'
645
+ ? `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} = ${sl('text')}`
646
+ : `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} IN ${NUMERIC()}`;
647
+ return `(${column} IS NOT NULL AND ${guard} AND `
648
+ + `${dialect.externalCompare(column, kind)} ${pred.op === 'ne' ? '<>' : symbol} `
649
+ + `${dialect.externalRef(externalSlot(pred.operand.ext), kind)})`;
410
650
  }
411
651
  const lit = pred.operand.lit;
412
652
  const litKind = typeof lit === 'number' ? 'number' : typeof lit === 'string' ? 'string' : 'other';
@@ -425,7 +665,9 @@ export function createEntityPredicateEmitters(dialect, param) {
425
665
  // stored values carry
426
666
  const emitEpochPred = (aliasSql, docSql, pred) => {
427
667
  const column = `${aliasSql}.${q(pred.ref.column)}`;
428
- const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
668
+ // the stored TEXT decides: an instant's codepoint comparison is
669
+ // exactly the engine's, whatever precision the value carries
670
+ const value = memberAt(docSql, pred.ref, 'text');
429
671
  const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
430
672
  const range = pred.op === 'gt' || pred.op === 'ge'
431
673
  ? `${column} >= ${param({ literal: pred.epoch - 1000 })}`
@@ -476,6 +718,7 @@ export function createEntityPredicateEmitters(dialect, param) {
476
718
  */
477
719
  export function emitEntityPlan(plan, dialect, physicalOf) {
478
720
  const q = dialect.quoteIdentifier;
721
+ const sl = dialect.stringLiteral;
479
722
  /** @type {ParamSlot[]} */
480
723
  const slots = [];
481
724
  const param = (slot) => {
@@ -496,25 +739,80 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
496
739
  return text;
497
740
  };
498
741
 
742
+ const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
499
743
  const emitters = createEntityPredicateEmitters(dialect, param);
500
744
  const emitPred = (bindingName, pred) =>
501
745
  emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
502
746
 
747
+ /**
748
+ * One projected member of a binding: its value beside its JSON type,
749
+ * under a suffixed pair of names the decoder reads back.
750
+ *
751
+ * Which SOURCE the pair reads is the entity mapping's rule (§9.3),
752
+ * not a choice: a mapped scalar lives in its COLUMN and is absent
753
+ * from the document, so reading the document for it would answer
754
+ * nothing; an epoch column keeps its string IN the document, because
755
+ * the integer is derived; everything else is document only. The type
756
+ * of a column value is the column's declared storage — SQL has no
757
+ * `json_type` for it — with `NULL` meaning the member is absent,
758
+ * which is exactly what the merge reads back.
759
+ */
760
+ const projectedPair = (leaf, suffix) => {
761
+ const names = `${q(`v${suffix}`)}`;
762
+ const typeName = `${q(`t${suffix}`)}`;
763
+ if (leaf.ref.flavor === 'entity-column') {
764
+ const column = `${aliasOf(leaf.binding)}.${q(leaf.ref.column)}`;
765
+ const type = leaf.ref.storage === 'boolean'
766
+ ? `CASE WHEN ${column} IS NULL THEN NULL WHEN ${column} = 0 `
767
+ + `THEN ${sl('false')} ELSE ${sl('true')} END`
768
+ : `CASE WHEN ${column} IS NULL THEN NULL ELSE ${sl(
769
+ leaf.ref.storage === 'string' ? 'text'
770
+ : leaf.ref.storage === 'integer'
771
+ ? dialect.numericTypeNames[0]
772
+ : dialect.numericTypeNames[dialect.numericTypeNames.length - 1])} END`;
773
+ return `${column} AS ${names}, ${type} AS ${typeName}`;
774
+ }
775
+ const docSql = docOf(leaf.binding);
776
+ const text = pathTextOf(leaf.ref);
777
+ const type = dialect.jsonTypeOf(docSql, text);
778
+ return `CASE WHEN ${type} IN (${sl('object')}, ${sl('array')}) `
779
+ + `THEN ${dialect.jsonText(dialect.jsonExtract(docSql, text))} `
780
+ + `ELSE ${dialect.jsonExtract(docSql, text, 'scalar')} END AS ${names}, `
781
+ + `${type} AS ${typeName}`;
782
+ };
783
+
503
784
  const ret = plan.ret;
504
785
  // every returned column plus the document rendered to text; the
505
- // caller merges them back into the entity shape
786
+ // caller merges them back into the entity shape — or, for a projected
787
+ // shape, one value/type pair per DISTINCT leaf and no document at all
506
788
  const selection = plan.aggregate === 'count'
507
789
  ? `COUNT(*) AS ${q('value')}`
508
- : `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
790
+ : plan.project != null
791
+ // `p`-prefixed, because a bare `t0` would collide with this
792
+ // plan's own binding aliases
793
+ ? plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
794
+ // a join-table root IS its two key columns: it has no document
795
+ // column, so the merge is handed an empty one
796
+ : physicalOf(entityOf.get(ret)).document === false
797
+ ? `${aliasOf(ret)}.*, ${sl('{}')} AS ${q('__doc')}`
798
+ : `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
799
+
800
+ const tableOf = (name) =>
801
+ `${q(physicalOf(entityOf.get(name)).table)} AS ${aliasOf(name)}`;
802
+ const JOIN_OPS = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
803
+ const onSql = (edge) =>
804
+ `${aliasOf(edge.left.binding)}.${q(edge.left.column)}`
805
+ + ` ${JOIN_OPS[edge.op ?? 'eq']} ${aliasOf(edge.right.binding)}.${q(edge.right.column)}`;
509
806
 
510
807
  let sql = `SELECT ${selection} FROM `;
511
- sql += plan.bindings
512
- .map((binding) => `${q(physicalOf(binding.entity).table)} AS ${aliasOf(binding.name)}`)
513
- .join(' JOIN ');
514
- if (plan.joinOn !== null) {
515
- sql += ` ON ${aliasOf(plan.joinOn.left.binding)}.${q(plan.joinOn.left.column)}`
516
- + ` = ${aliasOf(plan.joinOn.right.binding)}.${q(plan.joinOn.right.column)}`;
517
- }
808
+ // the JOIN order the planner settled: the first binding, then each
809
+ // one an edge attaches to what is already joined. A binding nothing
810
+ // attached never reaches here — that graph is the residual
811
+ sql += plan.joins.length === 0
812
+ ? tableOf(plan.bindings[0].name)
813
+ : plan.joins.map((join, i) => (i === 0
814
+ ? tableOf(join.binding)
815
+ : `${tableOf(join.binding)} ON ${join.on.map(onSql).join(' AND ')}`)).join(' JOIN ');
518
816
  const filterSql = plan.filters
519
817
  .filter((entry) => entry.filter !== null)
520
818
  .map((entry) => emitPred(entry.binding, entry.filter));
@@ -528,7 +826,7 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
528
826
  // integer column sort differently
529
827
  const value = term.ref.flavor === 'entity-column'
530
828
  ? `${aliasOf(term.binding)}.${q(term.ref.column)}`
531
- : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref));
829
+ : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
532
830
  const nullsFirst = term.emptyGreatest === term.desc;
533
831
  return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
534
832
  });