turbine-orm 0.33.0 → 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.
package/dist/powdb.js CHANGED
@@ -124,6 +124,22 @@ export class PowdbFloatParam {
124
124
  this.value = value;
125
125
  }
126
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
+ }
127
143
  /** Minimum PowDB server version the networked transport requires. */
128
144
  export const MIN_POWDB_VERSION = '0.7.0';
129
145
  /**
@@ -174,19 +190,115 @@ export function assertSupportedPowdbVersion(version) {
174
190
  throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
175
191
  'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
176
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
+ }
177
286
  /**
178
287
  * Map a Turbine column to the PowQL DDL type used in `defineSchema` →
179
288
  * `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
180
289
  * which cannot hold client-supplied values on the wire (no literal, no cast):
181
290
  * - `Date` → `int` (epoch micros) - `boolean` → `bool`
182
291
  * - integral `number`/`bigint` → `int` - fractional `number` → `float`
292
+ * - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
183
293
  * - everything else (incl. UUID/PK strings) → `str`
184
- * Array / JSON / bytes columns throw they have no PowDB equivalent.
294
+ * Array (non-json) and bytes columns throw, they have no PowDB equivalent.
185
295
  */
186
296
  export function powqlColumnType(col) {
187
297
  if (col.isArray) {
188
298
  throw new ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
189
299
  }
300
+ if (isJsonColumn(col))
301
+ return 'json';
190
302
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
191
303
  if (ts === 'Date')
192
304
  return 'int'; // epoch micros
@@ -201,9 +313,6 @@ export function powqlColumnType(col) {
201
313
  if (ts === 'Buffer' || ts === 'Uint8Array') {
202
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.`);
203
315
  }
204
- if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
205
- 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.`);
206
- }
207
316
  return 'str';
208
317
  }
209
318
  /** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
@@ -343,7 +452,8 @@ export function quotePowqlIdent(name) {
343
452
  }
344
453
  return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
345
454
  }
346
- export function powqlSchemaDDL(schema) {
455
+ export function powqlSchemaDDL(schema, opts = {}) {
456
+ const caps = opts.capabilities;
347
457
  const stmts = [];
348
458
  for (const meta of Object.values(schema.tables)) {
349
459
  const pkSet = new Set(meta.primaryKey);
@@ -354,6 +464,12 @@ export function powqlSchemaDDL(schema) {
354
464
  // cannot enforce the tuple's uniqueness at the engine level.
355
465
  const pkIsSingle = meta.primaryKey.length === 1;
356
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');
357
473
  const mods = [];
358
474
  if (!col.nullable || pkSet.has(col.name))
359
475
  mods.push('required');
@@ -362,15 +478,57 @@ export function powqlSchemaDDL(schema) {
362
478
  // `auto` = server-generated monotonic int. PowDB requires it be `int` and
363
479
  // rejects it alongside a `default`; non-int generated columns fall back to
364
480
  // a plain typed column (Turbine assigns the value client-side instead).
365
- if (col.isGenerated && powqlColumnType(col) === 'int')
481
+ if (col.isGenerated && powqlType === 'int')
366
482
  mods.push('auto');
367
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
483
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
368
484
  });
369
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]);
370
492
  // Secondary unique constraints (beyond the PK) become unique indexes.
371
493
  for (const uniq of meta.uniqueColumns) {
372
494
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
373
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);
374
532
  }
375
533
  }
376
534
  }
@@ -380,6 +538,10 @@ export function powqlSchemaDDL(schema) {
380
538
  function toPowdbParam(value, col) {
381
539
  if (value instanceof PowdbFloatParam)
382
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);
383
545
  if (value === undefined || value === null)
384
546
  return null;
385
547
  if (value instanceof Date)
@@ -402,10 +564,23 @@ function toPowdbParam(value, col) {
402
564
  */
403
565
  export function coerceValue(raw, col) {
404
566
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
567
+ const json = isJsonColumn(col);
405
568
  // NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
406
569
  // literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
407
- 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))
408
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
+ }
409
584
  if (ts === 'Date') {
410
585
  const micros = Number(raw);
411
586
  return Number.isFinite(micros) ? new Date(micros / 1000) : null;
@@ -422,19 +597,62 @@ export function coerceValue(raw, col) {
422
597
  return raw; // string / uuid-as-string
423
598
  }
424
599
  /**
425
- * Map one raw PowDB row (snake-cased columns raw wire strings, as produced by
426
- * {@link PowdbPool}) into a typed entity (camelCase fields, coerced values).
427
- * Only the columns present in `raw` are emitted, so partial `select` projections
428
- * 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`.
429
639
  */
430
- export function rowToEntity(raw, meta) {
640
+ export function rowToEntity(raw, meta, native = false) {
431
641
  const byName = new Map(meta.columns.map((c) => [c.name, c]));
432
642
  const out = {};
433
643
  for (const snake of Object.keys(raw)) {
434
644
  const col = byName.get(snake);
435
645
  const field = meta.reverseColumnMap[snake] ?? snake;
436
646
  const value = raw[snake];
437
- 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
+ }
438
656
  }
439
657
  return out;
440
658
  }
@@ -460,7 +678,7 @@ export function wrapPowdbError(err) {
460
678
  const e = err;
461
679
  const msg = e.message ?? 'unknown PowDB error';
462
680
  // Unique-constraint — message-based on both transports.
463
- if (/unique constraint violation/i.test(msg)) {
681
+ if (/unique (constraint|expression index) violation/i.test(msg)) {
464
682
  const m = /on\s+\S+\.(\w+)/i.exec(msg);
465
683
  return new UniqueConstraintError({ constraint: m?.[1], cause: err });
466
684
  }
@@ -473,37 +691,71 @@ export function wrapPowdbError(err) {
473
691
  // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
474
692
  // no .code — classify by message so both transports surface E004.
475
693
  if (/pool closed|pool acquire timeout/i.test(msg)) {
476
- return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
694
+ return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
477
695
  }
478
696
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
479
697
  // connection held the single global write lock past the server's
480
698
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
481
699
  if (/transaction gate timeout/i.test(msg)) {
482
- 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 });
483
714
  }
484
- // Type mismatch / parse / execution / storage / unexpected validation
485
- // (E003). On the embedded transport these are the only signal we get
486
- // (code is always 'GenericFailure'); on the networked path they are a
487
- // 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.
488
719
  if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
489
720
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
490
721
  }
491
722
  switch (e.code) {
492
723
  case 'connect_failed':
493
724
  case 'closed':
494
- 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 });
495
731
  case 'timeout':
496
732
  case 'aborted':
497
- return new TimeoutError(0, 'PowDB query');
733
+ return new TimeoutError(0, 'PowDB query', { cause: err });
498
734
  case 'query_failed':
499
735
  case 'type_coercion_failed':
500
- case 'protocol_error':
501
736
  case 'size_exceeded':
502
737
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
503
738
  default:
504
- 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 });
505
740
  }
506
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
+ }
507
759
  function normalizeQueryArgs(arg, values) {
508
760
  if (typeof arg === 'string')
509
761
  return { text: arg, params: values ?? [] };
@@ -682,7 +934,7 @@ class PowdbTxGate {
682
934
  return hold;
683
935
  }
684
936
  }
685
- /** 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. */
686
938
  function adaptResult(r) {
687
939
  switch (r.kind) {
688
940
  case 'rows': {
@@ -693,21 +945,86 @@ function adaptResult(r) {
693
945
  });
694
946
  return o;
695
947
  });
696
- 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 };
697
949
  }
698
950
  case 'ok':
699
- return { rows: [], rowCount: Number(r.affected), fields: [] };
951
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
700
952
  case 'scalar':
701
- 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 };
702
954
  default:
703
- 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 };
704
1017
  }
705
1018
  }
706
1019
  /**
707
1020
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
708
- * `text` is **PowQL**, not SQL {@link PowqlInterface} generates it. Rows come
709
- * back as raw strings here; per-column JS coercion happens in `PowqlInterface`
710
- * (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.
711
1028
  */
712
1029
  export class PowdbPool {
713
1030
  pool;
@@ -731,10 +1048,29 @@ export class PowdbPool {
731
1048
  * live socket holding the process open until the server's idle timeout.
732
1049
  */
733
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;
734
1055
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
735
1056
  this.pool = pool;
736
1057
  this.toParam = toParam;
737
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));
738
1074
  }
739
1075
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
740
1076
  async query(text, values) {
@@ -755,8 +1091,7 @@ export class PowdbPool {
755
1091
  return { rows: [], rowCount: 0, fields: [] };
756
1092
  }
757
1093
  try {
758
- const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
759
- return adaptResult(result);
1094
+ return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
760
1095
  }
761
1096
  catch (err) {
762
1097
  if (ctl === 'begin') {
@@ -827,7 +1162,7 @@ export class PowdbPool {
827
1162
  return { rows: [], rowCount: 0, fields: [] };
828
1163
  }
829
1164
  try {
830
- return adaptResult(await client.query(powql, params.map(this.toParam)));
1165
+ return await this.runOnClient(client, powql, params);
831
1166
  }
832
1167
  catch (err) {
833
1168
  broken = true;
@@ -953,6 +1288,10 @@ export function encodePowqlLiteral(value) {
953
1288
  // Force a float-form literal so an integer-valued float column stays a float.
954
1289
  return Number.isInteger(n) ? `${n}.0` : String(n);
955
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));
956
1295
  if (value === undefined || value === null)
957
1296
  return 'null';
958
1297
  if (value instanceof Date)
@@ -1030,9 +1369,19 @@ export class PowdbEmbeddedPool {
1030
1369
  txGate;
1031
1370
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1032
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;
1033
1380
  constructor(db, options = {}) {
1034
1381
  this.db = db;
1035
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;
1036
1385
  }
1037
1386
  /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1038
1387
  exec(powql, params) {
@@ -1142,6 +1491,8 @@ export class PowdbEmbeddedPool {
1142
1491
  // ---------------------------------------------------------------------------
1143
1492
  // PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
1144
1493
  // ---------------------------------------------------------------------------
1494
+ // `describe`-based introspection (programmatic API; see powdb-introspect.ts).
1495
+ export { introspectPowdbDatabase, } from './powdb-introspect.js';
1145
1496
  export { PowqlInterface } from './powql.js';
1146
1497
  /**
1147
1498
  * Dynamically load `@zvndev/powdb-client`. Kept out of the static import graph so
@@ -1188,8 +1539,20 @@ async function loadPowdbEmbedded() {
1188
1539
  }
1189
1540
  return mod;
1190
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
+ }
1191
1554
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1192
- async function openEmbeddedPool(target, poolOptions = {}) {
1555
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1193
1556
  const mod = await loadPowdbEmbedded();
1194
1557
  const { embedded: dir, syncMode, memoryLimit } = target;
1195
1558
  let db;
@@ -1215,7 +1578,11 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1215
1578
  }
1216
1579
  db.setSyncMode(syncMode);
1217
1580
  }
1218
- 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 });
1219
1586
  }
1220
1587
  /**
1221
1588
  * Bind Turbine to PowDB. `target` is one of:
@@ -1238,30 +1605,38 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1238
1605
  export async function turbinePowDB(target, schema, options = {}) {
1239
1606
  let pool;
1240
1607
  let owns = false;
1241
- const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1608
+ const poolOptions = {
1609
+ transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1610
+ retryStaleReads: options.retryStaleReads,
1611
+ };
1612
+ const max = options.connectionLimit ?? 10;
1242
1613
  if (typeof target === 'string') {
1243
1614
  const mod = options.powdbClientModule ?? (await loadPowdb());
1244
- const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
1245
- await assertNetworkedVersion(clientPool);
1246
- 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 });
1247
1618
  owns = true;
1248
1619
  }
1249
1620
  else if (target instanceof PowdbPool) {
1250
- // An injected PowdbPool carries its own PowdbPoolOptions.
1621
+ // An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
1251
1622
  pool = target;
1252
1623
  }
1253
1624
  else if (isEmbeddedTarget(target)) {
1254
- pool = await openEmbeddedPool(target, poolOptions);
1625
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1255
1626
  owns = true;
1256
1627
  }
1257
1628
  else if (isPowdbClientPool(target)) {
1258
- 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 });
1259
1634
  }
1260
1635
  else {
1261
1636
  const mod = options.powdbClientModule ?? (await loadPowdb());
1262
- const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
1263
- await assertNetworkedVersion(clientPool);
1264
- 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 });
1265
1640
  owns = true;
1266
1641
  }
1267
1642
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -1299,14 +1674,20 @@ export async function turbinePowDB(target, schema, options = {}) {
1299
1674
  return client;
1300
1675
  }
1301
1676
  /**
1302
- * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}) and
1303
- * fail fast if the server is older than {@link MIN_POWDB_VERSION}. Best-effort:
1304
- * a driver that does not surface a version is left untouched (we cannot prove it
1305
- * 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.
1306
1684
  */
1307
- async function assertNetworkedVersion(clientPool) {
1308
- await clientPool.withClient(async (c) => {
1685
+ async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
1686
+ return clientPool.withClient(async (c) => {
1309
1687
  assertSupportedPowdbVersion(c.serverVersion);
1688
+ const version = assumeEngineVersion ?? c.serverVersion;
1689
+ const hasNativeRaw = typeof c.queryNativeRaw === 'function';
1690
+ return capabilitiesFromVersion(version, { hasNativeRaw });
1310
1691
  });
1311
1692
  }
1312
1693
  function isPowdbClientPool(x) {