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/cjs/powdb.js CHANGED
@@ -90,15 +90,20 @@ 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.PowdbEmbeddedPool = exports.PowdbPool = exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = exports.POWQL_KEYWORDS = exports.MIN_POWDB_VERSION = exports.PowdbFloatParam = exports.powdbDialect = void 0;
93
+ exports.PowqlInterface = exports.introspectPowdbDatabase = exports.PowdbEmbeddedPool = 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;
94
94
  exports.parsePowdbUrl = parsePowdbUrl;
95
95
  exports.assertSupportedPowdbVersion = assertSupportedPowdbVersion;
96
+ exports.capabilitiesFromVersion = capabilitiesFromVersion;
97
+ exports.requireCapability = requireCapability;
98
+ exports.isJsonColumn = isJsonColumn;
96
99
  exports.powqlColumnType = powqlColumnType;
97
100
  exports.quotePowqlIdent = quotePowqlIdent;
98
101
  exports.powqlSchemaDDL = powqlSchemaDDL;
99
102
  exports.coerceValue = coerceValue;
103
+ exports.coerceNativeValue = coerceNativeValue;
100
104
  exports.rowToEntity = rowToEntity;
101
105
  exports.wrapPowdbError = wrapPowdbError;
106
+ exports.isStaleFramePowdbError = isStaleFramePowdbError;
102
107
  exports.encodePowqlLiteral = encodePowqlLiteral;
103
108
  exports.materializePowql = materializePowql;
104
109
  exports.turbinePowDB = turbinePowDB;
@@ -175,6 +180,23 @@ class PowdbFloatParam {
175
180
  }
176
181
  }
177
182
  exports.PowdbFloatParam = PowdbFloatParam;
183
+ /**
184
+ * Marker wrapper for a JS object/array bound to a `json` document column. Both
185
+ * transports serialize `value` with `JSON.stringify` and send the text as a
186
+ * `str` param / string literal, exactly how the PowDB docs insert a json
187
+ * document (the engine validates it as JSON text and stores the canonical
188
+ * binary form). Constructed in {@link PowqlInterface.param} when the target
189
+ * column is `json` and the value is a non-null object/array; a JS string
190
+ * written to a json column passes through RAW (same contract as pg jsonb,
191
+ * pass `'"x"'` to store the JSON string `"x"`), and `null` stays `null`.
192
+ */
193
+ class PowdbJsonParam {
194
+ value;
195
+ constructor(value) {
196
+ this.value = value;
197
+ }
198
+ }
199
+ exports.PowdbJsonParam = PowdbJsonParam;
178
200
  /** Minimum PowDB server version the networked transport requires. */
179
201
  exports.MIN_POWDB_VERSION = '0.7.0';
180
202
  /**
@@ -225,19 +247,115 @@ function assertSupportedPowdbVersion(version) {
225
247
  throw new errors_js_1.ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${exports.MIN_POWDB_VERSION}; the server reports "${version}". ` +
226
248
  'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
227
249
  }
250
+ /** Minimum engine version each gated feature needs, for the E017 hint text. */
251
+ const POWDB_FEATURE_MIN_VERSION = {
252
+ introspection: '0.10',
253
+ jsonDocs: '0.12',
254
+ docFieldIndexes: '0.13',
255
+ };
256
+ /**
257
+ * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
258
+ * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
259
+ * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
260
+ * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
261
+ * actual wire path and must only be enabled after a real server-version probe,
262
+ * never inferred from a bare construction.
263
+ */
264
+ exports.ALL_POWDB_CAPABILITIES = {
265
+ engineVersion: null,
266
+ jsonDocs: true,
267
+ docFieldIndexes: true,
268
+ introspection: true,
269
+ nativeRaw: false,
270
+ };
271
+ /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
272
+ function parsePowdbSemver(version) {
273
+ const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
274
+ if (!m)
275
+ return null;
276
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
277
+ }
278
+ /** Is `sem` at least `major.minor`? */
279
+ function atLeastVersion(sem, major, minor) {
280
+ return sem.major > major || (sem.major === major && sem.minor >= minor);
281
+ }
282
+ /**
283
+ * Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
284
+ * unknown version turns every gate OFF (the E017 hint then tells the caller to
285
+ * upgrade or pass `assumeEngineVersion`). `nativeRaw` requires BOTH the client
286
+ * to expose `queryNativeRaw` (passed in) AND server ≥ 0.13.
287
+ */
288
+ function capabilitiesFromVersion(version, opts = {}) {
289
+ const sem = parsePowdbSemver(version);
290
+ if (!sem) {
291
+ return {
292
+ engineVersion: version ?? null,
293
+ jsonDocs: false,
294
+ docFieldIndexes: false,
295
+ introspection: false,
296
+ nativeRaw: false,
297
+ };
298
+ }
299
+ return {
300
+ engineVersion: `${sem.major}.${sem.minor}.${sem.patch}`,
301
+ introspection: atLeastVersion(sem, 0, 10),
302
+ jsonDocs: atLeastVersion(sem, 0, 12),
303
+ docFieldIndexes: atLeastVersion(sem, 0, 13),
304
+ nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
305
+ };
306
+ }
307
+ /**
308
+ * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
309
+ * PowQL feature is used on an engine that does not support it. Keeps old engines
310
+ * getting clean typed errors instead of raw PowQL parse failures.
311
+ */
312
+ function requireCapability(caps, key, feature) {
313
+ if (caps[key])
314
+ return;
315
+ const min = POWDB_FEATURE_MIN_VERSION[key];
316
+ const reported = caps.engineVersion
317
+ ? `this connection reports ${caps.engineVersion}`
318
+ : 'this connection could not report a version';
319
+ throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
320
+ '(or pass `assumeEngineVersion` if the version cannot be detected).');
321
+ }
322
+ /**
323
+ * Does this column map to PowDB's native `json` document type? A Postgres
324
+ * `json`/`jsonb` type (via `dialectType`/`pgType`) is authoritative; otherwise
325
+ * the tsType heuristic (`Record<…>`, `object`, `unknown`, an object/array
326
+ * literal) that the four scalar branches do not claim. Array columns never map
327
+ * to json, a PowDB array only exists INSIDE a json document, so a Postgres
328
+ * array column has no PowDB shape and still throws in {@link powqlColumnType}.
329
+ */
330
+ function isJsonColumn(col) {
331
+ if (col.isArray)
332
+ return false;
333
+ const dbType = (col.dialectType ?? col.pgType ?? '').toLowerCase();
334
+ if (dbType === 'json' || dbType === 'jsonb')
335
+ return true;
336
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
337
+ if (ts === 'Date' || ts === 'boolean' || ts === 'number' || ts === 'bigint' || ts === 'string')
338
+ return false;
339
+ if (ts === 'Buffer' || ts === 'Uint8Array')
340
+ return false;
341
+ return /Record<|object|unknown|\[\]|\{/.test(ts);
342
+ }
228
343
  /**
229
344
  * Map a Turbine column to the PowQL DDL type used in `defineSchema` →
230
345
  * `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
231
346
  * which cannot hold client-supplied values on the wire (no literal, no cast):
232
347
  * - `Date` → `int` (epoch micros) - `boolean` → `bool`
233
348
  * - integral `number`/`bigint` → `int` - fractional `number` → `float`
349
+ * - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
234
350
  * - everything else (incl. UUID/PK strings) → `str`
235
- * Array / JSON / bytes columns throw they have no PowDB equivalent.
351
+ * Array (non-json) and bytes columns throw, they have no PowDB equivalent.
236
352
  */
237
353
  function powqlColumnType(col) {
238
354
  if (col.isArray) {
239
355
  throw new errors_js_1.ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
240
356
  }
357
+ if (isJsonColumn(col))
358
+ return 'json';
241
359
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
242
360
  if (ts === 'Date')
243
361
  return 'int'; // epoch micros
@@ -252,9 +370,6 @@ function powqlColumnType(col) {
252
370
  if (ts === 'Buffer' || ts === 'Uint8Array') {
253
371
  throw new errors_js_1.ValidationError(`[turbine] Column "${col.name}" is binary — PowDB cannot store client-supplied bytes on the wire. Use a string (e.g. base64) instead.`);
254
372
  }
255
- if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
256
- throw new errors_js_1.ValidationError(`[turbine] Column "${col.name}" (${col.tsType}) maps to JSON/object, which PowDB has no type for. Flatten it or store a JSON string.`);
257
- }
258
373
  return 'str';
259
374
  }
260
375
  /** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
@@ -394,7 +509,8 @@ function quotePowqlIdent(name) {
394
509
  }
395
510
  return exports.POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
396
511
  }
397
- function powqlSchemaDDL(schema) {
512
+ function powqlSchemaDDL(schema, opts = {}) {
513
+ const caps = opts.capabilities;
398
514
  const stmts = [];
399
515
  for (const meta of Object.values(schema.tables)) {
400
516
  const pkSet = new Set(meta.primaryKey);
@@ -405,6 +521,12 @@ function powqlSchemaDDL(schema) {
405
521
  // cannot enforce the tuple's uniqueness at the engine level.
406
522
  const pkIsSingle = meta.primaryKey.length === 1;
407
523
  const fields = meta.columns.map((col) => {
524
+ const powqlType = powqlColumnType(col);
525
+ // Gate `json` columns behind the engine's jsonDocs capability when a
526
+ // caller supplied one, an old engine has no `json` type and would reject
527
+ // the DDL. Pure-function callers (no opts) emit unconditionally.
528
+ if (powqlType === 'json' && caps)
529
+ requireCapability(caps, 'jsonDocs', 'JSON document columns');
408
530
  const mods = [];
409
531
  if (!col.nullable || pkSet.has(col.name))
410
532
  mods.push('required');
@@ -413,15 +535,57 @@ function powqlSchemaDDL(schema) {
413
535
  // `auto` = server-generated monotonic int. PowDB requires it be `int` and
414
536
  // rejects it alongside a `default`; non-int generated columns fall back to
415
537
  // a plain typed column (Turbine assigns the value client-side instead).
416
- if (col.isGenerated && powqlColumnType(col) === 'int')
538
+ if (col.isGenerated && powqlType === 'int')
417
539
  mods.push('auto');
418
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
540
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
419
541
  });
420
542
  stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
543
+ // Track which single columns already carry a unique constraint (the
544
+ // single-column PK is inlined `required unique` in the type body above) so
545
+ // a redundant `add unique .col` is never emitted twice.
546
+ const emittedUnique = new Set();
547
+ if (pkIsSingle && meta.primaryKey[0] !== undefined)
548
+ emittedUnique.add(meta.primaryKey[0]);
421
549
  // Secondary unique constraints (beyond the PK) become unique indexes.
422
550
  for (const uniq of meta.uniqueColumns) {
423
551
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
424
552
  stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
553
+ emittedUnique.add(uniq[0]);
554
+ }
555
+ }
556
+ // Declared indexes: PowDB doc-field expression indexes (docPath) and plain
557
+ // single-column indexes. A doc-field index MUST be parenthesized (the engine
558
+ // rejects a bare JSON path); string path segments emit lexer-exact via the
559
+ // shared `encodePowqlString`, integer array indexes emit bare. A json
560
+ // document column reference stays dotted-bare (`.col`), which bypasses
561
+ // keyword lookup on every engine version exactly like a filter path.
562
+ for (const idx of meta.indexes) {
563
+ const kind = idx.unique ? 'unique' : 'index';
564
+ if (idx.docPath) {
565
+ if (caps)
566
+ requireCapability(caps, 'docFieldIndexes', 'JSON doc-field expression indexes');
567
+ const column = idx.columns[0];
568
+ if (column === undefined) {
569
+ throw new errors_js_1.ValidationError(`[turbine] Doc-field index "${idx.name}" on ${meta.name} has no target json column.`);
570
+ }
571
+ const segs = idx.docPath.map((s) => (typeof s === 'number' ? `->${s}` : `->${encodePowqlString(s)}`)).join('');
572
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${column}${segs})`);
573
+ }
574
+ else {
575
+ // Plain column index. PowDB has no composite index (`add index` takes a
576
+ // single `.column`), so a multi-column entry is a typed E017.
577
+ if (idx.columns.length !== 1) {
578
+ throw new errors_js_1.UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
579
+ `${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
580
+ }
581
+ const column = idx.columns[0];
582
+ // A unique index whose column already carries a unique constraint (the
583
+ // PK, or a column-level unique) would be a redundant duplicate, so skip it.
584
+ if (idx.unique && emittedUnique.has(column))
585
+ continue;
586
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} .${quotePowqlIdent(column)}`);
587
+ if (idx.unique)
588
+ emittedUnique.add(column);
425
589
  }
426
590
  }
427
591
  }
@@ -431,6 +595,10 @@ function powqlSchemaDDL(schema) {
431
595
  function toPowdbParam(value, col) {
432
596
  if (value instanceof PowdbFloatParam)
433
597
  return value.value; // wire-side: a float column takes the plain number
598
+ // json document: serialize to canonical JSON text and bind as a str param,
599
+ // the engine validates it as JSON and stores the canonical binary form.
600
+ if (value instanceof PowdbJsonParam)
601
+ return JSON.stringify(value.value);
434
602
  if (value === undefined || value === null)
435
603
  return null;
436
604
  if (value instanceof Date)
@@ -453,10 +621,23 @@ function toPowdbParam(value, col) {
453
621
  */
454
622
  function coerceValue(raw, col) {
455
623
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
624
+ const json = isJsonColumn(col);
456
625
  // NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
457
626
  // literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
458
- if (raw === 'null' && (ts !== 'string' || col.nullable))
627
+ // For a `json` column the bareword `null` (a legacy-wire rendering shared by an
628
+ // absent value AND a top-level JSON-null document, documented residual,
629
+ // resolved on the native transport by the WireValue path) maps to null; a JSON
630
+ // string document "null" renders WITH quotes (`"null"`) and parses distinctly.
631
+ if (raw === 'null' && (json || ts !== 'string' || col.nullable))
459
632
  return null;
633
+ if (json) {
634
+ try {
635
+ return JSON.parse(raw);
636
+ }
637
+ catch {
638
+ return raw; // defensive: canonical JSON text always parses
639
+ }
640
+ }
460
641
  if (ts === 'Date') {
461
642
  const micros = Number(raw);
462
643
  return Number.isFinite(micros) ? new Date(micros / 1000) : null;
@@ -473,19 +654,62 @@ function coerceValue(raw, col) {
473
654
  return raw; // string / uuid-as-string
474
655
  }
475
656
  /**
476
- * Map one raw PowDB row (snake-cased columns raw wire strings, as produced by
477
- * {@link PowdbPool}) into a typed entity (camelCase fields, coerced values).
478
- * Only the columns present in `raw` are emitted, so partial `select` projections
479
- * round-trip unchanged.
657
+ * Coerce a single cell that arrived over the NATIVE typed wire (decoded from a
658
+ * {@link PowdbWireValue}, so already a JS `bigint`/`number`/`boolean`/`string`/
659
+ * `NativeJson`/`Uint8Array`/`null`, never a bare `"null"` string). Unlike
660
+ * {@link coerceValue} this NEVER collapses the string `"null"` to `null`: an
661
+ * absent value already decoded to `null` (from the `empty` cell), so a genuine
662
+ * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
663
+ * native transport). `datetime`-shaped cells (int micros) become `Date`; a
664
+ * bigint on a `number` column follows the int8 safe-integer policy.
665
+ */
666
+ function coerceNativeValue(value, col) {
667
+ if (value === undefined || value === null)
668
+ return null;
669
+ if (isDateColumn(col)) {
670
+ if (typeof value === 'bigint')
671
+ return new Date(Number(value) / 1000);
672
+ if (typeof value === 'number')
673
+ return new Date(value / 1000);
674
+ return value;
675
+ }
676
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
677
+ if (typeof value === 'bigint') {
678
+ if (ts === 'bigint')
679
+ return value;
680
+ if (ts === 'number') {
681
+ const n = Number(value);
682
+ return Number.isSafeInteger(n) ? n : value.toString(); // int8 policy: keep big ints as strings
683
+ }
684
+ return value;
685
+ }
686
+ return value; // number / boolean / string / NativeJson document / Uint8Array
687
+ }
688
+ /**
689
+ * Map one raw PowDB row into a typed entity (camelCase fields, coerced values).
690
+ * Only the columns present in `raw` are emitted, so partial `select`
691
+ * projections round-trip unchanged. `native` selects the coercion policy: the
692
+ * default `false` handles the legacy string wire (every cell is a string, via
693
+ * {@link coerceValue}); `true` handles the native typed wire, where non-string
694
+ * cells arrive pre-typed and go through {@link coerceNativeValue} (see F3).
695
+ * Callers on the native transport pass `this.pool.capabilities.nativeRaw`.
480
696
  */
481
- function rowToEntity(raw, meta) {
697
+ function rowToEntity(raw, meta, native = false) {
482
698
  const byName = new Map(meta.columns.map((c) => [c.name, c]));
483
699
  const out = {};
484
700
  for (const snake of Object.keys(raw)) {
485
701
  const col = byName.get(snake);
486
702
  const field = meta.reverseColumnMap[snake] ?? snake;
487
703
  const value = raw[snake];
488
- out[field] = col && typeof value === 'string' ? coerceValue(value, col) : value;
704
+ if (!col) {
705
+ out[field] = value;
706
+ }
707
+ else if (native) {
708
+ out[field] = coerceNativeValue(value, col);
709
+ }
710
+ else {
711
+ out[field] = typeof value === 'string' ? coerceValue(value, col) : value;
712
+ }
489
713
  }
490
714
  return out;
491
715
  }
@@ -511,7 +735,7 @@ function wrapPowdbError(err) {
511
735
  const e = err;
512
736
  const msg = e.message ?? 'unknown PowDB error';
513
737
  // Unique-constraint — message-based on both transports.
514
- if (/unique constraint violation/i.test(msg)) {
738
+ if (/unique (constraint|expression index) violation/i.test(msg)) {
515
739
  const m = /on\s+\S+\.(\w+)/i.exec(msg);
516
740
  return new errors_js_1.UniqueConstraintError({ constraint: m?.[1], cause: err });
517
741
  }
@@ -524,37 +748,71 @@ function wrapPowdbError(err) {
524
748
  // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
525
749
  // no .code — classify by message so both transports surface E004.
526
750
  if (/pool closed|pool acquire timeout/i.test(msg)) {
527
- return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
751
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
528
752
  }
529
753
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
530
754
  // connection held the single global write lock past the server's
531
755
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
532
756
  if (/transaction gate timeout/i.test(msg)) {
533
- return new errors_js_1.TimeoutError(0, 'PowDB transaction gate');
757
+ return new errors_js_1.TimeoutError(0, 'PowDB transaction gate', { cause: err });
758
+ }
759
+ // Stale / violated WIRE state → ConnectionError (E004), NOT a query defect.
760
+ // A `protocol_error`-class failure means the socket's framing state is gone
761
+ // (the client cannot safely reuse it and the pool must destroy it). The
762
+ // canonical trigger is the "received unexpected frame from server" that a
763
+ // fresh request hits after a multi-minute idle gap; sibling shapes are an
764
+ // unknown message type, a truncated payload, or bad framing. Runs BEFORE the
765
+ // validation regex below, whose `unexpected` token would otherwise misclass
766
+ // "received unexpected frame" as an E003 query defect. `.cause` preserved so
767
+ // callers (and the opt-in stale-read retry) can inspect the driver code.
768
+ if (e.code === 'protocol_error' ||
769
+ /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
770
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
534
771
  }
535
- // Type mismatch / parse / execution / storage / unexpected validation
536
- // (E003). On the embedded transport these are the only signal we get
537
- // (code is always 'GenericFailure'); on the networked path they are a
538
- // safety net before the .code switch.
772
+ // Type mismatch / parse / execution / storage / unexpected(token) / row too
773
+ // large → validation (E003). On the embedded transport these are the only
774
+ // signal we get (code is always 'GenericFailure'); on the networked path they
775
+ // are a safety net before the .code switch.
539
776
  if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
540
777
  return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
541
778
  }
542
779
  switch (e.code) {
543
780
  case 'connect_failed':
544
781
  case 'closed':
545
- return new errors_js_1.ConnectionError(`[turbine] PowDB connection failed: ${msg}`);
782
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection failed: ${msg}`, { cause: err });
783
+ case 'auth_failed':
784
+ // Connection-establishment class, non-retryable: the handshake was
785
+ // rejected. Surface E004 with a concrete remediation hint instead of
786
+ // letting it fall through to the raw error.
787
+ return new errors_js_1.ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
546
788
  case 'timeout':
547
789
  case 'aborted':
548
- return new errors_js_1.TimeoutError(0, 'PowDB query');
790
+ return new errors_js_1.TimeoutError(0, 'PowDB query', { cause: err });
549
791
  case 'query_failed':
550
792
  case 'type_coercion_failed':
551
- case 'protocol_error':
552
793
  case 'size_exceeded':
553
794
  return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
554
795
  default:
555
- return err instanceof Error ? err : new errors_js_1.ConnectionError(`[turbine] PowDB error: ${msg}`);
796
+ return err instanceof Error ? err : new errors_js_1.ConnectionError(`[turbine] PowDB error: ${msg}`, { cause: err });
556
797
  }
557
798
  }
799
+ /**
800
+ * True when `err` is the stale-wire-frame {@link ConnectionError} produced by
801
+ * {@link wrapPowdbError} (its `.cause` is a `protocol_error` PowDBError, or the
802
+ * message carries the invalid-state signature). The opt-in read retry
803
+ * (`retryStaleReads`, evaluated in {@link PowqlInterface}'s exec seam) uses this
804
+ * to decide whether a first-statement READ may be replayed once on a fresh
805
+ * connection; writes are NEVER retried (an ambiguous mutation reply is unsafe
806
+ * to replay, matching the client's own native-path policy).
807
+ */
808
+ function isStaleFramePowdbError(err) {
809
+ if (!(err instanceof errors_js_1.ConnectionError))
810
+ return false;
811
+ const cause = err.cause;
812
+ if (cause && typeof cause === 'object' && cause.code === 'protocol_error')
813
+ return true;
814
+ return /PowDB connection is in an invalid state/.test(err.message);
815
+ }
558
816
  function normalizeQueryArgs(arg, values) {
559
817
  if (typeof arg === 'string')
560
818
  return { text: arg, params: values ?? [] };
@@ -733,7 +991,7 @@ class PowdbTxGate {
733
991
  return hold;
734
992
  }
735
993
  }
736
- /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
994
+ /** Adapt a PowDB (legacy string wire) result into the pg-compat `{ rows, rowCount, fields }` shape. */
737
995
  function adaptResult(r) {
738
996
  switch (r.kind) {
739
997
  case 'rows': {
@@ -744,21 +1002,86 @@ function adaptResult(r) {
744
1002
  });
745
1003
  return o;
746
1004
  });
747
- return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
1005
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: false };
748
1006
  }
749
1007
  case 'ok':
750
- return { rows: [], rowCount: Number(r.affected), fields: [] };
1008
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
751
1009
  case 'scalar':
752
- return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
1010
+ return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }], native: false };
753
1011
  default:
754
- return { rows: [], rowCount: 0, fields: [] };
1012
+ return { rows: [], rowCount: 0, fields: [], native: false };
1013
+ }
1014
+ }
1015
+ /** Format 16 raw UUID bytes as a canonical `8-4-4-4-12` hex string. */
1016
+ function uuidBytesToHex(bytes) {
1017
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1018
+ if (hex.length !== 32)
1019
+ return hex; // defensive: non-16B payloads pass through as raw hex
1020
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1021
+ }
1022
+ /**
1023
+ * Decode one native {@link PowdbWireValue} cell into a JS value. `empty` →
1024
+ * `null` (an unset value; for a json column this cleanly distinguishes absent
1025
+ * from a JSON-null document, which arrives as `{ type: 'json', value: null }`).
1026
+ * `int`/`datetime` stay `bigint` so the row layer ({@link coerceNativeValue})
1027
+ * applies the int8 policy / Date conversion by column; `uuid` becomes canonical
1028
+ * hex; `bytes` stay `Uint8Array`; `json` passes the decoded document through
1029
+ * with no re-parse (its `pj1` raw bytes are dropped).
1030
+ */
1031
+ function decodeWireValue(cell) {
1032
+ switch (cell.type) {
1033
+ case 'empty':
1034
+ return null;
1035
+ case 'int':
1036
+ case 'datetime':
1037
+ return cell.value; // bigint; row layer decides Date vs number vs bigint per column
1038
+ case 'float':
1039
+ case 'bool':
1040
+ case 'str':
1041
+ return cell.value;
1042
+ case 'uuid':
1043
+ return uuidBytesToHex(cell.value);
1044
+ case 'bytes':
1045
+ return cell.value; // Uint8Array
1046
+ case 'json':
1047
+ return cell.value; // NativeJson document, already recursive data
1048
+ }
1049
+ }
1050
+ /** Adapt a native (typed-wire) PowDB result into the pg-compat shape, decoding every {@link PowdbWireValue} cell. */
1051
+ function adaptNativeResult(r) {
1052
+ switch (r.kind) {
1053
+ case 'rows': {
1054
+ const rows = r.rows.map((row) => {
1055
+ const o = {};
1056
+ r.columns.forEach((c, i) => {
1057
+ o[c] = decodeWireValue(row[i] ?? { type: 'empty' });
1058
+ });
1059
+ return o;
1060
+ });
1061
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: true };
1062
+ }
1063
+ case 'ok':
1064
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: true };
1065
+ case 'scalar':
1066
+ return {
1067
+ rows: [{ value: decodeWireValue(r.value) }],
1068
+ rowCount: 1,
1069
+ fields: [{ name: 'value', dataTypeID: 0 }],
1070
+ native: true,
1071
+ };
1072
+ default:
1073
+ return { rows: [], rowCount: 0, fields: [], native: true };
755
1074
  }
756
1075
  }
757
1076
  /**
758
1077
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
759
- * `text` is **PowQL**, not SQL {@link PowqlInterface} generates it. Rows come
760
- * back as raw strings here; per-column JS coercion happens in `PowqlInterface`
761
- * (it owns the schema metadata).
1078
+ * `text` is **PowQL**, not SQL ({@link PowqlInterface} generates it). On the
1079
+ * legacy string wire cells come back as strings; when `capabilities.nativeRaw`
1080
+ * is set (server 0.13 + a client exposing `queryNativeRaw`) this pool routes
1081
+ * through the typed native wire instead, so cells arrive pre-typed (a json int
1082
+ * as `bigint`, etc.) and each result is tagged with the wire that served it
1083
+ * ({@link PowdbTaggedResult}). Per-column JS coercion still happens in
1084
+ * `PowqlInterface` (it owns the schema metadata), keyed on that per-result tag.
762
1085
  */
763
1086
  class PowdbPool {
764
1087
  pool;
@@ -782,10 +1105,29 @@ class PowdbPool {
782
1105
  * live socket holding the process open until the server's idle timeout.
783
1106
  */
784
1107
  checkedOut = new Set();
1108
+ /** Feature capabilities of the bound server (probed version + native-wire feature-detect). */
1109
+ capabilities;
1110
+ /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1111
+ retryStaleReads;
785
1112
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
786
1113
  this.pool = pool;
787
1114
  this.toParam = toParam;
788
1115
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1116
+ this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1117
+ this.retryStaleReads = options.retryStaleReads ?? false;
1118
+ }
1119
+ /**
1120
+ * Run one statement on `c`, choosing the lossless native typed wire when the
1121
+ * server supports it (`capabilities.nativeRaw`) AND this client exposes
1122
+ * `queryNativeRaw` (a defensive per-call feature-detect, so a heterogeneous
1123
+ * injected pool cannot crash). Otherwise the legacy string wire, unchanged.
1124
+ */
1125
+ async runOnClient(c, powql, params) {
1126
+ const bound = params.map(this.toParam);
1127
+ if (this.capabilities.nativeRaw && typeof c.queryNativeRaw === 'function') {
1128
+ return adaptNativeResult(await c.queryNativeRaw(powql, bound));
1129
+ }
1130
+ return adaptResult(await c.query(powql, bound));
789
1131
  }
790
1132
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
791
1133
  async query(text, values) {
@@ -806,8 +1148,7 @@ class PowdbPool {
806
1148
  return { rows: [], rowCount: 0, fields: [] };
807
1149
  }
808
1150
  try {
809
- const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
810
- return adaptResult(result);
1151
+ return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
811
1152
  }
812
1153
  catch (err) {
813
1154
  if (ctl === 'begin') {
@@ -878,7 +1219,7 @@ class PowdbPool {
878
1219
  return { rows: [], rowCount: 0, fields: [] };
879
1220
  }
880
1221
  try {
881
- return adaptResult(await client.query(powql, params.map(this.toParam)));
1222
+ return await this.runOnClient(client, powql, params);
882
1223
  }
883
1224
  catch (err) {
884
1225
  broken = true;
@@ -1005,6 +1346,10 @@ function encodePowqlLiteral(value) {
1005
1346
  // Force a float-form literal so an integer-valued float column stays a float.
1006
1347
  return Number.isInteger(n) ? `${n}.0` : String(n);
1007
1348
  }
1349
+ // json document: emit the canonical JSON text as a PowQL string literal (the
1350
+ // embedded engine validates and stores it as a json document).
1351
+ if (value instanceof PowdbJsonParam)
1352
+ return encodePowqlString(JSON.stringify(value.value));
1008
1353
  if (value === undefined || value === null)
1009
1354
  return 'null';
1010
1355
  if (value instanceof Date)
@@ -1082,9 +1427,19 @@ class PowdbEmbeddedPool {
1082
1427
  txGate;
1083
1428
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1084
1429
  poolHoldRef = { hold: null };
1430
+ /**
1431
+ * Feature capabilities of the embedded engine (resolved from the addon
1432
+ * package version). `nativeRaw` is always false: the embedded addon exposes
1433
+ * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
1434
+ */
1435
+ capabilities;
1436
+ /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1437
+ retryStaleReads;
1085
1438
  constructor(db, options = {}) {
1086
1439
  this.db = db;
1087
1440
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1441
+ this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1442
+ this.retryStaleReads = options.retryStaleReads ?? false;
1088
1443
  }
1089
1444
  /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1090
1445
  exec(powql, params) {
@@ -1195,6 +1550,9 @@ exports.PowdbEmbeddedPool = PowdbEmbeddedPool;
1195
1550
  // ---------------------------------------------------------------------------
1196
1551
  // PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
1197
1552
  // ---------------------------------------------------------------------------
1553
+ // `describe`-based introspection (programmatic API; see powdb-introspect.ts).
1554
+ var powdb_introspect_js_1 = require("./powdb-introspect.js");
1555
+ Object.defineProperty(exports, "introspectPowdbDatabase", { enumerable: true, get: function () { return powdb_introspect_js_1.introspectPowdbDatabase; } });
1198
1556
  var powql_js_1 = require("./powql.js");
1199
1557
  Object.defineProperty(exports, "PowqlInterface", { enumerable: true, get: function () { return powql_js_1.PowqlInterface; } });
1200
1558
  /**
@@ -1242,8 +1600,20 @@ async function loadPowdbEmbedded() {
1242
1600
  }
1243
1601
  return mod;
1244
1602
  }
1603
+ /**
1604
+ * Resolve the embedded addon's engine version. The addon vendors the engine and
1605
+ * exports no version, but `@zvndev/powdb-embedded/package.json` has no `exports`
1606
+ * map, so a bare `require` of it resolves: the package version IS the engine
1607
+ * version. Delegated to the `.cts` optional-peer helper so the resolution uses a
1608
+ * real CommonJS `require` in BOTH build outputs; `import.meta.url` here would
1609
+ * fail `tsc` under `tsconfig.cjs.json` (module: CommonJS) and crash any CJS
1610
+ * consumer of `turbine-orm/powdb`. Returns `null` when it cannot be resolved.
1611
+ */
1612
+ function resolveEmbeddedVersion() {
1613
+ return optional_peer_import_cjs_1.default.peerPackageVersion('@zvndev/powdb-embedded');
1614
+ }
1245
1615
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1246
- async function openEmbeddedPool(target, poolOptions = {}) {
1616
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1247
1617
  const mod = await loadPowdbEmbedded();
1248
1618
  const { embedded: dir, syncMode, memoryLimit } = target;
1249
1619
  let db;
@@ -1269,7 +1639,11 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1269
1639
  }
1270
1640
  db.setSyncMode(syncMode);
1271
1641
  }
1272
- return new PowdbEmbeddedPool(db, poolOptions);
1642
+ // Embedded exposes no native typed-wire surface, so nativeRaw is always false.
1643
+ const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1644
+ hasNativeRaw: false,
1645
+ });
1646
+ return new PowdbEmbeddedPool(db, { ...poolOptions, capabilities });
1273
1647
  }
1274
1648
  /**
1275
1649
  * Bind Turbine to PowDB. `target` is one of:
@@ -1292,30 +1666,38 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1292
1666
  async function turbinePowDB(target, schema, options = {}) {
1293
1667
  let pool;
1294
1668
  let owns = false;
1295
- const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1669
+ const poolOptions = {
1670
+ transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1671
+ retryStaleReads: options.retryStaleReads,
1672
+ };
1673
+ const max = options.connectionLimit ?? 10;
1296
1674
  if (typeof target === 'string') {
1297
1675
  const mod = options.powdbClientModule ?? (await loadPowdb());
1298
- const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
1299
- await assertNetworkedVersion(clientPool);
1300
- pool = new PowdbPool(clientPool, undefined, poolOptions);
1676
+ const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max });
1677
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1678
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1301
1679
  owns = true;
1302
1680
  }
1303
1681
  else if (target instanceof PowdbPool) {
1304
- // An injected PowdbPool carries its own PowdbPoolOptions.
1682
+ // An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
1305
1683
  pool = target;
1306
1684
  }
1307
1685
  else if (isEmbeddedTarget(target)) {
1308
- pool = await openEmbeddedPool(target, poolOptions);
1686
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1309
1687
  owns = true;
1310
1688
  }
1311
1689
  else if (isPowdbClientPool(target)) {
1312
- pool = new PowdbPool(target, undefined, poolOptions);
1690
+ // Injected client pool: run the SAME probe as the URL / host+port paths so
1691
+ // it gets real capabilities AND the version-floor check (this branch used
1692
+ // to skip the probe entirely (an injected pool silently bypassed both).
1693
+ const capabilities = await assertNetworkedVersion(target, options.assumeEngineVersion);
1694
+ pool = new PowdbPool(target, undefined, { ...poolOptions, capabilities });
1313
1695
  }
1314
1696
  else {
1315
1697
  const mod = options.powdbClientModule ?? (await loadPowdb());
1316
- const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
1317
- await assertNetworkedVersion(clientPool);
1318
- pool = new PowdbPool(clientPool, undefined, poolOptions);
1698
+ const clientPool = new mod.Pool({ ...target, max });
1699
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1700
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1319
1701
  owns = true;
1320
1702
  }
1321
1703
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -1353,14 +1735,20 @@ async function turbinePowDB(target, schema, options = {}) {
1353
1735
  return client;
1354
1736
  }
1355
1737
  /**
1356
- * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}) and
1357
- * fail fast if the server is older than {@link MIN_POWDB_VERSION}. Best-effort:
1358
- * a driver that does not surface a version is left untouched (we cannot prove it
1359
- * too old). Errors from the probe itself surface as the normal connect failure.
1738
+ * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}),
1739
+ * fail fast if the server is older than {@link MIN_POWDB_VERSION}, and derive
1740
+ * the {@link PowdbCapabilities} for it: the version gates PLUS `nativeRaw`
1741
+ * (server ≥ 0.13 AND the client exposes `queryNativeRaw`, feature-detected
1742
+ * here). `assumeEngineVersion` overrides the version used for capability
1743
+ * derivation (the floor check still runs against the real reported version).
1744
+ * Errors from the probe itself surface as the normal connect failure.
1360
1745
  */
1361
- async function assertNetworkedVersion(clientPool) {
1362
- await clientPool.withClient(async (c) => {
1746
+ async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
1747
+ return clientPool.withClient(async (c) => {
1363
1748
  assertSupportedPowdbVersion(c.serverVersion);
1749
+ const version = assumeEngineVersion ?? c.serverVersion;
1750
+ const hasNativeRaw = typeof c.queryNativeRaw === 'function';
1751
+ return capabilitiesFromVersion(version, { hasNativeRaw });
1364
1752
  });
1365
1753
  }
1366
1754
  function isPowdbClientPool(x) {