turbine-orm 0.51.0 → 0.52.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 (74) hide show
  1. package/README.md +33 -5
  2. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  3. package/dist/cjs/client.d.ts +106 -2
  4. package/dist/cjs/client.js +111 -5
  5. package/dist/cjs/dialect.d.ts +33 -0
  6. package/dist/cjs/dialect.js +14 -0
  7. package/dist/cjs/engine-config.d.ts +49 -0
  8. package/dist/cjs/engine-config.js +19 -0
  9. package/dist/cjs/index-advisor.js +0 -0
  10. package/dist/cjs/index.d.ts +1 -1
  11. package/dist/cjs/index.js +3 -2
  12. package/dist/cjs/mssql.d.ts +8 -3
  13. package/dist/cjs/mssql.js +22 -3
  14. package/dist/cjs/mysql.d.ts +7 -3
  15. package/dist/cjs/mysql.js +20 -3
  16. package/dist/cjs/nested-write.d.ts +31 -0
  17. package/dist/cjs/nested-write.js +80 -2
  18. package/dist/cjs/powdb-introspect.d.ts +10 -1
  19. package/dist/cjs/powdb-introspect.js +10 -1
  20. package/dist/cjs/powdb.d.ts +116 -6
  21. package/dist/cjs/powdb.js +169 -10
  22. package/dist/cjs/powql.d.ts +161 -1
  23. package/dist/cjs/powql.js +299 -19
  24. package/dist/cjs/prisma-compat.d.ts +54 -8
  25. package/dist/cjs/prisma-compat.js +136 -20
  26. package/dist/cjs/query/batched-loader.d.ts +7 -0
  27. package/dist/cjs/query/batched-loader.js +97 -15
  28. package/dist/cjs/query/builder.d.ts +131 -5
  29. package/dist/cjs/query/builder.js +223 -19
  30. package/dist/cjs/query/compound-unique.js +0 -0
  31. package/dist/cjs/query/index.d.ts +1 -1
  32. package/dist/cjs/query/index.js +2 -1
  33. package/dist/cjs/query/warn-registry.d.ts +10 -0
  34. package/dist/cjs/query/warn-registry.js +10 -0
  35. package/dist/cjs/query/writes.js +115 -7
  36. package/dist/cjs/sqlite.d.ts +10 -4
  37. package/dist/cjs/sqlite.js +18 -4
  38. package/dist/cli/studio-ui.generated.js +1 -1
  39. package/dist/client.d.ts +106 -2
  40. package/dist/client.js +111 -5
  41. package/dist/dialect.d.ts +33 -0
  42. package/dist/dialect.js +14 -0
  43. package/dist/engine-config.d.ts +49 -0
  44. package/dist/engine-config.js +18 -0
  45. package/dist/index-advisor.js +0 -0
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/mssql.d.ts +8 -3
  49. package/dist/mssql.js +22 -3
  50. package/dist/mysql.d.ts +7 -3
  51. package/dist/mysql.js +20 -3
  52. package/dist/nested-write.d.ts +31 -0
  53. package/dist/nested-write.js +79 -2
  54. package/dist/powdb-introspect.d.ts +10 -1
  55. package/dist/powdb-introspect.js +10 -1
  56. package/dist/powdb.d.ts +116 -6
  57. package/dist/powdb.js +167 -9
  58. package/dist/powql.d.ts +161 -1
  59. package/dist/powql.js +299 -19
  60. package/dist/prisma-compat.d.ts +54 -8
  61. package/dist/prisma-compat.js +136 -20
  62. package/dist/query/batched-loader.d.ts +7 -0
  63. package/dist/query/batched-loader.js +98 -16
  64. package/dist/query/builder.d.ts +131 -5
  65. package/dist/query/builder.js +222 -18
  66. package/dist/query/compound-unique.js +0 -0
  67. package/dist/query/index.d.ts +1 -1
  68. package/dist/query/index.js +1 -1
  69. package/dist/query/warn-registry.d.ts +10 -0
  70. package/dist/query/warn-registry.js +10 -0
  71. package/dist/query/writes.js +116 -8
  72. package/dist/sqlite.d.ts +10 -4
  73. package/dist/sqlite.js +19 -5
  74. package/package.json +3 -3
@@ -101,6 +101,37 @@ export declare function hasRelationFields(data: Record<string, unknown>, tableMe
101
101
  * Handles composite keys. Returns a new object (does not mutate input).
102
102
  */
103
103
  export declare function injectForeignKey(childData: Record<string, unknown>, relation: RelationDef, parentRow: Record<string, unknown>, schema: SchemaMetadata): Record<string, unknown>;
104
+ /**
105
+ * Split rows destined for `createMany` into CONTIGUOUS runs that each name the
106
+ * same fields.
107
+ *
108
+ * `createMany` compiles ONE statement whose column list comes from the first
109
+ * row, so it refuses a batch whose rows disagree about which fields they name
110
+ * (`assertUniformCreateManyRows` in query/writes.ts): a field a later row omits
111
+ * would be bound as NULL over that column's default, and a field only a later
112
+ * row names would be dropped. Rows that arrive as ordinary user input, a nested
113
+ * `create: [...]` array or a Prisma-shaped `createMany` on the compat layer, are
114
+ * allowed to mix shapes, so the caller splits the batch instead of refusing it
115
+ * and issues one `createMany` per run. Every run is internally uniform, so the
116
+ * refusal still stands where it matters and none of the corruption it exists to
117
+ * stop becomes reachable.
118
+ *
119
+ * Runs are CONTIGUOUS rather than grouped by shape across the whole array: one
120
+ * statement inserted the rows in array order, and running contiguous runs in
121
+ * order keeps that, so generated keys stay ascending with the caller's array.
122
+ * Grouping non-adjacent rows would batch harder (`A B A B` in two statements
123
+ * rather than four) at the cost of reordering rows, which is observable through
124
+ * any server-assigned sequence.
125
+ *
126
+ * A uniform array, the overwhelmingly common shape, yields exactly one run
127
+ * holding the ORIGINAL array, so the caller makes the one call it always made.
128
+ *
129
+ * A key whose value is `undefined` is not named, matching `definedKeys` in
130
+ * query/writes.ts (and single-row `create`).
131
+ *
132
+ * @internal shared by the nested-write batch path and turbine-orm/prisma-compat.
133
+ */
134
+ export declare function createManyShapeRuns<T extends Record<string, unknown>>(rows: T[]): T[][];
104
135
  /**
105
136
  * Tree-walking create: inserts the parent row, then processes each relation
106
137
  * operation (create, connect, connectOrCreate), and finally reads back the
@@ -79,6 +79,72 @@ export function injectForeignKey(childData, relation, parentRow, schema) {
79
79
  }
80
80
  return result;
81
81
  }
82
+ /**
83
+ * Split rows destined for `createMany` into CONTIGUOUS runs that each name the
84
+ * same fields.
85
+ *
86
+ * `createMany` compiles ONE statement whose column list comes from the first
87
+ * row, so it refuses a batch whose rows disagree about which fields they name
88
+ * (`assertUniformCreateManyRows` in query/writes.ts): a field a later row omits
89
+ * would be bound as NULL over that column's default, and a field only a later
90
+ * row names would be dropped. Rows that arrive as ordinary user input, a nested
91
+ * `create: [...]` array or a Prisma-shaped `createMany` on the compat layer, are
92
+ * allowed to mix shapes, so the caller splits the batch instead of refusing it
93
+ * and issues one `createMany` per run. Every run is internally uniform, so the
94
+ * refusal still stands where it matters and none of the corruption it exists to
95
+ * stop becomes reachable.
96
+ *
97
+ * Runs are CONTIGUOUS rather than grouped by shape across the whole array: one
98
+ * statement inserted the rows in array order, and running contiguous runs in
99
+ * order keeps that, so generated keys stay ascending with the caller's array.
100
+ * Grouping non-adjacent rows would batch harder (`A B A B` in two statements
101
+ * rather than four) at the cost of reordering rows, which is observable through
102
+ * any server-assigned sequence.
103
+ *
104
+ * A uniform array, the overwhelmingly common shape, yields exactly one run
105
+ * holding the ORIGINAL array, so the caller makes the one call it always made.
106
+ *
107
+ * A key whose value is `undefined` is not named, matching `definedKeys` in
108
+ * query/writes.ts (and single-row `create`).
109
+ *
110
+ * @internal shared by the nested-write batch path and turbine-orm/prisma-compat.
111
+ */
112
+ export function createManyShapeRuns(rows) {
113
+ if (rows.length === 0)
114
+ return [];
115
+ const runs = [];
116
+ let current = [];
117
+ let currentShape = '';
118
+ for (const row of rows) {
119
+ const shape = rowShapeKey(row);
120
+ if (current.length === 0) {
121
+ currentShape = shape;
122
+ }
123
+ else if (shape !== currentShape) {
124
+ runs.push(current);
125
+ current = [];
126
+ currentShape = shape;
127
+ }
128
+ current.push(row);
129
+ }
130
+ runs.push(current);
131
+ // One run means every row agreed: hand back the caller's own array so the
132
+ // resulting call is indistinguishable from the ungrouped one.
133
+ return runs.length === 1 ? [rows] : runs;
134
+ }
135
+ /**
136
+ * Order-independent identity of the fields a row names. Each key is written
137
+ * length-prefixed so no delimiter can appear inside one: a property name may
138
+ * legally contain any character at all, so a plain join would let two distinct
139
+ * key sets collide on the same string.
140
+ */
141
+ function rowShapeKey(row) {
142
+ return Object.keys(row)
143
+ .filter((k) => row[k] !== undefined)
144
+ .sort()
145
+ .map((k) => `${k.length}:${k}`)
146
+ .join(',');
147
+ }
82
148
  // ---------------------------------------------------------------------------
83
149
  // Internal helpers
84
150
  // ---------------------------------------------------------------------------
@@ -834,9 +900,20 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
834
900
  }
835
901
  }
836
902
  else {
837
- // Batch via createMany (UNNEST), fast path
903
+ // Batch via createMany (UNNEST), fast path.
904
+ //
905
+ // A nested `create: [...]` array is ordinary user input and may
906
+ // legitimately mix row shapes (`[{ title }, { title, published }]`),
907
+ // which one createMany cannot express, see createManyShapeRuns. Split
908
+ // it into contiguous same-shape runs and issue one createMany per run:
909
+ // every run stays uniform, so an omitted field takes its column default
910
+ // instead of being written as NULL, and the rows still reach the
911
+ // database in the caller's array order exactly as the single statement
912
+ // put them there. A uniform array is one run and one identical call.
838
913
  const injected = items.map((item) => injectForeignKey(item, rel, parentRow, ctx.schema));
839
- await ctx.tx.table(rel.to).createMany({ data: injected });
914
+ for (const run of createManyShapeRuns(injected)) {
915
+ await ctx.tx.table(rel.to).createMany({ data: run });
916
+ }
840
917
  }
841
918
  }
842
919
  }
@@ -45,7 +45,16 @@
45
45
  * round-trip; only plain `unique`/`index` columns appear in `indexes`.
46
46
  * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types
47
47
  * (`Date` / `string` / `Uint8Array`). Turbine never emits those PowQL types
48
- * on write, so writing to such a column may not round-trip.
48
+ * on write, so writing to such a column may not round-trip. This introspector
49
+ * is the ONLY producer of `dialectType: 'datetime'` metadata, and a predicate
50
+ * on such a column is version-gated: comparing it against the integer
51
+ * microseconds Turbine binds was silently wrong below engine 0.20, so those
52
+ * predicates raise a typed E017 there rather than being answered wrongly (see
53
+ * `PowdbCapabilities.datetimeCompare`). The `in` / `not in` list forms are
54
+ * still wrong upstream at 0.20, so Turbine never emits one for such a column:
55
+ * it compiles to the equality chain the engine answers correctly, bounded by
56
+ * `MAX_POWQL_DATETIME_TERMS`. Reads, ordering, grouping and null checks on
57
+ * the column are unaffected.
49
58
  *
50
59
  * v1 is a PROGRAMMATIC API (exported from `turbine-orm/powdb`); the CLI's
51
60
  * `turbine generate` still defaults to Postgres. Routing a `powdb://` URL
@@ -45,7 +45,16 @@
45
45
  * round-trip; only plain `unique`/`index` columns appear in `indexes`.
46
46
  * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types
47
47
  * (`Date` / `string` / `Uint8Array`). Turbine never emits those PowQL types
48
- * on write, so writing to such a column may not round-trip.
48
+ * on write, so writing to such a column may not round-trip. This introspector
49
+ * is the ONLY producer of `dialectType: 'datetime'` metadata, and a predicate
50
+ * on such a column is version-gated: comparing it against the integer
51
+ * microseconds Turbine binds was silently wrong below engine 0.20, so those
52
+ * predicates raise a typed E017 there rather than being answered wrongly (see
53
+ * `PowdbCapabilities.datetimeCompare`). The `in` / `not in` list forms are
54
+ * still wrong upstream at 0.20, so Turbine never emits one for such a column:
55
+ * it compiles to the equality chain the engine answers correctly, bounded by
56
+ * `MAX_POWQL_DATETIME_TERMS`. Reads, ordering, grouping and null checks on
57
+ * the column are unaffected.
49
58
  *
50
59
  * v1 is a PROGRAMMATIC API (exported from `turbine-orm/powdb`); the CLI's
51
60
  * `turbine generate` still defaults to Postgres. Routing a `powdb://` URL
package/dist/powdb.d.ts CHANGED
@@ -290,11 +290,70 @@ export interface PowdbCapabilities {
290
290
  * ALL_POWDB_CAPABILITIES: it must only light up behind a real version probe.
291
291
  */
292
292
  linkPaths: boolean;
293
+ /**
294
+ * ≥ 0.20: a comparison between a `datetime` column and an integer timestamp
295
+ * literal evaluates as microseconds. Below 0.20 that pairing was unhandled and
296
+ * fell back to comparing TYPE TAGS (every DateTime sorted above every Int), so
297
+ * `>` matched every non-null row, `=` and `<` matched none, and the answer
298
+ * additionally depended on whether the column carried an index. Turbine binds
299
+ * a JS `Date` as int micros, so that is exactly the shape it emits: every
300
+ * datetime predicate was silently wrong on an older engine.
301
+ *
302
+ * The `in` / `not in` LIST form is a separate, still-open engine bug that 0.20
303
+ * did NOT fix, so this flag does not unlock it: a datetime `in` list is
304
+ * COMPILED AWAY into the equality chain the engine does answer correctly (see
305
+ * `PowqlInterface.buildInList`). That expansion needs working binary
306
+ * comparisons, so it too sits behind this flag.
307
+ *
308
+ * Predominantly a refusal gate, but the `in` rewrite makes it a (bounded)
309
+ * generation flip as well. It stays ON in {@link ALL_POWDB_CAPABILITIES}
310
+ * anyway: with the flag OFF the datetime paths do not fall back to some other
311
+ * SQL, they refuse outright, so a hand-constructed pool defaulting to OFF
312
+ * would break datetime queries that work rather than protect anything.
313
+ */
314
+ datetimeCompare: boolean;
315
+ /**
316
+ * ≥ 0.20: `count(T { .col })` counts non-null values of `.col` (SQL's
317
+ * `COUNT(col)`), which is what Turbine's per-field `_count` means. Below 0.20
318
+ * both frontends ignored the projection and returned the ROW count, so
319
+ * `aggregate({ _count: { field: true } })` silently disagreed with every SQL
320
+ * engine on a nullable column. `count(T)` / `_count: true` is unaffected on
321
+ * every version. Refusal-only gate (the emitted PowQL does not change).
322
+ */
323
+ projectedCountNonNull: boolean;
293
324
  /** Networked only: server ≥ 0.13 AND the client exposes `queryNativeRaw`. */
294
325
  nativeRaw: boolean;
295
326
  }
327
+ /**
328
+ * PowQL's parser bounds the SHAPE of the AST it produces, not just its own
329
+ * recursion: an `and` / `or` chain is parsed iteratively but re-wraps its
330
+ * accumulator once per term, so a flat chain counts against the same 64-level
331
+ * nesting budget as nested parentheses (PowDB 0.20 security fix, an unbounded
332
+ * chain overflowed the stack in a later recursive walk).
333
+ *
334
+ * The practical ceiling for a Turbine-generated predicate is therefore around
335
+ * **63 terms in one `OR` / `AND` array at the top level**, and fewer inside a
336
+ * nested projection block (measured: 63 flat, 61 one level of parens deep),
337
+ * because the budget is shared with whatever depth the predicate sits at.
338
+ * Turbine does NOT pre-check this client-side: the true remaining budget
339
+ * depends on the engine's parse depth at that point, so a client-side estimate
340
+ * would refuse valid queries. The engine's refusal is mapped to a typed
341
+ * {@link ValidationError} with a split-the-array hint instead (see
342
+ * {@link wrapPowdbError}).
343
+ *
344
+ * A literal `in (a, b, c, …)` list is a single flat node, NOT a chain, so it
345
+ * does not count against the budget (verified to 5,000 elements). Turbine's
346
+ * relation-key chunking (`MAX_RELATION_KEYS`) is unaffected.
347
+ *
348
+ * The one exception is a key list on a PowDB-native `datetime` column, which
349
+ * cannot use the list form at all (the engine still compares it by type tag) and
350
+ * is expanded into exactly such a chain. Those lists ARE pre-checked and chunked,
351
+ * against a deliberately conservative cap, see `MAX_POWQL_DATETIME_TERMS` in
352
+ * powql.ts.
353
+ */
354
+ export declare const POWQL_MAX_NESTING_DEPTH = 64;
296
355
  /** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
297
- type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks' | 'linkIntrospection' | 'linkPaths';
356
+ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks' | 'linkIntrospection' | 'linkPaths' | 'datetimeCompare' | 'projectedCountNonNull';
298
357
  /**
299
358
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
300
359
  * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
@@ -312,6 +371,14 @@ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serve
312
371
  * projections), and `linkIntrospection` is only meaningful once genuinely
313
372
  * probed, so both must come from a real version resolution, never a bare
314
373
  * construction.
374
+ * `datetimeCompare` / `projectedCountNonNull` stay ON here for the same
375
+ * trusted-caller reason as `jsonDocs` and `serverJoins`. Neither is a fallback
376
+ * gate: with the flag OFF the affected query is REFUSED, not served by some
377
+ * other statement, so defaulting them off would break working queries rather
378
+ * than protect anything. Every path that can learn the engine version
379
+ * (`turbinePowDB`, embedded or networked) resolves them from a real probe; this
380
+ * fallback only covers a hand-constructed or injected pool, whose owner is
381
+ * asserting the engine is current.
315
382
  */
316
383
  export declare const ALL_POWDB_CAPABILITIES: PowdbCapabilities;
317
384
  /**
@@ -327,8 +394,18 @@ export declare function capabilitiesFromVersion(version: string | undefined | nu
327
394
  * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
328
395
  * PowQL feature is used on an engine that does not support it. Keeps old engines
329
396
  * getting clean typed errors instead of raw PowQL parse failures.
397
+ *
398
+ * The error's first sentence already names the feature (`<feature> is
399
+ * unsupported on "PowDB".`), so the hint says "Requires PowDB >= x" rather than
400
+ * repeating the label: a long feature description read twice in one message
401
+ * (`per-field \`_count\` … is unsupported … per-field \`_count\` … requires …`)
402
+ * buries the version floor that is the actionable part.
403
+ *
404
+ * `extra` appends one more sentence for gates that have a workaround worth
405
+ * naming (e.g. the read path that answers the same query without the gated
406
+ * comparison).
330
407
  */
331
- export declare function requireCapability(caps: PowdbCapabilities, key: PowdbFeatureKey, feature: string): void;
408
+ export declare function requireCapability(caps: PowdbCapabilities, key: PowdbFeatureKey, feature: string, extra?: string): void;
332
409
  /**
333
410
  * PowQL column types Turbine emits: the four writable scalars plus PowDB's
334
411
  * native `json` document type (added to the map in the 0.12/0.13 parity round,
@@ -357,6 +434,23 @@ export declare function isJsonColumn(col: ColumnMetadata): boolean;
357
434
  * Array (non-json) and bytes columns throw, they have no PowDB equivalent.
358
435
  */
359
436
  export declare function powqlColumnType(col: ColumnMetadata): PowqlType;
437
+ /**
438
+ * Is this column stored in PowDB's NATIVE `datetime` type (as opposed to the
439
+ * `int` epoch micros Turbine's own DDL emits for a `Date` column)?
440
+ *
441
+ * Only the literal PowQL type name counts. `powqlColumnType` never returns
442
+ * `datetime`, so a Turbine-provisioned table can never have one; the shapes that
443
+ * do are a table created outside Turbine and read back through
444
+ * `introspectPowdbDatabase` (which maps `datetime` → `{ tsType: 'Date',
445
+ * dialectType: 'datetime' }`), or hand-written metadata declaring it. Deliberately
446
+ * strict: a Postgres-sourced `timestamptz` column is DDL'd as PowQL `int`, so it
447
+ * is NOT a PowDB datetime and must not be caught here.
448
+ *
449
+ * Matters because comparing a datetime column against the integer timestamp
450
+ * literal Turbine binds was silently wrong below engine 0.20 (see
451
+ * {@link PowdbCapabilities.datetimeCompare}).
452
+ */
453
+ export declare function isPowdbDatetimeColumn(col: ColumnMetadata): boolean;
360
454
  /**
361
455
  * Generate PowQL DDL (`type T { … }`) for every table in a schema. Used to
362
456
  * provision a PowDB database from a code-first `defineSchema`/`SchemaMetadata`
@@ -480,6 +574,14 @@ export declare function coerceValue(raw: string, col: ColumnMetadata): unknown;
480
574
  * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
481
575
  * native transport). `datetime`-shaped cells (int micros) become `Date`; a
482
576
  * bigint on a `number` column follows the int8 safe-integer policy.
577
+ *
578
+ * A date cell can also arrive as a DIGIT STRING: a nested-projection block's
579
+ * children ride a JSON array, and micros exceed `Number.MAX_SAFE_INTEGER`'s
580
+ * decimal comfort, so the engine renders them as a JSON string. Before that
581
+ * string was parsed here, a nested `with` handed back the raw micros text while
582
+ * the batched loader and the native join both handed back a `Date` (the same
583
+ * relation, three answers). Only an all-digit string is parsed; any other text
584
+ * on a date column passes through untouched.
483
585
  */
484
586
  export declare function coerceNativeValue(value: unknown, col: ColumnMetadata): unknown;
485
587
  /**
@@ -723,11 +825,19 @@ export declare function encodePowqlLiteral(value: unknown, position?: string): s
723
825
  * pre-existing tokens, so the tokenization / escape surface this ceiling guards is
724
826
  * unmoved. The 0.19.1 link-introspection / link-path round is likewise lexer-neutral:
725
827
  * `git diff v0.19.0 v0.19.1 -- crates/query/src/lexer.rs` is empty (the bare-dotted-path
726
- * hard error is parser-level, not tokenization), so this ceiling stays `'0.19'`. The
727
- * guard in {@link PowdbEmbeddedPool.exec} compares major.minor only, so `'0.19'`
728
- * already covers every 0.19.x patch, no bump is needed for 0.19.1.
828
+ * hard error is parser-level, not tokenization). The guard in
829
+ * {@link PowdbEmbeddedPool.exec} compares major.minor only, so one entry covers every
830
+ * patch of a line.
831
+ *
832
+ * Verification for the 0.20 line: `git diff v0.19.1 v0.20.0 -- crates/query/src/lexer.rs
833
+ * crates/query/src/token.rs` is EMPTY, both files are byte-identical. Everything 0.20
834
+ * changed in the query crate is downstream of tokenization: the operator-chain nesting
835
+ * budget and the count-projection lift are in `parser.rs`, and the unknown-column /
836
+ * type-mismatch / negative-limit refusals are a new executor validation pass
837
+ * (`executor/plan_exec/validate.rs`). No new escape sequence, so the escaper's contract
838
+ * is unmoved and the ceiling advances to `'0.20'`.
729
839
  */
730
- export declare const POWQL_LEXER_TESTED_CEILING = "0.19";
840
+ export declare const POWQL_LEXER_TESTED_CEILING = "0.20";
731
841
  /**
732
842
  * Substitute every `$N` placeholder in a generator-produced PowQL template with
733
843
  * the encoded literal of `params[N-1]`. Safe because the template is produced by
package/dist/powdb.js CHANGED
@@ -198,6 +198,34 @@ export function assertSupportedPowdbVersion(version) {
198
198
  throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
199
199
  'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
200
200
  }
201
+ /**
202
+ * PowQL's parser bounds the SHAPE of the AST it produces, not just its own
203
+ * recursion: an `and` / `or` chain is parsed iteratively but re-wraps its
204
+ * accumulator once per term, so a flat chain counts against the same 64-level
205
+ * nesting budget as nested parentheses (PowDB 0.20 security fix, an unbounded
206
+ * chain overflowed the stack in a later recursive walk).
207
+ *
208
+ * The practical ceiling for a Turbine-generated predicate is therefore around
209
+ * **63 terms in one `OR` / `AND` array at the top level**, and fewer inside a
210
+ * nested projection block (measured: 63 flat, 61 one level of parens deep),
211
+ * because the budget is shared with whatever depth the predicate sits at.
212
+ * Turbine does NOT pre-check this client-side: the true remaining budget
213
+ * depends on the engine's parse depth at that point, so a client-side estimate
214
+ * would refuse valid queries. The engine's refusal is mapped to a typed
215
+ * {@link ValidationError} with a split-the-array hint instead (see
216
+ * {@link wrapPowdbError}).
217
+ *
218
+ * A literal `in (a, b, c, …)` list is a single flat node, NOT a chain, so it
219
+ * does not count against the budget (verified to 5,000 elements). Turbine's
220
+ * relation-key chunking (`MAX_RELATION_KEYS`) is unaffected.
221
+ *
222
+ * The one exception is a key list on a PowDB-native `datetime` column, which
223
+ * cannot use the list form at all (the engine still compares it by type tag) and
224
+ * is expanded into exactly such a chain. Those lists ARE pre-checked and chunked,
225
+ * against a deliberately conservative cap, see `MAX_POWQL_DATETIME_TERMS` in
226
+ * powql.ts.
227
+ */
228
+ export const POWQL_MAX_NESTING_DEPTH = 64;
201
229
  /**
202
230
  * Minimum engine version each gated feature needs, for the E017 hint text.
203
231
  * Most gates carry a `major.minor` floor (patch-insensitive); the two link
@@ -215,6 +243,8 @@ const POWDB_FEATURE_MIN_VERSION = {
215
243
  entityLinks: '0.19',
216
244
  linkIntrospection: '0.19.1',
217
245
  linkPaths: '0.19.1',
246
+ datetimeCompare: '0.20',
247
+ projectedCountNonNull: '0.20',
218
248
  };
219
249
  /**
220
250
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -233,6 +263,14 @@ const POWDB_FEATURE_MIN_VERSION = {
233
263
  * projections), and `linkIntrospection` is only meaningful once genuinely
234
264
  * probed, so both must come from a real version resolution, never a bare
235
265
  * construction.
266
+ * `datetimeCompare` / `projectedCountNonNull` stay ON here for the same
267
+ * trusted-caller reason as `jsonDocs` and `serverJoins`. Neither is a fallback
268
+ * gate: with the flag OFF the affected query is REFUSED, not served by some
269
+ * other statement, so defaulting them off would break working queries rather
270
+ * than protect anything. Every path that can learn the engine version
271
+ * (`turbinePowDB`, embedded or networked) resolves them from a real probe; this
272
+ * fallback only covers a hand-constructed or injected pool, whose owner is
273
+ * asserting the engine is current.
236
274
  */
237
275
  export const ALL_POWDB_CAPABILITIES = {
238
276
  engineVersion: null,
@@ -244,6 +282,8 @@ export const ALL_POWDB_CAPABILITIES = {
244
282
  entityLinks: false,
245
283
  linkIntrospection: false,
246
284
  linkPaths: false,
285
+ datetimeCompare: true,
286
+ projectedCountNonNull: true,
247
287
  nativeRaw: false,
248
288
  };
249
289
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -287,6 +327,8 @@ export function capabilitiesFromVersion(version, opts = {}) {
287
327
  entityLinks: false,
288
328
  linkIntrospection: false,
289
329
  linkPaths: false,
330
+ datetimeCompare: false,
331
+ projectedCountNonNull: false,
290
332
  nativeRaw: false,
291
333
  };
292
334
  }
@@ -303,6 +345,8 @@ export function capabilitiesFromVersion(version, opts = {}) {
303
345
  // never 0.19.0.
304
346
  linkIntrospection: atLeastVersion(sem, 0, 19, 1),
305
347
  linkPaths: atLeastVersion(sem, 0, 19, 1),
348
+ datetimeCompare: atLeastVersion(sem, 0, 20),
349
+ projectedCountNonNull: atLeastVersion(sem, 0, 20),
306
350
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
307
351
  };
308
352
  }
@@ -310,16 +354,26 @@ export function capabilitiesFromVersion(version, opts = {}) {
310
354
  * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
311
355
  * PowQL feature is used on an engine that does not support it. Keeps old engines
312
356
  * getting clean typed errors instead of raw PowQL parse failures.
357
+ *
358
+ * The error's first sentence already names the feature (`<feature> is
359
+ * unsupported on "PowDB".`), so the hint says "Requires PowDB >= x" rather than
360
+ * repeating the label: a long feature description read twice in one message
361
+ * (`per-field \`_count\` … is unsupported … per-field \`_count\` … requires …`)
362
+ * buries the version floor that is the actionable part.
363
+ *
364
+ * `extra` appends one more sentence for gates that have a workaround worth
365
+ * naming (e.g. the read path that answers the same query without the gated
366
+ * comparison).
313
367
  */
314
- export function requireCapability(caps, key, feature) {
368
+ export function requireCapability(caps, key, feature, extra) {
315
369
  if (caps[key])
316
370
  return;
317
371
  const min = POWDB_FEATURE_MIN_VERSION[key];
318
372
  const reported = caps.engineVersion
319
373
  ? `this connection reports ${caps.engineVersion}`
320
374
  : 'this connection could not report a version';
321
- throw new UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
322
- '(or pass `assumeEngineVersion` if the version cannot be detected).');
375
+ throw new UnsupportedFeatureError(feature, 'PowDB', `Requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
376
+ `(or pass \`assumeEngineVersion\` if the version cannot be detected).${extra ? ` ${extra}` : ''}`);
323
377
  }
324
378
  /**
325
379
  * Does this column map to PowDB's native `json` document type? A Postgres
@@ -383,6 +437,25 @@ function isFloatColumn(col) {
383
437
  function isDateColumn(col) {
384
438
  return col.tsType.replace(/\s*\|\s*null$/i, '').trim() === 'Date';
385
439
  }
440
+ /**
441
+ * Is this column stored in PowDB's NATIVE `datetime` type (as opposed to the
442
+ * `int` epoch micros Turbine's own DDL emits for a `Date` column)?
443
+ *
444
+ * Only the literal PowQL type name counts. `powqlColumnType` never returns
445
+ * `datetime`, so a Turbine-provisioned table can never have one; the shapes that
446
+ * do are a table created outside Turbine and read back through
447
+ * `introspectPowdbDatabase` (which maps `datetime` → `{ tsType: 'Date',
448
+ * dialectType: 'datetime' }`), or hand-written metadata declaring it. Deliberately
449
+ * strict: a Postgres-sourced `timestamptz` column is DDL'd as PowQL `int`, so it
450
+ * is NOT a PowDB datetime and must not be caught here.
451
+ *
452
+ * Matters because comparing a datetime column against the integer timestamp
453
+ * literal Turbine binds was silently wrong below engine 0.20 (see
454
+ * {@link PowdbCapabilities.datetimeCompare}).
455
+ */
456
+ export function isPowdbDatetimeColumn(col) {
457
+ return (col.dialectType ?? col.pgType ?? '').toLowerCase() === 'datetime';
458
+ }
386
459
  /**
387
460
  * Generate PowQL DDL (`type T { … }`) for every table in a schema. Used to
388
461
  * provision a PowDB database from a code-first `defineSchema`/`SchemaMetadata`
@@ -676,7 +749,7 @@ export async function applyPowdbLinks(exec, schema, options = {}) {
676
749
  requireCapability(caps, 'linkIntrospection', 'PowDB `schema links` introspection');
677
750
  }
678
751
  const existing = await listPowdbLinks(exec);
679
- const byOwnerName = new Map(existing.map((l) => [`${l.owner}${l.name}`, l]));
752
+ const byOwnerName = new Map(existing.map((l) => [`${l.owner}\u0000${l.name}`, l]));
680
753
  const desired = deriveDesiredLinks(schema, (owner, name) => {
681
754
  if (shouldWarnOnce(WARN_NS.powdbLinks, `collide:${owner}.${name}`)) {
682
755
  console.warn(`[turbine] applyPowdbLinks: relation "${name}" on "${owner}" collides with a column of the same name; ` +
@@ -685,7 +758,7 @@ export async function applyPowdbLinks(exec, schema, options = {}) {
685
758
  });
686
759
  const executed = [];
687
760
  for (const link of desired) {
688
- const found = byOwnerName.get(`${link.owner}${link.name}`);
761
+ const found = byOwnerName.get(`${link.owner}\u0000${link.name}`);
689
762
  if (found) {
690
763
  const same = found.target === link.target && found.localKey === link.localKey && found.targetKey === link.targetKey;
691
764
  if (!same && shouldWarnOnce(WARN_NS.powdbLinks, `drift:${link.owner}.${link.name}`)) {
@@ -823,6 +896,14 @@ export function coerceValue(raw, col) {
823
896
  * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
824
897
  * native transport). `datetime`-shaped cells (int micros) become `Date`; a
825
898
  * bigint on a `number` column follows the int8 safe-integer policy.
899
+ *
900
+ * A date cell can also arrive as a DIGIT STRING: a nested-projection block's
901
+ * children ride a JSON array, and micros exceed `Number.MAX_SAFE_INTEGER`'s
902
+ * decimal comfort, so the engine renders them as a JSON string. Before that
903
+ * string was parsed here, a nested `with` handed back the raw micros text while
904
+ * the batched loader and the native join both handed back a `Date` (the same
905
+ * relation, three answers). Only an all-digit string is parsed; any other text
906
+ * on a date column passes through untouched.
826
907
  */
827
908
  export function coerceNativeValue(value, col) {
828
909
  if (value === undefined || value === null)
@@ -832,6 +913,8 @@ export function coerceNativeValue(value, col) {
832
913
  return new Date(Number(value) / 1000);
833
914
  if (typeof value === 'number')
834
915
  return new Date(value / 1000);
916
+ if (typeof value === 'string' && /^-?\d+$/.test(value))
917
+ return new Date(Number(value) / 1000);
835
918
  return value;
836
919
  }
837
920
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
@@ -1003,6 +1086,73 @@ export function wrapPowdbError(err) {
1003
1086
  if (/is ambiguous in a projection|aggregates over a nested or link projection/i.test(msg)) {
1004
1087
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
1005
1088
  }
1089
+ // Corrupt storage → ConnectionError (E004). PowDB 0.20 verifies page checksums
1090
+ // at table-OPEN time and fails closed (previously the open scan skipped the bad
1091
+ // page and the failure surfaced later, on the read that touched it). This is a
1092
+ // data-integrity / availability failure, not a query defect, so it joins the
1093
+ // connection families ABOVE the generic validation regexes (whose `StorageError`
1094
+ // token would otherwise class it E003). There is no salvage mode: restoring
1095
+ // from a backup is the documented recovery, so say so.
1096
+ if (/page corrupt|catalog corrupt|corrupt heap superblock|CRC32 mismatch/i.test(msg)) {
1097
+ return new ConnectionError(`[turbine] PowDB refused to open a corrupt data directory: ${msg}. PowDB verifies page checksums on open ` +
1098
+ 'and fails closed rather than serving partial data; there is no skip-corrupt-pages mode, so recover by ' +
1099
+ 'restoring the directory from a backup.', { cause: err });
1100
+ }
1101
+ // Unknown column → ValidationError (E003), naming the column. PowDB 0.20 turned
1102
+ // an unknown column in `filter` / a projection from a silent NULL into an error
1103
+ // (the old behavior made `count(T filter .agee = null)` match every row, so a
1104
+ // delete on that predicate emptied the table). Turbine validates field names
1105
+ // against its own metadata first, so reaching here means the runtime metadata
1106
+ // has drifted from the live catalog.
1107
+ {
1108
+ const m = /column '([^']+)' not found(?: in table '([^']+)')?/i.exec(msg);
1109
+ if (m) {
1110
+ const where = m[2] ? ` on table "${m[2]}"` : '';
1111
+ return new ValidationError(`[turbine] PowDB rejected column "${m[1]}"${where}: it does not exist in the live catalog. ` +
1112
+ 'The schema metadata Turbine is using has drifted from the database; re-derive it ' +
1113
+ '(`schemaDefToMetadata` / `introspectPowdbDatabase`) or apply the missing DDL. ' +
1114
+ `(engine: ${msg})`);
1115
+ }
1116
+ }
1117
+ // Type-mismatched comparison → ValidationError (E003), naming the column. Also
1118
+ // new in 0.20: comparing e.g. a `str` column against an integer literal used to
1119
+ // evaluate true for every row. Runs before the generic `type mismatch` regex
1120
+ // below so the column name and the fix survive into the message.
1121
+ {
1122
+ const m = /type mismatch for column '([^']+)': expected ([^,]+), got (\w+)/i.exec(msg);
1123
+ if (m) {
1124
+ return new ValidationError(`[turbine] PowDB rejected a comparison on column "${m[1]}": the column is ${m[2]} but the bound value is ` +
1125
+ `${m[3]}. PowQL never coerces across types in a comparison (before engine 0.20 this silently matched ` +
1126
+ "every row), so bind a value of the column's own type.");
1127
+ }
1128
+ }
1129
+ // Operator-chain / nesting budget → ValidationError (E003) with the actual fix.
1130
+ // PowDB 0.20 counts flat `and` / `or` chains against the same 64-level budget as
1131
+ // nested parentheses, so a machine-built predicate with very many terms is now
1132
+ // rejected outright (see POWQL_MAX_NESTING_DEPTH).
1133
+ if (/nesting depth exceeds maximum/i.test(msg)) {
1134
+ return new ValidationError(`[turbine] PowDB rejected the query: ${msg}. PowQL bounds the shape of the predicate tree, and a flat ` +
1135
+ `\`OR\` / \`AND\` array counts one level per term (roughly ${POWQL_MAX_NESTING_DEPTH - 1} terms at the top ` +
1136
+ 'level, fewer inside a nested `with` block). Split a large `OR` / `AND` array into several queries and ' +
1137
+ 'merge the results, or express it as a single `in` list, which is one flat node and does not count ' +
1138
+ 'against the budget.');
1139
+ }
1140
+ // Negative limit / offset → ValidationError (E003). Turbine validates these
1141
+ // client-side before emitting, so this is the backstop for a raw PowQL string.
1142
+ if (/(limit|offset) must not be negative/i.test(msg)) {
1143
+ return new ValidationError(`[turbine] PowDB rejected the query: ${msg}. Pass a non-negative \`limit\` / \`offset\` ` +
1144
+ '(before engine 0.20 a negative limit was ignored and returned every row).');
1145
+ }
1146
+ // Client-side result-frame cell cap (`@zvndev/powdb-client` >= 0.20 rejects a
1147
+ // declared result shape over 2,000,000 cells before decoding it, so a hostile or
1148
+ // MITM'd server cannot force a multi-gigabyte allocation). It is a plain driver
1149
+ // Error with no code, so match the message and give the caller the two real
1150
+ // remedies instead of letting it fall through untyped.
1151
+ if (/result too large: \d+ cells/i.test(msg)) {
1152
+ return new ValidationError(`[turbine] PowDB result too large to decode: ${msg}. The client caps one result frame at 2,000,000 cells ` +
1153
+ '(rows x columns). Page the query with `limit` / `offset`, or narrow the row with `select` so each row ' +
1154
+ 'carries fewer columns.');
1155
+ }
1006
1156
  // Typed wire error class (networked, server >= 0.17): the client surfaces the
1007
1157
  // stable one-byte class from the error frame as `.wireErrorClass`. Classify by
1008
1158
  // it BEFORE the generic message regexes: the server sanitizes non-allowlisted
@@ -1713,11 +1863,19 @@ function powqlNumberText(n) {
1713
1863
  * pre-existing tokens, so the tokenization / escape surface this ceiling guards is
1714
1864
  * unmoved. The 0.19.1 link-introspection / link-path round is likewise lexer-neutral:
1715
1865
  * `git diff v0.19.0 v0.19.1 -- crates/query/src/lexer.rs` is empty (the bare-dotted-path
1716
- * hard error is parser-level, not tokenization), so this ceiling stays `'0.19'`. The
1717
- * guard in {@link PowdbEmbeddedPool.exec} compares major.minor only, so `'0.19'`
1718
- * already covers every 0.19.x patch, no bump is needed for 0.19.1.
1866
+ * hard error is parser-level, not tokenization). The guard in
1867
+ * {@link PowdbEmbeddedPool.exec} compares major.minor only, so one entry covers every
1868
+ * patch of a line.
1869
+ *
1870
+ * Verification for the 0.20 line: `git diff v0.19.1 v0.20.0 -- crates/query/src/lexer.rs
1871
+ * crates/query/src/token.rs` is EMPTY, both files are byte-identical. Everything 0.20
1872
+ * changed in the query crate is downstream of tokenization: the operator-chain nesting
1873
+ * budget and the count-projection lift are in `parser.rs`, and the unknown-column /
1874
+ * type-mismatch / negative-limit refusals are a new executor validation pass
1875
+ * (`executor/plan_exec/validate.rs`). No new escape sequence, so the escaper's contract
1876
+ * is unmoved and the ceiling advances to `'0.20'`.
1719
1877
  */
1720
- export const POWQL_LEXER_TESTED_CEILING = '0.19';
1878
+ export const POWQL_LEXER_TESTED_CEILING = '0.20';
1721
1879
  /**
1722
1880
  * Escape a string into a PowQL `"…"` literal, matching the engine lexer's
1723
1881
  * escape rules.