turbine-orm 0.32.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/dialect.js +1 -0
  3. package/dist/cjs/index-advisor.js +0 -0
  4. package/dist/cjs/index.js +2 -1
  5. package/dist/cjs/mssql.js +3 -0
  6. package/dist/cjs/mysql.js +3 -0
  7. package/dist/cjs/optional-peer-import.cjs +28 -0
  8. package/dist/cjs/powdb-introspect.js +222 -0
  9. package/dist/cjs/powdb.js +446 -55
  10. package/dist/cjs/powql.js +566 -111
  11. package/dist/cjs/query/builder.js +136 -53
  12. package/dist/cjs/query/filters.js +4 -4
  13. package/dist/cjs/schema-builder.js +16 -0
  14. package/dist/cjs/schema-metadata.js +81 -10
  15. package/dist/cjs/sqlite.js +2 -0
  16. package/dist/dialect.d.ts +7 -0
  17. package/dist/dialect.js +1 -0
  18. package/dist/index-advisor.d.ts +15 -1
  19. package/dist/index-advisor.js +0 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/mssql.js +3 -0
  23. package/dist/mysql.js +3 -0
  24. package/dist/optional-peer-import.cjs +28 -0
  25. package/dist/optional-peer-import.d.cts +19 -0
  26. package/dist/powdb-introspect.d.ts +84 -0
  27. package/dist/powdb-introspect.js +219 -0
  28. package/dist/powdb.d.ts +249 -13
  29. package/dist/powdb.js +438 -54
  30. package/dist/powql.d.ts +113 -6
  31. package/dist/powql.js +568 -113
  32. package/dist/query/builder.d.ts +11 -0
  33. package/dist/query/builder.js +136 -53
  34. package/dist/query/filters.d.ts +3 -3
  35. package/dist/query/filters.js +4 -4
  36. package/dist/query/types.d.ts +50 -6
  37. package/dist/schema-builder.d.ts +46 -1
  38. package/dist/schema-builder.js +15 -0
  39. package/dist/schema-metadata.d.ts +13 -7
  40. package/dist/schema-metadata.js +82 -11
  41. package/dist/schema.d.ts +25 -0
  42. package/dist/sqlite.js +2 -0
  43. package/package.json +3 -3
package/dist/powdb.js CHANGED
@@ -92,6 +92,9 @@ export const powdbDialect = {
92
92
  supportsRLS: false,
93
93
  supportsAdvisoryLock: false,
94
94
  supportsILike: false,
95
+ // PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
96
+ // earlier, this override keeps the flag truthful if a future path consults it.
97
+ supportsLateralJoin: false,
95
98
  beginStatement: () => 'begin',
96
99
  commitStatement: () => 'commit',
97
100
  rollbackStatement: () => 'rollback',
@@ -121,6 +124,22 @@ export class PowdbFloatParam {
121
124
  this.value = value;
122
125
  }
123
126
  }
127
+ /**
128
+ * Marker wrapper for a JS object/array bound to a `json` document column. Both
129
+ * transports serialize `value` with `JSON.stringify` and send the text as a
130
+ * `str` param / string literal, exactly how the PowDB docs insert a json
131
+ * document (the engine validates it as JSON text and stores the canonical
132
+ * binary form). Constructed in {@link PowqlInterface.param} when the target
133
+ * column is `json` and the value is a non-null object/array; a JS string
134
+ * written to a json column passes through RAW (same contract as pg jsonb,
135
+ * pass `'"x"'` to store the JSON string `"x"`), and `null` stays `null`.
136
+ */
137
+ export class PowdbJsonParam {
138
+ value;
139
+ constructor(value) {
140
+ this.value = value;
141
+ }
142
+ }
124
143
  /** Minimum PowDB server version the networked transport requires. */
125
144
  export const MIN_POWDB_VERSION = '0.7.0';
126
145
  /**
@@ -171,19 +190,115 @@ export function assertSupportedPowdbVersion(version) {
171
190
  throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
172
191
  'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
173
192
  }
193
+ /** Minimum engine version each gated feature needs, for the E017 hint text. */
194
+ const POWDB_FEATURE_MIN_VERSION = {
195
+ introspection: '0.10',
196
+ jsonDocs: '0.12',
197
+ docFieldIndexes: '0.13',
198
+ };
199
+ /**
200
+ * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
201
+ * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
202
+ * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
203
+ * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
204
+ * actual wire path and must only be enabled after a real server-version probe,
205
+ * never inferred from a bare construction.
206
+ */
207
+ export const ALL_POWDB_CAPABILITIES = {
208
+ engineVersion: null,
209
+ jsonDocs: true,
210
+ docFieldIndexes: true,
211
+ introspection: true,
212
+ nativeRaw: false,
213
+ };
214
+ /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
215
+ function parsePowdbSemver(version) {
216
+ const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
217
+ if (!m)
218
+ return null;
219
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
220
+ }
221
+ /** Is `sem` at least `major.minor`? */
222
+ function atLeastVersion(sem, major, minor) {
223
+ return sem.major > major || (sem.major === major && sem.minor >= minor);
224
+ }
225
+ /**
226
+ * Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
227
+ * unknown version turns every gate OFF (the E017 hint then tells the caller to
228
+ * upgrade or pass `assumeEngineVersion`). `nativeRaw` requires BOTH the client
229
+ * to expose `queryNativeRaw` (passed in) AND server ≥ 0.13.
230
+ */
231
+ export function capabilitiesFromVersion(version, opts = {}) {
232
+ const sem = parsePowdbSemver(version);
233
+ if (!sem) {
234
+ return {
235
+ engineVersion: version ?? null,
236
+ jsonDocs: false,
237
+ docFieldIndexes: false,
238
+ introspection: false,
239
+ nativeRaw: false,
240
+ };
241
+ }
242
+ return {
243
+ engineVersion: `${sem.major}.${sem.minor}.${sem.patch}`,
244
+ introspection: atLeastVersion(sem, 0, 10),
245
+ jsonDocs: atLeastVersion(sem, 0, 12),
246
+ docFieldIndexes: atLeastVersion(sem, 0, 13),
247
+ nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
248
+ };
249
+ }
250
+ /**
251
+ * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
252
+ * PowQL feature is used on an engine that does not support it. Keeps old engines
253
+ * getting clean typed errors instead of raw PowQL parse failures.
254
+ */
255
+ export function requireCapability(caps, key, feature) {
256
+ if (caps[key])
257
+ return;
258
+ const min = POWDB_FEATURE_MIN_VERSION[key];
259
+ const reported = caps.engineVersion
260
+ ? `this connection reports ${caps.engineVersion}`
261
+ : 'this connection could not report a version';
262
+ throw new UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
263
+ '(or pass `assumeEngineVersion` if the version cannot be detected).');
264
+ }
265
+ /**
266
+ * Does this column map to PowDB's native `json` document type? A Postgres
267
+ * `json`/`jsonb` type (via `dialectType`/`pgType`) is authoritative; otherwise
268
+ * the tsType heuristic (`Record<…>`, `object`, `unknown`, an object/array
269
+ * literal) that the four scalar branches do not claim. Array columns never map
270
+ * to json, a PowDB array only exists INSIDE a json document, so a Postgres
271
+ * array column has no PowDB shape and still throws in {@link powqlColumnType}.
272
+ */
273
+ export function isJsonColumn(col) {
274
+ if (col.isArray)
275
+ return false;
276
+ const dbType = (col.dialectType ?? col.pgType ?? '').toLowerCase();
277
+ if (dbType === 'json' || dbType === 'jsonb')
278
+ return true;
279
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
280
+ if (ts === 'Date' || ts === 'boolean' || ts === 'number' || ts === 'bigint' || ts === 'string')
281
+ return false;
282
+ if (ts === 'Buffer' || ts === 'Uint8Array')
283
+ return false;
284
+ return /Record<|object|unknown|\[\]|\{/.test(ts);
285
+ }
174
286
  /**
175
287
  * Map a Turbine column to the PowQL DDL type used in `defineSchema` →
176
288
  * `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
177
289
  * which cannot hold client-supplied values on the wire (no literal, no cast):
178
290
  * - `Date` → `int` (epoch micros) - `boolean` → `bool`
179
291
  * - integral `number`/`bigint` → `int` - fractional `number` → `float`
292
+ * - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
180
293
  * - everything else (incl. UUID/PK strings) → `str`
181
- * Array / JSON / bytes columns throw they have no PowDB equivalent.
294
+ * Array (non-json) and bytes columns throw, they have no PowDB equivalent.
182
295
  */
183
296
  export function powqlColumnType(col) {
184
297
  if (col.isArray) {
185
298
  throw new ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
186
299
  }
300
+ if (isJsonColumn(col))
301
+ return 'json';
187
302
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
188
303
  if (ts === 'Date')
189
304
  return 'int'; // epoch micros
@@ -198,9 +313,6 @@ export function powqlColumnType(col) {
198
313
  if (ts === 'Buffer' || ts === 'Uint8Array') {
199
314
  throw new ValidationError(`[turbine] Column "${col.name}" is binary — PowDB cannot store client-supplied bytes on the wire. Use a string (e.g. base64) instead.`);
200
315
  }
201
- if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
202
- throw new ValidationError(`[turbine] Column "${col.name}" (${col.tsType}) maps to JSON/object, which PowDB has no type for. Flatten it or store a JSON string.`);
203
- }
204
316
  return 'str';
205
317
  }
206
318
  /** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
@@ -340,7 +452,8 @@ export function quotePowqlIdent(name) {
340
452
  }
341
453
  return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
342
454
  }
343
- export function powqlSchemaDDL(schema) {
455
+ export function powqlSchemaDDL(schema, opts = {}) {
456
+ const caps = opts.capabilities;
344
457
  const stmts = [];
345
458
  for (const meta of Object.values(schema.tables)) {
346
459
  const pkSet = new Set(meta.primaryKey);
@@ -351,6 +464,12 @@ export function powqlSchemaDDL(schema) {
351
464
  // cannot enforce the tuple's uniqueness at the engine level.
352
465
  const pkIsSingle = meta.primaryKey.length === 1;
353
466
  const fields = meta.columns.map((col) => {
467
+ const powqlType = powqlColumnType(col);
468
+ // Gate `json` columns behind the engine's jsonDocs capability when a
469
+ // caller supplied one, an old engine has no `json` type and would reject
470
+ // the DDL. Pure-function callers (no opts) emit unconditionally.
471
+ if (powqlType === 'json' && caps)
472
+ requireCapability(caps, 'jsonDocs', 'JSON document columns');
354
473
  const mods = [];
355
474
  if (!col.nullable || pkSet.has(col.name))
356
475
  mods.push('required');
@@ -359,15 +478,57 @@ export function powqlSchemaDDL(schema) {
359
478
  // `auto` = server-generated monotonic int. PowDB requires it be `int` and
360
479
  // rejects it alongside a `default`; non-int generated columns fall back to
361
480
  // a plain typed column (Turbine assigns the value client-side instead).
362
- if (col.isGenerated && powqlColumnType(col) === 'int')
481
+ if (col.isGenerated && powqlType === 'int')
363
482
  mods.push('auto');
364
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
483
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
365
484
  });
366
485
  stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
486
+ // Track which single columns already carry a unique constraint (the
487
+ // single-column PK is inlined `required unique` in the type body above) so
488
+ // a redundant `add unique .col` is never emitted twice.
489
+ const emittedUnique = new Set();
490
+ if (pkIsSingle && meta.primaryKey[0] !== undefined)
491
+ emittedUnique.add(meta.primaryKey[0]);
367
492
  // Secondary unique constraints (beyond the PK) become unique indexes.
368
493
  for (const uniq of meta.uniqueColumns) {
369
494
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
370
495
  stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
496
+ emittedUnique.add(uniq[0]);
497
+ }
498
+ }
499
+ // Declared indexes: PowDB doc-field expression indexes (docPath) and plain
500
+ // single-column indexes. A doc-field index MUST be parenthesized (the engine
501
+ // rejects a bare JSON path); string path segments emit lexer-exact via the
502
+ // shared `encodePowqlString`, integer array indexes emit bare. A json
503
+ // document column reference stays dotted-bare (`.col`), which bypasses
504
+ // keyword lookup on every engine version exactly like a filter path.
505
+ for (const idx of meta.indexes) {
506
+ const kind = idx.unique ? 'unique' : 'index';
507
+ if (idx.docPath) {
508
+ if (caps)
509
+ requireCapability(caps, 'docFieldIndexes', 'JSON doc-field expression indexes');
510
+ const column = idx.columns[0];
511
+ if (column === undefined) {
512
+ throw new ValidationError(`[turbine] Doc-field index "${idx.name}" on ${meta.name} has no target json column.`);
513
+ }
514
+ const segs = idx.docPath.map((s) => (typeof s === 'number' ? `->${s}` : `->${encodePowqlString(s)}`)).join('');
515
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${column}${segs})`);
516
+ }
517
+ else {
518
+ // Plain column index. PowDB has no composite index (`add index` takes a
519
+ // single `.column`), so a multi-column entry is a typed E017.
520
+ if (idx.columns.length !== 1) {
521
+ throw new UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
522
+ `${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
523
+ }
524
+ const column = idx.columns[0];
525
+ // A unique index whose column already carries a unique constraint (the
526
+ // PK, or a column-level unique) would be a redundant duplicate, so skip it.
527
+ if (idx.unique && emittedUnique.has(column))
528
+ continue;
529
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} .${quotePowqlIdent(column)}`);
530
+ if (idx.unique)
531
+ emittedUnique.add(column);
371
532
  }
372
533
  }
373
534
  }
@@ -377,6 +538,10 @@ export function powqlSchemaDDL(schema) {
377
538
  function toPowdbParam(value, col) {
378
539
  if (value instanceof PowdbFloatParam)
379
540
  return value.value; // wire-side: a float column takes the plain number
541
+ // json document: serialize to canonical JSON text and bind as a str param,
542
+ // the engine validates it as JSON and stores the canonical binary form.
543
+ if (value instanceof PowdbJsonParam)
544
+ return JSON.stringify(value.value);
380
545
  if (value === undefined || value === null)
381
546
  return null;
382
547
  if (value instanceof Date)
@@ -399,10 +564,23 @@ function toPowdbParam(value, col) {
399
564
  */
400
565
  export function coerceValue(raw, col) {
401
566
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
567
+ const json = isJsonColumn(col);
402
568
  // NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
403
569
  // literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
404
- if (raw === 'null' && (ts !== 'string' || col.nullable))
570
+ // For a `json` column the bareword `null` (a legacy-wire rendering shared by an
571
+ // absent value AND a top-level JSON-null document, documented residual,
572
+ // resolved on the native transport by the WireValue path) maps to null; a JSON
573
+ // string document "null" renders WITH quotes (`"null"`) and parses distinctly.
574
+ if (raw === 'null' && (json || ts !== 'string' || col.nullable))
405
575
  return null;
576
+ if (json) {
577
+ try {
578
+ return JSON.parse(raw);
579
+ }
580
+ catch {
581
+ return raw; // defensive: canonical JSON text always parses
582
+ }
583
+ }
406
584
  if (ts === 'Date') {
407
585
  const micros = Number(raw);
408
586
  return Number.isFinite(micros) ? new Date(micros / 1000) : null;
@@ -419,19 +597,62 @@ export function coerceValue(raw, col) {
419
597
  return raw; // string / uuid-as-string
420
598
  }
421
599
  /**
422
- * Map one raw PowDB row (snake-cased columns raw wire strings, as produced by
423
- * {@link PowdbPool}) into a typed entity (camelCase fields, coerced values).
424
- * Only the columns present in `raw` are emitted, so partial `select` projections
425
- * round-trip unchanged.
600
+ * Coerce a single cell that arrived over the NATIVE typed wire (decoded from a
601
+ * {@link PowdbWireValue}, so already a JS `bigint`/`number`/`boolean`/`string`/
602
+ * `NativeJson`/`Uint8Array`/`null`, never a bare `"null"` string). Unlike
603
+ * {@link coerceValue} this NEVER collapses the string `"null"` to `null`: an
604
+ * absent value already decoded to `null` (from the `empty` cell), so a genuine
605
+ * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
606
+ * native transport). `datetime`-shaped cells (int micros) become `Date`; a
607
+ * bigint on a `number` column follows the int8 safe-integer policy.
608
+ */
609
+ export function coerceNativeValue(value, col) {
610
+ if (value === undefined || value === null)
611
+ return null;
612
+ if (isDateColumn(col)) {
613
+ if (typeof value === 'bigint')
614
+ return new Date(Number(value) / 1000);
615
+ if (typeof value === 'number')
616
+ return new Date(value / 1000);
617
+ return value;
618
+ }
619
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
620
+ if (typeof value === 'bigint') {
621
+ if (ts === 'bigint')
622
+ return value;
623
+ if (ts === 'number') {
624
+ const n = Number(value);
625
+ return Number.isSafeInteger(n) ? n : value.toString(); // int8 policy: keep big ints as strings
626
+ }
627
+ return value;
628
+ }
629
+ return value; // number / boolean / string / NativeJson document / Uint8Array
630
+ }
631
+ /**
632
+ * Map one raw PowDB row into a typed entity (camelCase fields, coerced values).
633
+ * Only the columns present in `raw` are emitted, so partial `select`
634
+ * projections round-trip unchanged. `native` selects the coercion policy: the
635
+ * default `false` handles the legacy string wire (every cell is a string, via
636
+ * {@link coerceValue}); `true` handles the native typed wire, where non-string
637
+ * cells arrive pre-typed and go through {@link coerceNativeValue} (see F3).
638
+ * Callers on the native transport pass `this.pool.capabilities.nativeRaw`.
426
639
  */
427
- export function rowToEntity(raw, meta) {
640
+ export function rowToEntity(raw, meta, native = false) {
428
641
  const byName = new Map(meta.columns.map((c) => [c.name, c]));
429
642
  const out = {};
430
643
  for (const snake of Object.keys(raw)) {
431
644
  const col = byName.get(snake);
432
645
  const field = meta.reverseColumnMap[snake] ?? snake;
433
646
  const value = raw[snake];
434
- out[field] = col && typeof value === 'string' ? coerceValue(value, col) : value;
647
+ if (!col) {
648
+ out[field] = value;
649
+ }
650
+ else if (native) {
651
+ out[field] = coerceNativeValue(value, col);
652
+ }
653
+ else {
654
+ out[field] = typeof value === 'string' ? coerceValue(value, col) : value;
655
+ }
435
656
  }
436
657
  return out;
437
658
  }
@@ -457,7 +678,7 @@ export function wrapPowdbError(err) {
457
678
  const e = err;
458
679
  const msg = e.message ?? 'unknown PowDB error';
459
680
  // Unique-constraint — message-based on both transports.
460
- if (/unique constraint violation/i.test(msg)) {
681
+ if (/unique (constraint|expression index) violation/i.test(msg)) {
461
682
  const m = /on\s+\S+\.(\w+)/i.exec(msg);
462
683
  return new UniqueConstraintError({ constraint: m?.[1], cause: err });
463
684
  }
@@ -470,37 +691,71 @@ export function wrapPowdbError(err) {
470
691
  // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
471
692
  // no .code — classify by message so both transports surface E004.
472
693
  if (/pool closed|pool acquire timeout/i.test(msg)) {
473
- return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
694
+ return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
474
695
  }
475
696
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
476
697
  // connection held the single global write lock past the server's
477
698
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
478
699
  if (/transaction gate timeout/i.test(msg)) {
479
- return new TimeoutError(0, 'PowDB transaction gate');
700
+ return new TimeoutError(0, 'PowDB transaction gate', { cause: err });
701
+ }
702
+ // Stale / violated WIRE state → ConnectionError (E004), NOT a query defect.
703
+ // A `protocol_error`-class failure means the socket's framing state is gone
704
+ // (the client cannot safely reuse it and the pool must destroy it). The
705
+ // canonical trigger is the "received unexpected frame from server" that a
706
+ // fresh request hits after a multi-minute idle gap; sibling shapes are an
707
+ // unknown message type, a truncated payload, or bad framing. Runs BEFORE the
708
+ // validation regex below, whose `unexpected` token would otherwise misclass
709
+ // "received unexpected frame" as an E003 query defect. `.cause` preserved so
710
+ // callers (and the opt-in stale-read retry) can inspect the driver code.
711
+ if (e.code === 'protocol_error' ||
712
+ /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
713
+ return new ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
480
714
  }
481
- // Type mismatch / parse / execution / storage / unexpected validation
482
- // (E003). On the embedded transport these are the only signal we get
483
- // (code is always 'GenericFailure'); on the networked path they are a
484
- // safety net before the .code switch.
715
+ // Type mismatch / parse / execution / storage / unexpected(token) / row too
716
+ // large → validation (E003). On the embedded transport these are the only
717
+ // signal we get (code is always 'GenericFailure'); on the networked path they
718
+ // are a safety net before the .code switch.
485
719
  if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
486
720
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
487
721
  }
488
722
  switch (e.code) {
489
723
  case 'connect_failed':
490
724
  case 'closed':
491
- return new ConnectionError(`[turbine] PowDB connection failed: ${msg}`);
725
+ return new ConnectionError(`[turbine] PowDB connection failed: ${msg}`, { cause: err });
726
+ case 'auth_failed':
727
+ // Connection-establishment class, non-retryable: the handshake was
728
+ // rejected. Surface E004 with a concrete remediation hint instead of
729
+ // letting it fall through to the raw error.
730
+ return new ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
492
731
  case 'timeout':
493
732
  case 'aborted':
494
- return new TimeoutError(0, 'PowDB query');
733
+ return new TimeoutError(0, 'PowDB query', { cause: err });
495
734
  case 'query_failed':
496
735
  case 'type_coercion_failed':
497
- case 'protocol_error':
498
736
  case 'size_exceeded':
499
737
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
500
738
  default:
501
- return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}`);
739
+ return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}`, { cause: err });
502
740
  }
503
741
  }
742
+ /**
743
+ * True when `err` is the stale-wire-frame {@link ConnectionError} produced by
744
+ * {@link wrapPowdbError} (its `.cause` is a `protocol_error` PowDBError, or the
745
+ * message carries the invalid-state signature). The opt-in read retry
746
+ * (`retryStaleReads`, evaluated in {@link PowqlInterface}'s exec seam) uses this
747
+ * to decide whether a first-statement READ may be replayed once on a fresh
748
+ * connection; writes are NEVER retried (an ambiguous mutation reply is unsafe
749
+ * to replay, matching the client's own native-path policy).
750
+ */
751
+ export function isStaleFramePowdbError(err) {
752
+ if (!(err instanceof ConnectionError))
753
+ return false;
754
+ const cause = err.cause;
755
+ if (cause && typeof cause === 'object' && cause.code === 'protocol_error')
756
+ return true;
757
+ return /PowDB connection is in an invalid state/.test(err.message);
758
+ }
504
759
  function normalizeQueryArgs(arg, values) {
505
760
  if (typeof arg === 'string')
506
761
  return { text: arg, params: values ?? [] };
@@ -679,7 +934,7 @@ class PowdbTxGate {
679
934
  return hold;
680
935
  }
681
936
  }
682
- /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
937
+ /** Adapt a PowDB (legacy string wire) result into the pg-compat `{ rows, rowCount, fields }` shape. */
683
938
  function adaptResult(r) {
684
939
  switch (r.kind) {
685
940
  case 'rows': {
@@ -690,21 +945,86 @@ function adaptResult(r) {
690
945
  });
691
946
  return o;
692
947
  });
693
- return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
948
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: false };
694
949
  }
695
950
  case 'ok':
696
- return { rows: [], rowCount: Number(r.affected), fields: [] };
951
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
697
952
  case 'scalar':
698
- return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
953
+ return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }], native: false };
699
954
  default:
700
- return { rows: [], rowCount: 0, fields: [] };
955
+ return { rows: [], rowCount: 0, fields: [], native: false };
956
+ }
957
+ }
958
+ /** Format 16 raw UUID bytes as a canonical `8-4-4-4-12` hex string. */
959
+ function uuidBytesToHex(bytes) {
960
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
961
+ if (hex.length !== 32)
962
+ return hex; // defensive: non-16B payloads pass through as raw hex
963
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
964
+ }
965
+ /**
966
+ * Decode one native {@link PowdbWireValue} cell into a JS value. `empty` →
967
+ * `null` (an unset value; for a json column this cleanly distinguishes absent
968
+ * from a JSON-null document, which arrives as `{ type: 'json', value: null }`).
969
+ * `int`/`datetime` stay `bigint` so the row layer ({@link coerceNativeValue})
970
+ * applies the int8 policy / Date conversion by column; `uuid` becomes canonical
971
+ * hex; `bytes` stay `Uint8Array`; `json` passes the decoded document through
972
+ * with no re-parse (its `pj1` raw bytes are dropped).
973
+ */
974
+ function decodeWireValue(cell) {
975
+ switch (cell.type) {
976
+ case 'empty':
977
+ return null;
978
+ case 'int':
979
+ case 'datetime':
980
+ return cell.value; // bigint; row layer decides Date vs number vs bigint per column
981
+ case 'float':
982
+ case 'bool':
983
+ case 'str':
984
+ return cell.value;
985
+ case 'uuid':
986
+ return uuidBytesToHex(cell.value);
987
+ case 'bytes':
988
+ return cell.value; // Uint8Array
989
+ case 'json':
990
+ return cell.value; // NativeJson document, already recursive data
991
+ }
992
+ }
993
+ /** Adapt a native (typed-wire) PowDB result into the pg-compat shape, decoding every {@link PowdbWireValue} cell. */
994
+ function adaptNativeResult(r) {
995
+ switch (r.kind) {
996
+ case 'rows': {
997
+ const rows = r.rows.map((row) => {
998
+ const o = {};
999
+ r.columns.forEach((c, i) => {
1000
+ o[c] = decodeWireValue(row[i] ?? { type: 'empty' });
1001
+ });
1002
+ return o;
1003
+ });
1004
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: true };
1005
+ }
1006
+ case 'ok':
1007
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: true };
1008
+ case 'scalar':
1009
+ return {
1010
+ rows: [{ value: decodeWireValue(r.value) }],
1011
+ rowCount: 1,
1012
+ fields: [{ name: 'value', dataTypeID: 0 }],
1013
+ native: true,
1014
+ };
1015
+ default:
1016
+ return { rows: [], rowCount: 0, fields: [], native: true };
701
1017
  }
702
1018
  }
703
1019
  /**
704
1020
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
705
- * `text` is **PowQL**, not SQL {@link PowqlInterface} generates it. Rows come
706
- * back as raw strings here; per-column JS coercion happens in `PowqlInterface`
707
- * (it owns the schema metadata).
1021
+ * `text` is **PowQL**, not SQL ({@link PowqlInterface} generates it). On the
1022
+ * legacy string wire cells come back as strings; when `capabilities.nativeRaw`
1023
+ * is set (server 0.13 + a client exposing `queryNativeRaw`) this pool routes
1024
+ * through the typed native wire instead, so cells arrive pre-typed (a json int
1025
+ * as `bigint`, etc.) and each result is tagged with the wire that served it
1026
+ * ({@link PowdbTaggedResult}). Per-column JS coercion still happens in
1027
+ * `PowqlInterface` (it owns the schema metadata), keyed on that per-result tag.
708
1028
  */
709
1029
  export class PowdbPool {
710
1030
  pool;
@@ -728,10 +1048,29 @@ export class PowdbPool {
728
1048
  * live socket holding the process open until the server's idle timeout.
729
1049
  */
730
1050
  checkedOut = new Set();
1051
+ /** Feature capabilities of the bound server (probed version + native-wire feature-detect). */
1052
+ capabilities;
1053
+ /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1054
+ retryStaleReads;
731
1055
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
732
1056
  this.pool = pool;
733
1057
  this.toParam = toParam;
734
1058
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1059
+ this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1060
+ this.retryStaleReads = options.retryStaleReads ?? false;
1061
+ }
1062
+ /**
1063
+ * Run one statement on `c`, choosing the lossless native typed wire when the
1064
+ * server supports it (`capabilities.nativeRaw`) AND this client exposes
1065
+ * `queryNativeRaw` (a defensive per-call feature-detect, so a heterogeneous
1066
+ * injected pool cannot crash). Otherwise the legacy string wire, unchanged.
1067
+ */
1068
+ async runOnClient(c, powql, params) {
1069
+ const bound = params.map(this.toParam);
1070
+ if (this.capabilities.nativeRaw && typeof c.queryNativeRaw === 'function') {
1071
+ return adaptNativeResult(await c.queryNativeRaw(powql, bound));
1072
+ }
1073
+ return adaptResult(await c.query(powql, bound));
735
1074
  }
736
1075
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
737
1076
  async query(text, values) {
@@ -752,8 +1091,7 @@ export class PowdbPool {
752
1091
  return { rows: [], rowCount: 0, fields: [] };
753
1092
  }
754
1093
  try {
755
- const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
756
- return adaptResult(result);
1094
+ return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
757
1095
  }
758
1096
  catch (err) {
759
1097
  if (ctl === 'begin') {
@@ -824,7 +1162,7 @@ export class PowdbPool {
824
1162
  return { rows: [], rowCount: 0, fields: [] };
825
1163
  }
826
1164
  try {
827
- return adaptResult(await client.query(powql, params.map(this.toParam)));
1165
+ return await this.runOnClient(client, powql, params);
828
1166
  }
829
1167
  catch (err) {
830
1168
  broken = true;
@@ -950,6 +1288,10 @@ export function encodePowqlLiteral(value) {
950
1288
  // Force a float-form literal so an integer-valued float column stays a float.
951
1289
  return Number.isInteger(n) ? `${n}.0` : String(n);
952
1290
  }
1291
+ // json document: emit the canonical JSON text as a PowQL string literal (the
1292
+ // embedded engine validates and stores it as a json document).
1293
+ if (value instanceof PowdbJsonParam)
1294
+ return encodePowqlString(JSON.stringify(value.value));
953
1295
  if (value === undefined || value === null)
954
1296
  return 'null';
955
1297
  if (value instanceof Date)
@@ -1027,9 +1369,19 @@ export class PowdbEmbeddedPool {
1027
1369
  txGate;
1028
1370
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1029
1371
  poolHoldRef = { hold: null };
1372
+ /**
1373
+ * Feature capabilities of the embedded engine (resolved from the addon
1374
+ * package version). `nativeRaw` is always false: the embedded addon exposes
1375
+ * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
1376
+ */
1377
+ capabilities;
1378
+ /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1379
+ retryStaleReads;
1030
1380
  constructor(db, options = {}) {
1031
1381
  this.db = db;
1032
1382
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1383
+ this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1384
+ this.retryStaleReads = options.retryStaleReads ?? false;
1033
1385
  }
1034
1386
  /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1035
1387
  exec(powql, params) {
@@ -1139,6 +1491,8 @@ export class PowdbEmbeddedPool {
1139
1491
  // ---------------------------------------------------------------------------
1140
1492
  // PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
1141
1493
  // ---------------------------------------------------------------------------
1494
+ // `describe`-based introspection (programmatic API; see powdb-introspect.ts).
1495
+ export { introspectPowdbDatabase, } from './powdb-introspect.js';
1142
1496
  export { PowqlInterface } from './powql.js';
1143
1497
  /**
1144
1498
  * Dynamically load `@zvndev/powdb-client`. Kept out of the static import graph so
@@ -1185,8 +1539,20 @@ async function loadPowdbEmbedded() {
1185
1539
  }
1186
1540
  return mod;
1187
1541
  }
1542
+ /**
1543
+ * Resolve the embedded addon's engine version. The addon vendors the engine and
1544
+ * exports no version, but `@zvndev/powdb-embedded/package.json` has no `exports`
1545
+ * map, so a bare `require` of it resolves: the package version IS the engine
1546
+ * version. Delegated to the `.cts` optional-peer helper so the resolution uses a
1547
+ * real CommonJS `require` in BOTH build outputs; `import.meta.url` here would
1548
+ * fail `tsc` under `tsconfig.cjs.json` (module: CommonJS) and crash any CJS
1549
+ * consumer of `turbine-orm/powdb`. Returns `null` when it cannot be resolved.
1550
+ */
1551
+ function resolveEmbeddedVersion() {
1552
+ return importOptionalPeer.peerPackageVersion('@zvndev/powdb-embedded');
1553
+ }
1188
1554
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1189
- async function openEmbeddedPool(target, poolOptions = {}) {
1555
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1190
1556
  const mod = await loadPowdbEmbedded();
1191
1557
  const { embedded: dir, syncMode, memoryLimit } = target;
1192
1558
  let db;
@@ -1212,7 +1578,11 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1212
1578
  }
1213
1579
  db.setSyncMode(syncMode);
1214
1580
  }
1215
- return new PowdbEmbeddedPool(db, poolOptions);
1581
+ // Embedded exposes no native typed-wire surface, so nativeRaw is always false.
1582
+ const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1583
+ hasNativeRaw: false,
1584
+ });
1585
+ return new PowdbEmbeddedPool(db, { ...poolOptions, capabilities });
1216
1586
  }
1217
1587
  /**
1218
1588
  * Bind Turbine to PowDB. `target` is one of:
@@ -1235,30 +1605,38 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1235
1605
  export async function turbinePowDB(target, schema, options = {}) {
1236
1606
  let pool;
1237
1607
  let owns = false;
1238
- const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1608
+ const poolOptions = {
1609
+ transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1610
+ retryStaleReads: options.retryStaleReads,
1611
+ };
1612
+ const max = options.connectionLimit ?? 10;
1239
1613
  if (typeof target === 'string') {
1240
1614
  const mod = options.powdbClientModule ?? (await loadPowdb());
1241
- const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
1242
- await assertNetworkedVersion(clientPool);
1243
- pool = new PowdbPool(clientPool, undefined, poolOptions);
1615
+ const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max });
1616
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1617
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1244
1618
  owns = true;
1245
1619
  }
1246
1620
  else if (target instanceof PowdbPool) {
1247
- // An injected PowdbPool carries its own PowdbPoolOptions.
1621
+ // An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
1248
1622
  pool = target;
1249
1623
  }
1250
1624
  else if (isEmbeddedTarget(target)) {
1251
- pool = await openEmbeddedPool(target, poolOptions);
1625
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1252
1626
  owns = true;
1253
1627
  }
1254
1628
  else if (isPowdbClientPool(target)) {
1255
- pool = new PowdbPool(target, undefined, poolOptions);
1629
+ // Injected client pool: run the SAME probe as the URL / host+port paths so
1630
+ // it gets real capabilities AND the version-floor check (this branch used
1631
+ // to skip the probe entirely (an injected pool silently bypassed both).
1632
+ const capabilities = await assertNetworkedVersion(target, options.assumeEngineVersion);
1633
+ pool = new PowdbPool(target, undefined, { ...poolOptions, capabilities });
1256
1634
  }
1257
1635
  else {
1258
1636
  const mod = options.powdbClientModule ?? (await loadPowdb());
1259
- const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
1260
- await assertNetworkedVersion(clientPool);
1261
- pool = new PowdbPool(clientPool, undefined, poolOptions);
1637
+ const clientPool = new mod.Pool({ ...target, max });
1638
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1639
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1262
1640
  owns = true;
1263
1641
  }
1264
1642
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -1296,14 +1674,20 @@ export async function turbinePowDB(target, schema, options = {}) {
1296
1674
  return client;
1297
1675
  }
1298
1676
  /**
1299
- * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}) and
1300
- * fail fast if the server is older than {@link MIN_POWDB_VERSION}. Best-effort:
1301
- * a driver that does not surface a version is left untouched (we cannot prove it
1302
- * too old). Errors from the probe itself surface as the normal connect failure.
1677
+ * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}),
1678
+ * fail fast if the server is older than {@link MIN_POWDB_VERSION}, and derive
1679
+ * the {@link PowdbCapabilities} for it: the version gates PLUS `nativeRaw`
1680
+ * (server ≥ 0.13 AND the client exposes `queryNativeRaw`, feature-detected
1681
+ * here). `assumeEngineVersion` overrides the version used for capability
1682
+ * derivation (the floor check still runs against the real reported version).
1683
+ * Errors from the probe itself surface as the normal connect failure.
1303
1684
  */
1304
- async function assertNetworkedVersion(clientPool) {
1305
- await clientPool.withClient(async (c) => {
1685
+ async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
1686
+ return clientPool.withClient(async (c) => {
1306
1687
  assertSupportedPowdbVersion(c.serverVersion);
1688
+ const version = assumeEngineVersion ?? c.serverVersion;
1689
+ const hasNativeRaw = typeof c.queryNativeRaw === 'function';
1690
+ return capabilitiesFromVersion(version, { hasNativeRaw });
1307
1691
  });
1308
1692
  }
1309
1693
  function isPowdbClientPool(x) {