@jarenjs/db 0.56.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
package/src/emit.js CHANGED
@@ -29,8 +29,9 @@ export class UnrepresentablePath extends Error {}
29
29
  /**
30
30
  * @typedef {{ external: string } | { literal: unknown } |
31
31
  * { derived: { kind: 'bboxAxis', external: string,
32
- * axis: 'w' | 's' | 'e' | 'n' } }} ParamSlot
33
- * 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
34
35
  * cannot bind at all: a GeoJSON region arrives as an external object,
35
36
  * and what the statement needs is one edge of its bounding box, so
36
37
  * the binder computes that edge from the bound value. It is the same
@@ -38,6 +39,14 @@ export class UnrepresentablePath extends Error {}
38
39
  * cannot, bind an ordinary parameter — and it stays closed on
39
40
  * purpose: a general expression slot would be a second query language
40
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.
41
50
  */
42
51
 
43
52
  /**
@@ -77,6 +86,7 @@ function stropForm(dialect, param, valueSql, pred) {
77
86
  function slotName(slot) {
78
87
  if ('external' in slot) return slot.external;
79
88
  if ('derived' in slot) return slot.derived.external;
89
+ if ('typed' in slot) return slot.typed.seek;
80
90
  return 'value';
81
91
  }
82
92
 
@@ -114,14 +124,29 @@ export function emitPlan(plan, dialect, physical) {
114
124
  const docColumn = q(physical.docColumn);
115
125
  /** @type {ParamSlot[]} */
116
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;
117
131
  const param = (slot) => {
118
- slots.push(slot);
119
- return dialect.parameterRef(slots.length, slotName(slot));
132
+ sink.push(slot);
133
+ return dialect.parameterRef(sink.length, slotName(slot));
120
134
  };
121
135
 
122
- /** SQL for a ref's VALUE: the generated column when one exists. */
123
- const valueOf = (ref) =>
124
- (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));
125
150
  const pathTextOf = (ref) => {
126
151
  const text = dialect.jsonPathText(ref.segments);
127
152
  if (text === null) {
@@ -132,9 +157,79 @@ export function emitPlan(plan, dialect, physical) {
132
157
  };
133
158
  /** The presence/type discriminator, always over the document column. */
134
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}`)}`;
135
170
 
136
171
  const sl = dialect.stringLiteral;
137
- 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
+ };
138
233
 
139
234
  /**
140
235
  * The guarded, total comparison forms of the truth table.
@@ -143,11 +238,11 @@ export function emitPlan(plan, dialect, physical) {
143
238
  */
144
239
  const emitCmp = (pred) => {
145
240
  const jt = typeOf(pred.ref);
146
- const value = valueOf(pred.ref);
147
241
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
148
242
  if ('lit' in pred.operand) {
149
243
  const lit = pred.operand.lit;
150
- const kind = typeof lit === 'number' ? 'number' : 'string';
244
+ const kind = typeof lit === 'number' ? 'number' : 'text';
245
+ const value = valueOf(pred.ref, kind);
151
246
  const typeGuard = kind === 'number'
152
247
  ? `${jt} IN ${NUMERIC()}`
153
248
  : `${jt} = ${sl('text')}`;
@@ -163,15 +258,33 @@ export function emitPlan(plan, dialect, physical) {
163
258
  // NULL, and a NULL escaping through a NOT flips a row's fate
164
259
  return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
165
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
+ }
166
272
  // external operand: its JSON type is only knowable at bind time —
167
- // 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
168
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);
169
282
  const textBranch = `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND `
170
- + `${dialect.valueTypeOf(param({ external: name }))} = ${sl('text')} AND `
171
- + `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
283
+ + `${dialect.valueTypeOf(external())} = ${sl('text')} AND `
284
+ + `${compare('text')} ${pred.op === 'ne' ? '=' : symbol} ${against('text')})`;
172
285
  const numberBranch = `(${jt} IS NOT NULL AND ${jt} IN ${NUMERIC()} AND `
173
- + `${dialect.valueTypeOf(param({ external: name }))} IN ${NUMERIC()} AND `
174
- + `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
286
+ + `${dialect.valueTypeOf(external())} IN ${NUMERIC()} AND `
287
+ + `${compare('number')} ${pred.op === 'ne' ? '=' : symbol} ${against('number')})`;
175
288
  const equalInSomeBranch = `(${textBranch} OR ${numberBranch})`;
176
289
  return pred.op === 'ne'
177
290
  ? `(${jt} IS NOT NULL AND NOT ${equalInSomeBranch})`
@@ -194,6 +307,23 @@ export function emitPlan(plan, dialect, physical) {
194
307
  return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
195
308
  case 'cmp':
196
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
+ }
197
327
  case 'typeIs': {
198
328
  const jt = typeOf(pred.ref);
199
329
  if (pred.types.length === 0) {
@@ -209,11 +339,13 @@ export function emitPlan(plan, dialect, physical) {
209
339
  }
210
340
  case 'udf':
211
341
  // the registered deterministic predicate: reads the row's
212
- // document as JSON text, answers 1 or 0 (always total)
213
- 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')})`;
214
346
  case 'strop': {
215
347
  const jt = typeOf(pred.ref);
216
- const form = stropForm(dialect, param, valueOf(pred.ref), pred);
348
+ const form = stropForm(dialect, param, valueOf(pred.ref, 'text'), pred);
217
349
  return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
218
350
  }
219
351
  case 'bboxOverlap': {
@@ -297,20 +429,64 @@ export function emitPlan(plan, dialect, physical) {
297
429
  // engine scores, cuts and ranks (measured: every SQL spelling of
298
430
  // the rank loses to fetching the column and ranking in the engine,
299
431
  // and none of them runs where no function can be registered)
300
- ? `${dialect.rowIdentity()} AS ${q('rid')}, ${q(plan.rank.column)} AS ${q('vec')}`
301
- : plan.bucket !== null
302
- ? [`${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)}`,
303
443
  ...plan.bucket.aggregates.map((entry) =>
304
- `${dialect.groupAggregate(entry.fn,
305
- 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(', ')
306
446
  : plan.aggregate === null
307
- ? `${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(', '))
308
461
  : plan.aggregate.fn === 'count'
309
462
  ? `COUNT(*) AS ${q('value')}`
310
- : `${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')}`;
311
470
 
312
471
  let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
313
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
+ }
314
490
  if (plan.bucket !== null) {
315
491
  // `first-seen` is the engine's own group order (§6.5, first
316
492
  // appearance), which over a collection is the group's earliest row
@@ -321,13 +497,15 @@ export function emitPlan(plan, dialect, physical) {
321
497
  : `${alias} ${plan.bucket.order === 'desc' ? 'DESC' : 'ASC'}`;
322
498
  sql += ` GROUP BY ${alias} ORDER BY ${order}`;
323
499
  }
324
- 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) {
325
502
  const terms = (plan.order ?? []).map((term) => {
326
503
  // Jaren's default sorts an empty key least: NULLS FIRST when
327
504
  // ascending, NULLS LAST when descending — and mirrored for
328
505
  // $empty: 'greatest' (probed against the engine)
329
506
  const nullsFirst = term.emptyGreatest === term.desc;
330
- 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)}`;
331
509
  });
332
510
  // the collection is a SEQUENCE: its order is insertion (row
333
511
  // identity) order, and the engine's sort is stable — the identity
@@ -336,10 +514,52 @@ export function emitPlan(plan, dialect, physical) {
336
514
  terms.push(dialect.rowIdentity());
337
515
  sql += ` ORDER BY ${terms.join(', ')}`;
338
516
  }
339
- if (plan.window !== null && plan.aggregate === null) {
517
+ if (plan.window !== null && plan.aggregate === null && plan.group === null) {
340
518
  sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
341
519
  }
342
- 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
+ }
343
563
  }
344
564
 
345
565
  // ————— The entity document kind (one emitter layer, two kinds) —————
@@ -355,7 +575,11 @@ export function emitPlan(plan, dialect, physical) {
355
575
  export function createEntityPredicateEmitters(dialect, param) {
356
576
  const q = dialect.quoteIdentifier;
357
577
  const sl = dialect.stringLiteral;
358
- 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
+
359
583
  const pathTextOf = (ref) => {
360
584
  const text = dialect.jsonPathText(ref.segments);
361
585
  if (text === null)
@@ -363,9 +587,14 @@ export function createEntityPredicateEmitters(dialect, param) {
363
587
  return text;
364
588
  };
365
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
+
366
596
  const emitDocPred = (docSql, pred) => {
367
597
  const jt = dialect.jsonTypeOf(docSql, pathTextOf(pred.ref));
368
- const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
369
598
  if (pred.p === 'typeIs') {
370
599
  if (pred.types.length === 0)
371
600
  return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
@@ -376,12 +605,13 @@ export function createEntityPredicateEmitters(dialect, param) {
376
605
  : `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
377
606
  }
378
607
  if (pred.p === 'strop') {
379
- const form = stropForm(dialect, param, value, pred);
608
+ const form = stropForm(dialect, param, memberAt(docSql, pred.ref, 'text'), pred);
380
609
  return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
381
610
  }
382
611
  const lit = pred.operand.lit;
383
612
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
384
- const kind = typeof lit === 'number' ? 'number' : 'string';
613
+ const kind = typeof lit === 'number' ? 'number' : 'text';
614
+ const value = memberAt(docSql, pred.ref, kind);
385
615
  if (pred.op === 'ne') {
386
616
  const notType = kind === 'number'
387
617
  ? `${jt} NOT IN ${NUMERIC()}` : `${jt} <> ${sl('text')}`;
@@ -410,10 +640,13 @@ export function createEntityPredicateEmitters(dialect, param) {
410
640
  }
411
641
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
412
642
  if ('ext' in pred.operand) {
413
- const guard = pred.ref.storage === 'string'
414
- ? `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} = ${sl('text')}`
415
- : `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} IN ${NUMERIC()}`;
416
- 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)})`;
417
650
  }
418
651
  const lit = pred.operand.lit;
419
652
  const litKind = typeof lit === 'number' ? 'number' : typeof lit === 'string' ? 'string' : 'other';
@@ -432,7 +665,9 @@ export function createEntityPredicateEmitters(dialect, param) {
432
665
  // stored values carry
433
666
  const emitEpochPred = (aliasSql, docSql, pred) => {
434
667
  const column = `${aliasSql}.${q(pred.ref.column)}`;
435
- 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');
436
671
  const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
437
672
  const range = pred.op === 'gt' || pred.op === 'ge'
438
673
  ? `${column} >= ${param({ literal: pred.epoch - 1000 })}`
@@ -483,6 +718,7 @@ export function createEntityPredicateEmitters(dialect, param) {
483
718
  */
484
719
  export function emitEntityPlan(plan, dialect, physicalOf) {
485
720
  const q = dialect.quoteIdentifier;
721
+ const sl = dialect.stringLiteral;
486
722
  /** @type {ParamSlot[]} */
487
723
  const slots = [];
488
724
  const param = (slot) => {
@@ -503,25 +739,80 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
503
739
  return text;
504
740
  };
505
741
 
742
+ const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
506
743
  const emitters = createEntityPredicateEmitters(dialect, param);
507
744
  const emitPred = (bindingName, pred) =>
508
745
  emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
509
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
+
510
784
  const ret = plan.ret;
511
785
  // every returned column plus the document rendered to text; the
512
- // 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
513
788
  const selection = plan.aggregate === 'count'
514
789
  ? `COUNT(*) AS ${q('value')}`
515
- : `${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)}`;
516
806
 
517
807
  let sql = `SELECT ${selection} FROM `;
518
- sql += plan.bindings
519
- .map((binding) => `${q(physicalOf(binding.entity).table)} AS ${aliasOf(binding.name)}`)
520
- .join(' JOIN ');
521
- if (plan.joinOn !== null) {
522
- sql += ` ON ${aliasOf(plan.joinOn.left.binding)}.${q(plan.joinOn.left.column)}`
523
- + ` = ${aliasOf(plan.joinOn.right.binding)}.${q(plan.joinOn.right.column)}`;
524
- }
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 ');
525
816
  const filterSql = plan.filters
526
817
  .filter((entry) => entry.filter !== null)
527
818
  .map((entry) => emitPred(entry.binding, entry.filter));
@@ -535,7 +826,7 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
535
826
  // integer column sort differently
536
827
  const value = term.ref.flavor === 'entity-column'
537
828
  ? `${aliasOf(term.binding)}.${q(term.ref.column)}`
538
- : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref));
829
+ : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
539
830
  const nullsFirst = term.emptyGreatest === term.desc;
540
831
  return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
541
832
  });
package/src/entity.js CHANGED
@@ -18,8 +18,9 @@ import { compileJsonQuery } from '@jarenjs/json/query';
18
18
  import {
19
19
  getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
20
20
  } from '@jarenjs/core/dates/rfc3339';
21
+ import { resolveRuntime } from '@jarenjs/core/runtime';
21
22
 
22
- import { DbRuntimeError, isDuplicateKeyError } from './errors.js';
23
+ import { DbRuntimeError, wrapDriverError } from './errors.js';
23
24
  import { chain, attempt } from './driver.js';
24
25
 
25
26
  /**
@@ -28,9 +29,13 @@ import { chain, attempt } from './driver.js';
28
29
  * @param {any} entity - the normalized entity (model.js)
29
30
  * @param {any} entityMapping - `explainMapping(...).entities[name]`
30
31
  * @param {((doc: any) => any) | null} validate
32
+ * @param {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime] - the
33
+ * host's runtime record: the clock a `default: 'now'` stamps and the
34
+ * identifier a `default: 'uuid'` allocates; the platform's own when absent
31
35
  * @returns {any}
32
36
  */
33
- export function entityCore(connection, entity, entityMapping, validate) {
37
+ export function entityCore(connection, entity, entityMapping, validate, runtime = undefined) {
38
+ const host = resolveRuntime(runtime);
34
39
  const dialect = connection.dialect;
35
40
  const q = dialect.quoteIdentifier;
36
41
  const table = entityMapping.table;
@@ -164,14 +169,14 @@ export function entityCore(connection, entity, entityMapping, validate) {
164
169
  // a `date` property takes the calendar date; a date-time stamp on
165
170
  // it was invalid under its own format and refused by an epoch column
166
171
  const stamp = property.format === 'date'
167
- ? () => new Date().toISOString().slice(0, 10)
168
- : () => new Date().toISOString();
172
+ ? () => new Date(host.now()).toISOString().slice(0, 10)
173
+ : () => new Date(host.now()).toISOString();
169
174
  defaulters.push({ name: property.name, fill: stamp });
170
175
  if (declared === 'updated') updateStamps.push({ name: property.name, fill: stamp });
171
176
  continue;
172
177
  }
173
178
  if (declared === 'uuid') {
174
- defaulters.push({ name: property.name, fill: () => crypto.randomUUID() });
179
+ defaulters.push({ name: property.name, fill: () => host.uuid() });
175
180
  continue;
176
181
  }
177
182
  if (declared === 'auto') continue; // the database allocates
@@ -243,7 +248,7 @@ export function entityCore(connection, entity, entityMapping, validate) {
243
248
  const prepared = (name, sql) => {
244
249
  let statement = statements.get(name);
245
250
  if (statement === undefined) {
246
- statement = connection.prepare(sql);
251
+ statement = connection.prepare(sql, { readOnly: name === 'get' });
247
252
  statements.set(name, statement);
248
253
  }
249
254
  return statement;
@@ -288,20 +293,11 @@ export function entityCore(connection, entity, entityMapping, validate) {
288
293
  { docPath, collection: entity.name });
289
294
  };
290
295
 
291
- const wrapWrite = (error, key) => {
292
- const code = /** @type {any} */ (error)?.code;
293
- if (typeof code === 'string' && code.startsWith('JD')) return error;
294
- if (keys.length === 1 && isDuplicateKeyError(error, table, keys[0])) {
295
- return new DbRuntimeError('JD2001',
296
- `a '${entity.name}' already exists under key ${JSON.stringify(key)}`,
297
- { docPath, collection: entity.name, key, cause: error });
298
- }
299
- return new DbRuntimeError('JD2005',
300
- `the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
301
- key === undefined
302
- ? { docPath, collection: entity.name, cause: error }
303
- : { docPath, collection: entity.name, key, cause: error });
304
- };
296
+ const wrapWrite = (error, key) => wrapDriverError(error, {
297
+ docPath, collection: entity.name, ...(key === undefined ? undefined : { key }),
298
+ ...(keys.length === 1 ? { unique: { table, column: keys[0] } } : undefined),
299
+ duplicateReason: `a '${entity.name}' already exists under key ${JSON.stringify(key)}`,
300
+ });
305
301
 
306
302
  const columnByName = new Map(scalarColumns.map((column) => [column.name, column]));
307
303
  /** Encode ONE column assignment the way {@link split} would. */
@@ -360,8 +356,10 @@ export function entityCore(connection, entity, entityMapping, validate) {
360
356
  get(key) {
361
357
  const parts = normalizeKeyArg(key);
362
358
  const sql = `SELECT ${selectColumns} FROM ${q(table)} WHERE ${keyWhere(0)}`;
363
- return chain(prepared('get', sql), (statement) =>
364
- chain(statement.get(parts), (row) => (row === undefined ? undefined : merge(row))));
359
+ // classified like every read of the query engines, never raw
360
+ return attempt(() => chain(prepared('get', sql), (statement) =>
361
+ chain(statement.get(parts), (row) => (row === undefined ? undefined : merge(row)))),
362
+ (error) => wrapDriverError(error, { docPath, collection: entity.name, key }));
365
363
  },
366
364
  update(key, changes) {
367
365
  const parts = normalizeKeyArg(key);