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
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.extractRelationFields = extractRelationFields;
17
17
  exports.hasRelationFields = hasRelationFields;
18
18
  exports.injectForeignKey = injectForeignKey;
19
+ exports.createManyShapeRuns = createManyShapeRuns;
19
20
  exports.executeNestedCreate = executeNestedCreate;
20
21
  exports.executeNestedUpdate = executeNestedUpdate;
21
22
  const errors_js_1 = require("./errors.js");
@@ -86,6 +87,72 @@ function injectForeignKey(childData, relation, parentRow, schema) {
86
87
  }
87
88
  return result;
88
89
  }
90
+ /**
91
+ * Split rows destined for `createMany` into CONTIGUOUS runs that each name the
92
+ * same fields.
93
+ *
94
+ * `createMany` compiles ONE statement whose column list comes from the first
95
+ * row, so it refuses a batch whose rows disagree about which fields they name
96
+ * (`assertUniformCreateManyRows` in query/writes.ts): a field a later row omits
97
+ * would be bound as NULL over that column's default, and a field only a later
98
+ * row names would be dropped. Rows that arrive as ordinary user input, a nested
99
+ * `create: [...]` array or a Prisma-shaped `createMany` on the compat layer, are
100
+ * allowed to mix shapes, so the caller splits the batch instead of refusing it
101
+ * and issues one `createMany` per run. Every run is internally uniform, so the
102
+ * refusal still stands where it matters and none of the corruption it exists to
103
+ * stop becomes reachable.
104
+ *
105
+ * Runs are CONTIGUOUS rather than grouped by shape across the whole array: one
106
+ * statement inserted the rows in array order, and running contiguous runs in
107
+ * order keeps that, so generated keys stay ascending with the caller's array.
108
+ * Grouping non-adjacent rows would batch harder (`A B A B` in two statements
109
+ * rather than four) at the cost of reordering rows, which is observable through
110
+ * any server-assigned sequence.
111
+ *
112
+ * A uniform array, the overwhelmingly common shape, yields exactly one run
113
+ * holding the ORIGINAL array, so the caller makes the one call it always made.
114
+ *
115
+ * A key whose value is `undefined` is not named, matching `definedKeys` in
116
+ * query/writes.ts (and single-row `create`).
117
+ *
118
+ * @internal shared by the nested-write batch path and turbine-orm/prisma-compat.
119
+ */
120
+ function createManyShapeRuns(rows) {
121
+ if (rows.length === 0)
122
+ return [];
123
+ const runs = [];
124
+ let current = [];
125
+ let currentShape = '';
126
+ for (const row of rows) {
127
+ const shape = rowShapeKey(row);
128
+ if (current.length === 0) {
129
+ currentShape = shape;
130
+ }
131
+ else if (shape !== currentShape) {
132
+ runs.push(current);
133
+ current = [];
134
+ currentShape = shape;
135
+ }
136
+ current.push(row);
137
+ }
138
+ runs.push(current);
139
+ // One run means every row agreed: hand back the caller's own array so the
140
+ // resulting call is indistinguishable from the ungrouped one.
141
+ return runs.length === 1 ? [rows] : runs;
142
+ }
143
+ /**
144
+ * Order-independent identity of the fields a row names. Each key is written
145
+ * length-prefixed so no delimiter can appear inside one: a property name may
146
+ * legally contain any character at all, so a plain join would let two distinct
147
+ * key sets collide on the same string.
148
+ */
149
+ function rowShapeKey(row) {
150
+ return Object.keys(row)
151
+ .filter((k) => row[k] !== undefined)
152
+ .sort()
153
+ .map((k) => `${k.length}:${k}`)
154
+ .join(',');
155
+ }
89
156
  // ---------------------------------------------------------------------------
90
157
  // Internal helpers
91
158
  // ---------------------------------------------------------------------------
@@ -841,9 +908,20 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
841
908
  }
842
909
  }
843
910
  else {
844
- // Batch via createMany (UNNEST), fast path
911
+ // Batch via createMany (UNNEST), fast path.
912
+ //
913
+ // A nested `create: [...]` array is ordinary user input and may
914
+ // legitimately mix row shapes (`[{ title }, { title, published }]`),
915
+ // which one createMany cannot express, see createManyShapeRuns. Split
916
+ // it into contiguous same-shape runs and issue one createMany per run:
917
+ // every run stays uniform, so an omitted field takes its column default
918
+ // instead of being written as NULL, and the rows still reach the
919
+ // database in the caller's array order exactly as the single statement
920
+ // put them there. A uniform array is one run and one identical call.
845
921
  const injected = items.map((item) => injectForeignKey(item, rel, parentRow, ctx.schema));
846
- await ctx.tx.table(rel.to).createMany({ data: injected });
922
+ for (const run of createManyShapeRuns(injected)) {
923
+ await ctx.tx.table(rel.to).createMany({ data: run });
924
+ }
847
925
  }
848
926
  }
849
927
  }
@@ -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
@@ -46,7 +46,16 @@
46
46
  * round-trip; only plain `unique`/`index` columns appear in `indexes`.
47
47
  * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types
48
48
  * (`Date` / `string` / `Uint8Array`). Turbine never emits those PowQL types
49
- * on write, so writing to such a column may not round-trip.
49
+ * on write, so writing to such a column may not round-trip. This introspector
50
+ * is the ONLY producer of `dialectType: 'datetime'` metadata, and a predicate
51
+ * on such a column is version-gated: comparing it against the integer
52
+ * microseconds Turbine binds was silently wrong below engine 0.20, so those
53
+ * predicates raise a typed E017 there rather than being answered wrongly (see
54
+ * `PowdbCapabilities.datetimeCompare`). The `in` / `not in` list forms are
55
+ * still wrong upstream at 0.20, so Turbine never emits one for such a column:
56
+ * it compiles to the equality chain the engine answers correctly, bounded by
57
+ * `MAX_POWQL_DATETIME_TERMS`. Reads, ordering, grouping and null checks on
58
+ * the column are unaffected.
50
59
  *
51
60
  * v1 is a PROGRAMMATIC API (exported from `turbine-orm/powdb`); the CLI's
52
61
  * `turbine generate` still defaults to Postgres. Routing a `powdb://` URL
@@ -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/cjs/powdb.js CHANGED
@@ -90,13 +90,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
90
90
  return (mod && mod.__esModule) ? mod : { "default": mod };
91
91
  };
92
92
  Object.defineProperty(exports, "__esModule", { value: true });
93
- exports.PowqlInterface = exports.introspectPowdbDatabase = exports.PowdbEmbeddedPool = exports.POWQL_LEXER_TESTED_CEILING = exports.PowdbPool = exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = exports.POWQL_KEYWORDS = exports.ALL_POWDB_CAPABILITIES = exports.MIN_POWDB_VERSION = exports.PowdbJsonParam = exports.PowdbFloatParam = exports.powdbDialect = void 0;
93
+ exports.PowqlInterface = exports.introspectPowdbDatabase = exports.PowdbEmbeddedPool = exports.POWQL_LEXER_TESTED_CEILING = exports.PowdbPool = exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = exports.POWQL_KEYWORDS = exports.ALL_POWDB_CAPABILITIES = exports.POWQL_MAX_NESTING_DEPTH = exports.MIN_POWDB_VERSION = exports.PowdbJsonParam = exports.PowdbFloatParam = exports.powdbDialect = void 0;
94
94
  exports.parsePowdbUrl = parsePowdbUrl;
95
95
  exports.assertSupportedPowdbVersion = assertSupportedPowdbVersion;
96
96
  exports.capabilitiesFromVersion = capabilitiesFromVersion;
97
97
  exports.requireCapability = requireCapability;
98
98
  exports.isJsonColumn = isJsonColumn;
99
99
  exports.powqlColumnType = powqlColumnType;
100
+ exports.isPowdbDatetimeColumn = isPowdbDatetimeColumn;
100
101
  exports.quotePowqlIdent = quotePowqlIdent;
101
102
  exports.deriveDesiredLinks = deriveDesiredLinks;
102
103
  exports.powdbLinkStatement = powdbLinkStatement;
@@ -258,6 +259,34 @@ function assertSupportedPowdbVersion(version) {
258
259
  throw new errors_js_1.ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${exports.MIN_POWDB_VERSION}; the server reports "${version}". ` +
259
260
  'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
260
261
  }
262
+ /**
263
+ * PowQL's parser bounds the SHAPE of the AST it produces, not just its own
264
+ * recursion: an `and` / `or` chain is parsed iteratively but re-wraps its
265
+ * accumulator once per term, so a flat chain counts against the same 64-level
266
+ * nesting budget as nested parentheses (PowDB 0.20 security fix, an unbounded
267
+ * chain overflowed the stack in a later recursive walk).
268
+ *
269
+ * The practical ceiling for a Turbine-generated predicate is therefore around
270
+ * **63 terms in one `OR` / `AND` array at the top level**, and fewer inside a
271
+ * nested projection block (measured: 63 flat, 61 one level of parens deep),
272
+ * because the budget is shared with whatever depth the predicate sits at.
273
+ * Turbine does NOT pre-check this client-side: the true remaining budget
274
+ * depends on the engine's parse depth at that point, so a client-side estimate
275
+ * would refuse valid queries. The engine's refusal is mapped to a typed
276
+ * {@link ValidationError} with a split-the-array hint instead (see
277
+ * {@link wrapPowdbError}).
278
+ *
279
+ * A literal `in (a, b, c, …)` list is a single flat node, NOT a chain, so it
280
+ * does not count against the budget (verified to 5,000 elements). Turbine's
281
+ * relation-key chunking (`MAX_RELATION_KEYS`) is unaffected.
282
+ *
283
+ * The one exception is a key list on a PowDB-native `datetime` column, which
284
+ * cannot use the list form at all (the engine still compares it by type tag) and
285
+ * is expanded into exactly such a chain. Those lists ARE pre-checked and chunked,
286
+ * against a deliberately conservative cap, see `MAX_POWQL_DATETIME_TERMS` in
287
+ * powql.ts.
288
+ */
289
+ exports.POWQL_MAX_NESTING_DEPTH = 64;
261
290
  /**
262
291
  * Minimum engine version each gated feature needs, for the E017 hint text.
263
292
  * Most gates carry a `major.minor` floor (patch-insensitive); the two link
@@ -275,6 +304,8 @@ const POWDB_FEATURE_MIN_VERSION = {
275
304
  entityLinks: '0.19',
276
305
  linkIntrospection: '0.19.1',
277
306
  linkPaths: '0.19.1',
307
+ datetimeCompare: '0.20',
308
+ projectedCountNonNull: '0.20',
278
309
  };
279
310
  /**
280
311
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -293,6 +324,14 @@ const POWDB_FEATURE_MIN_VERSION = {
293
324
  * projections), and `linkIntrospection` is only meaningful once genuinely
294
325
  * probed, so both must come from a real version resolution, never a bare
295
326
  * construction.
327
+ * `datetimeCompare` / `projectedCountNonNull` stay ON here for the same
328
+ * trusted-caller reason as `jsonDocs` and `serverJoins`. Neither is a fallback
329
+ * gate: with the flag OFF the affected query is REFUSED, not served by some
330
+ * other statement, so defaulting them off would break working queries rather
331
+ * than protect anything. Every path that can learn the engine version
332
+ * (`turbinePowDB`, embedded or networked) resolves them from a real probe; this
333
+ * fallback only covers a hand-constructed or injected pool, whose owner is
334
+ * asserting the engine is current.
296
335
  */
297
336
  exports.ALL_POWDB_CAPABILITIES = {
298
337
  engineVersion: null,
@@ -304,6 +343,8 @@ exports.ALL_POWDB_CAPABILITIES = {
304
343
  entityLinks: false,
305
344
  linkIntrospection: false,
306
345
  linkPaths: false,
346
+ datetimeCompare: true,
347
+ projectedCountNonNull: true,
307
348
  nativeRaw: false,
308
349
  };
309
350
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -347,6 +388,8 @@ function capabilitiesFromVersion(version, opts = {}) {
347
388
  entityLinks: false,
348
389
  linkIntrospection: false,
349
390
  linkPaths: false,
391
+ datetimeCompare: false,
392
+ projectedCountNonNull: false,
350
393
  nativeRaw: false,
351
394
  };
352
395
  }
@@ -363,6 +406,8 @@ function capabilitiesFromVersion(version, opts = {}) {
363
406
  // never 0.19.0.
364
407
  linkIntrospection: atLeastVersion(sem, 0, 19, 1),
365
408
  linkPaths: atLeastVersion(sem, 0, 19, 1),
409
+ datetimeCompare: atLeastVersion(sem, 0, 20),
410
+ projectedCountNonNull: atLeastVersion(sem, 0, 20),
366
411
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
367
412
  };
368
413
  }
@@ -370,16 +415,26 @@ function capabilitiesFromVersion(version, opts = {}) {
370
415
  * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
371
416
  * PowQL feature is used on an engine that does not support it. Keeps old engines
372
417
  * getting clean typed errors instead of raw PowQL parse failures.
418
+ *
419
+ * The error's first sentence already names the feature (`<feature> is
420
+ * unsupported on "PowDB".`), so the hint says "Requires PowDB >= x" rather than
421
+ * repeating the label: a long feature description read twice in one message
422
+ * (`per-field \`_count\` … is unsupported … per-field \`_count\` … requires …`)
423
+ * buries the version floor that is the actionable part.
424
+ *
425
+ * `extra` appends one more sentence for gates that have a workaround worth
426
+ * naming (e.g. the read path that answers the same query without the gated
427
+ * comparison).
373
428
  */
374
- function requireCapability(caps, key, feature) {
429
+ function requireCapability(caps, key, feature, extra) {
375
430
  if (caps[key])
376
431
  return;
377
432
  const min = POWDB_FEATURE_MIN_VERSION[key];
378
433
  const reported = caps.engineVersion
379
434
  ? `this connection reports ${caps.engineVersion}`
380
435
  : 'this connection could not report a version';
381
- throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
382
- '(or pass `assumeEngineVersion` if the version cannot be detected).');
436
+ throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `Requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
437
+ `(or pass \`assumeEngineVersion\` if the version cannot be detected).${extra ? ` ${extra}` : ''}`);
383
438
  }
384
439
  /**
385
440
  * Does this column map to PowDB's native `json` document type? A Postgres
@@ -443,6 +498,25 @@ function isFloatColumn(col) {
443
498
  function isDateColumn(col) {
444
499
  return col.tsType.replace(/\s*\|\s*null$/i, '').trim() === 'Date';
445
500
  }
501
+ /**
502
+ * Is this column stored in PowDB's NATIVE `datetime` type (as opposed to the
503
+ * `int` epoch micros Turbine's own DDL emits for a `Date` column)?
504
+ *
505
+ * Only the literal PowQL type name counts. `powqlColumnType` never returns
506
+ * `datetime`, so a Turbine-provisioned table can never have one; the shapes that
507
+ * do are a table created outside Turbine and read back through
508
+ * `introspectPowdbDatabase` (which maps `datetime` → `{ tsType: 'Date',
509
+ * dialectType: 'datetime' }`), or hand-written metadata declaring it. Deliberately
510
+ * strict: a Postgres-sourced `timestamptz` column is DDL'd as PowQL `int`, so it
511
+ * is NOT a PowDB datetime and must not be caught here.
512
+ *
513
+ * Matters because comparing a datetime column against the integer timestamp
514
+ * literal Turbine binds was silently wrong below engine 0.20 (see
515
+ * {@link PowdbCapabilities.datetimeCompare}).
516
+ */
517
+ function isPowdbDatetimeColumn(col) {
518
+ return (col.dialectType ?? col.pgType ?? '').toLowerCase() === 'datetime';
519
+ }
446
520
  /**
447
521
  * Generate PowQL DDL (`type T { … }`) for every table in a schema. Used to
448
522
  * provision a PowDB database from a code-first `defineSchema`/`SchemaMetadata`
@@ -736,7 +810,7 @@ async function applyPowdbLinks(exec, schema, options = {}) {
736
810
  requireCapability(caps, 'linkIntrospection', 'PowDB `schema links` introspection');
737
811
  }
738
812
  const existing = await listPowdbLinks(exec);
739
- const byOwnerName = new Map(existing.map((l) => [`${l.owner}${l.name}`, l]));
813
+ const byOwnerName = new Map(existing.map((l) => [`${l.owner}\u0000${l.name}`, l]));
740
814
  const desired = deriveDesiredLinks(schema, (owner, name) => {
741
815
  if ((0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.powdbLinks, `collide:${owner}.${name}`)) {
742
816
  console.warn(`[turbine] applyPowdbLinks: relation "${name}" on "${owner}" collides with a column of the same name; ` +
@@ -745,7 +819,7 @@ async function applyPowdbLinks(exec, schema, options = {}) {
745
819
  });
746
820
  const executed = [];
747
821
  for (const link of desired) {
748
- const found = byOwnerName.get(`${link.owner}${link.name}`);
822
+ const found = byOwnerName.get(`${link.owner}\u0000${link.name}`);
749
823
  if (found) {
750
824
  const same = found.target === link.target && found.localKey === link.localKey && found.targetKey === link.targetKey;
751
825
  if (!same && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.powdbLinks, `drift:${link.owner}.${link.name}`)) {
@@ -883,6 +957,14 @@ function coerceValue(raw, col) {
883
957
  * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
884
958
  * native transport). `datetime`-shaped cells (int micros) become `Date`; a
885
959
  * bigint on a `number` column follows the int8 safe-integer policy.
960
+ *
961
+ * A date cell can also arrive as a DIGIT STRING: a nested-projection block's
962
+ * children ride a JSON array, and micros exceed `Number.MAX_SAFE_INTEGER`'s
963
+ * decimal comfort, so the engine renders them as a JSON string. Before that
964
+ * string was parsed here, a nested `with` handed back the raw micros text while
965
+ * the batched loader and the native join both handed back a `Date` (the same
966
+ * relation, three answers). Only an all-digit string is parsed; any other text
967
+ * on a date column passes through untouched.
886
968
  */
887
969
  function coerceNativeValue(value, col) {
888
970
  if (value === undefined || value === null)
@@ -892,6 +974,8 @@ function coerceNativeValue(value, col) {
892
974
  return new Date(Number(value) / 1000);
893
975
  if (typeof value === 'number')
894
976
  return new Date(value / 1000);
977
+ if (typeof value === 'string' && /^-?\d+$/.test(value))
978
+ return new Date(Number(value) / 1000);
895
979
  return value;
896
980
  }
897
981
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
@@ -1063,6 +1147,73 @@ function wrapPowdbError(err) {
1063
1147
  if (/is ambiguous in a projection|aggregates over a nested or link projection/i.test(msg)) {
1064
1148
  return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
1065
1149
  }
1150
+ // Corrupt storage → ConnectionError (E004). PowDB 0.20 verifies page checksums
1151
+ // at table-OPEN time and fails closed (previously the open scan skipped the bad
1152
+ // page and the failure surfaced later, on the read that touched it). This is a
1153
+ // data-integrity / availability failure, not a query defect, so it joins the
1154
+ // connection families ABOVE the generic validation regexes (whose `StorageError`
1155
+ // token would otherwise class it E003). There is no salvage mode: restoring
1156
+ // from a backup is the documented recovery, so say so.
1157
+ if (/page corrupt|catalog corrupt|corrupt heap superblock|CRC32 mismatch/i.test(msg)) {
1158
+ return new errors_js_1.ConnectionError(`[turbine] PowDB refused to open a corrupt data directory: ${msg}. PowDB verifies page checksums on open ` +
1159
+ 'and fails closed rather than serving partial data; there is no skip-corrupt-pages mode, so recover by ' +
1160
+ 'restoring the directory from a backup.', { cause: err });
1161
+ }
1162
+ // Unknown column → ValidationError (E003), naming the column. PowDB 0.20 turned
1163
+ // an unknown column in `filter` / a projection from a silent NULL into an error
1164
+ // (the old behavior made `count(T filter .agee = null)` match every row, so a
1165
+ // delete on that predicate emptied the table). Turbine validates field names
1166
+ // against its own metadata first, so reaching here means the runtime metadata
1167
+ // has drifted from the live catalog.
1168
+ {
1169
+ const m = /column '([^']+)' not found(?: in table '([^']+)')?/i.exec(msg);
1170
+ if (m) {
1171
+ const where = m[2] ? ` on table "${m[2]}"` : '';
1172
+ return new errors_js_1.ValidationError(`[turbine] PowDB rejected column "${m[1]}"${where}: it does not exist in the live catalog. ` +
1173
+ 'The schema metadata Turbine is using has drifted from the database; re-derive it ' +
1174
+ '(`schemaDefToMetadata` / `introspectPowdbDatabase`) or apply the missing DDL. ' +
1175
+ `(engine: ${msg})`);
1176
+ }
1177
+ }
1178
+ // Type-mismatched comparison → ValidationError (E003), naming the column. Also
1179
+ // new in 0.20: comparing e.g. a `str` column against an integer literal used to
1180
+ // evaluate true for every row. Runs before the generic `type mismatch` regex
1181
+ // below so the column name and the fix survive into the message.
1182
+ {
1183
+ const m = /type mismatch for column '([^']+)': expected ([^,]+), got (\w+)/i.exec(msg);
1184
+ if (m) {
1185
+ return new errors_js_1.ValidationError(`[turbine] PowDB rejected a comparison on column "${m[1]}": the column is ${m[2]} but the bound value is ` +
1186
+ `${m[3]}. PowQL never coerces across types in a comparison (before engine 0.20 this silently matched ` +
1187
+ "every row), so bind a value of the column's own type.");
1188
+ }
1189
+ }
1190
+ // Operator-chain / nesting budget → ValidationError (E003) with the actual fix.
1191
+ // PowDB 0.20 counts flat `and` / `or` chains against the same 64-level budget as
1192
+ // nested parentheses, so a machine-built predicate with very many terms is now
1193
+ // rejected outright (see POWQL_MAX_NESTING_DEPTH).
1194
+ if (/nesting depth exceeds maximum/i.test(msg)) {
1195
+ return new errors_js_1.ValidationError(`[turbine] PowDB rejected the query: ${msg}. PowQL bounds the shape of the predicate tree, and a flat ` +
1196
+ `\`OR\` / \`AND\` array counts one level per term (roughly ${exports.POWQL_MAX_NESTING_DEPTH - 1} terms at the top ` +
1197
+ 'level, fewer inside a nested `with` block). Split a large `OR` / `AND` array into several queries and ' +
1198
+ 'merge the results, or express it as a single `in` list, which is one flat node and does not count ' +
1199
+ 'against the budget.');
1200
+ }
1201
+ // Negative limit / offset → ValidationError (E003). Turbine validates these
1202
+ // client-side before emitting, so this is the backstop for a raw PowQL string.
1203
+ if (/(limit|offset) must not be negative/i.test(msg)) {
1204
+ return new errors_js_1.ValidationError(`[turbine] PowDB rejected the query: ${msg}. Pass a non-negative \`limit\` / \`offset\` ` +
1205
+ '(before engine 0.20 a negative limit was ignored and returned every row).');
1206
+ }
1207
+ // Client-side result-frame cell cap (`@zvndev/powdb-client` >= 0.20 rejects a
1208
+ // declared result shape over 2,000,000 cells before decoding it, so a hostile or
1209
+ // MITM'd server cannot force a multi-gigabyte allocation). It is a plain driver
1210
+ // Error with no code, so match the message and give the caller the two real
1211
+ // remedies instead of letting it fall through untyped.
1212
+ if (/result too large: \d+ cells/i.test(msg)) {
1213
+ return new errors_js_1.ValidationError(`[turbine] PowDB result too large to decode: ${msg}. The client caps one result frame at 2,000,000 cells ` +
1214
+ '(rows x columns). Page the query with `limit` / `offset`, or narrow the row with `select` so each row ' +
1215
+ 'carries fewer columns.');
1216
+ }
1066
1217
  // Typed wire error class (networked, server >= 0.17): the client surfaces the
1067
1218
  // stable one-byte class from the error frame as `.wireErrorClass`. Classify by
1068
1219
  // it BEFORE the generic message regexes: the server sanitizes non-allowlisted
@@ -1774,11 +1925,19 @@ function powqlNumberText(n) {
1774
1925
  * pre-existing tokens, so the tokenization / escape surface this ceiling guards is
1775
1926
  * unmoved. The 0.19.1 link-introspection / link-path round is likewise lexer-neutral:
1776
1927
  * `git diff v0.19.0 v0.19.1 -- crates/query/src/lexer.rs` is empty (the bare-dotted-path
1777
- * hard error is parser-level, not tokenization), so this ceiling stays `'0.19'`. The
1778
- * guard in {@link PowdbEmbeddedPool.exec} compares major.minor only, so `'0.19'`
1779
- * already covers every 0.19.x patch, no bump is needed for 0.19.1.
1928
+ * hard error is parser-level, not tokenization). The guard in
1929
+ * {@link PowdbEmbeddedPool.exec} compares major.minor only, so one entry covers every
1930
+ * patch of a line.
1931
+ *
1932
+ * Verification for the 0.20 line: `git diff v0.19.1 v0.20.0 -- crates/query/src/lexer.rs
1933
+ * crates/query/src/token.rs` is EMPTY, both files are byte-identical. Everything 0.20
1934
+ * changed in the query crate is downstream of tokenization: the operator-chain nesting
1935
+ * budget and the count-projection lift are in `parser.rs`, and the unknown-column /
1936
+ * type-mismatch / negative-limit refusals are a new executor validation pass
1937
+ * (`executor/plan_exec/validate.rs`). No new escape sequence, so the escaper's contract
1938
+ * is unmoved and the ceiling advances to `'0.20'`.
1780
1939
  */
1781
- exports.POWQL_LEXER_TESTED_CEILING = '0.19';
1940
+ exports.POWQL_LEXER_TESTED_CEILING = '0.20';
1782
1941
  /**
1783
1942
  * Escape a string into a PowQL `"…"` literal, matching the engine lexer's
1784
1943
  * escape rules.