turbine-orm 0.33.0 → 0.35.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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/client.js +26 -4
  3. package/dist/cjs/dialect.js +1 -0
  4. package/dist/cjs/errors.js +41 -1
  5. package/dist/cjs/index-advisor.js +0 -0
  6. package/dist/cjs/index.js +4 -2
  7. package/dist/cjs/mssql.js +5 -0
  8. package/dist/cjs/mysql.js +4 -0
  9. package/dist/cjs/optional-peer-import.cjs +28 -0
  10. package/dist/cjs/powdb-introspect.js +222 -0
  11. package/dist/cjs/powdb.js +592 -72
  12. package/dist/cjs/powql.js +998 -134
  13. package/dist/cjs/query/builder.js +72 -1
  14. package/dist/cjs/schema-builder.js +16 -0
  15. package/dist/cjs/schema-metadata.js +81 -10
  16. package/dist/cjs/sqlite.js +3 -0
  17. package/dist/client.d.ts +32 -5
  18. package/dist/client.js +26 -4
  19. package/dist/dialect.d.ts +13 -0
  20. package/dist/dialect.js +1 -0
  21. package/dist/errors.d.ts +36 -0
  22. package/dist/errors.js +39 -0
  23. package/dist/index-advisor.d.ts +15 -1
  24. package/dist/index-advisor.js +0 -0
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.js +2 -2
  27. package/dist/mssql.js +5 -0
  28. package/dist/mysql.js +4 -0
  29. package/dist/optional-peer-import.cjs +28 -0
  30. package/dist/optional-peer-import.d.cts +19 -0
  31. package/dist/powdb-introspect.d.ts +84 -0
  32. package/dist/powdb-introspect.js +219 -0
  33. package/dist/powdb.d.ts +361 -19
  34. package/dist/powdb.js +585 -72
  35. package/dist/powql.d.ts +245 -8
  36. package/dist/powql.js +1001 -137
  37. package/dist/query/builder.d.ts +36 -1
  38. package/dist/query/builder.js +72 -1
  39. package/dist/query/deferred.d.ts +6 -2
  40. package/dist/query/types.d.ts +49 -12
  41. package/dist/schema-builder.d.ts +46 -1
  42. package/dist/schema-builder.js +15 -0
  43. package/dist/schema-metadata.d.ts +13 -7
  44. package/dist/schema-metadata.js +82 -11
  45. package/dist/schema.d.ts +25 -0
  46. package/dist/sqlite.js +3 -0
  47. package/package.json +3 -3
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,119 @@ 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
+ serverJoins: '0.13',
256
+ };
257
+ /**
258
+ * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
259
+ * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
260
+ * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
261
+ * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
262
+ * actual wire path and must only be enabled after a real server-version probe,
263
+ * never inferred from a bare construction.
264
+ */
265
+ exports.ALL_POWDB_CAPABILITIES = {
266
+ engineVersion: null,
267
+ jsonDocs: true,
268
+ docFieldIndexes: true,
269
+ introspection: true,
270
+ serverJoins: true,
271
+ nativeRaw: false,
272
+ };
273
+ /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
274
+ function parsePowdbSemver(version) {
275
+ const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
276
+ if (!m)
277
+ return null;
278
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
279
+ }
280
+ /** Is `sem` at least `major.minor`? */
281
+ function atLeastVersion(sem, major, minor) {
282
+ return sem.major > major || (sem.major === major && sem.minor >= minor);
283
+ }
284
+ /**
285
+ * Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
286
+ * unknown version turns every gate OFF (the E017 hint then tells the caller to
287
+ * upgrade or pass `assumeEngineVersion`). `nativeRaw` requires BOTH the client
288
+ * to expose `queryNativeRaw` (passed in) AND server ≥ 0.13.
289
+ */
290
+ function capabilitiesFromVersion(version, opts = {}) {
291
+ const sem = parsePowdbSemver(version);
292
+ if (!sem) {
293
+ return {
294
+ engineVersion: version ?? null,
295
+ jsonDocs: false,
296
+ docFieldIndexes: false,
297
+ introspection: false,
298
+ serverJoins: false,
299
+ nativeRaw: false,
300
+ };
301
+ }
302
+ return {
303
+ engineVersion: `${sem.major}.${sem.minor}.${sem.patch}`,
304
+ introspection: atLeastVersion(sem, 0, 10),
305
+ jsonDocs: atLeastVersion(sem, 0, 12),
306
+ docFieldIndexes: atLeastVersion(sem, 0, 13),
307
+ serverJoins: atLeastVersion(sem, 0, 13),
308
+ nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
309
+ };
310
+ }
311
+ /**
312
+ * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
313
+ * PowQL feature is used on an engine that does not support it. Keeps old engines
314
+ * getting clean typed errors instead of raw PowQL parse failures.
315
+ */
316
+ function requireCapability(caps, key, feature) {
317
+ if (caps[key])
318
+ return;
319
+ const min = POWDB_FEATURE_MIN_VERSION[key];
320
+ const reported = caps.engineVersion
321
+ ? `this connection reports ${caps.engineVersion}`
322
+ : 'this connection could not report a version';
323
+ throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
324
+ '(or pass `assumeEngineVersion` if the version cannot be detected).');
325
+ }
326
+ /**
327
+ * Does this column map to PowDB's native `json` document type? A Postgres
328
+ * `json`/`jsonb` type (via `dialectType`/`pgType`) is authoritative; otherwise
329
+ * the tsType heuristic (`Record<…>`, `object`, `unknown`, an object/array
330
+ * literal) that the four scalar branches do not claim. Array columns never map
331
+ * to json, a PowDB array only exists INSIDE a json document, so a Postgres
332
+ * array column has no PowDB shape and still throws in {@link powqlColumnType}.
333
+ */
334
+ function isJsonColumn(col) {
335
+ if (col.isArray)
336
+ return false;
337
+ const dbType = (col.dialectType ?? col.pgType ?? '').toLowerCase();
338
+ if (dbType === 'json' || dbType === 'jsonb')
339
+ return true;
340
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
341
+ if (ts === 'Date' || ts === 'boolean' || ts === 'number' || ts === 'bigint' || ts === 'string')
342
+ return false;
343
+ if (ts === 'Buffer' || ts === 'Uint8Array')
344
+ return false;
345
+ return /Record<|object|unknown|\[\]|\{/.test(ts);
346
+ }
228
347
  /**
229
348
  * Map a Turbine column to the PowQL DDL type used in `defineSchema` →
230
349
  * `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
231
350
  * which cannot hold client-supplied values on the wire (no literal, no cast):
232
351
  * - `Date` → `int` (epoch micros) - `boolean` → `bool`
233
352
  * - integral `number`/`bigint` → `int` - fractional `number` → `float`
353
+ * - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
234
354
  * - everything else (incl. UUID/PK strings) → `str`
235
- * Array / JSON / bytes columns throw they have no PowDB equivalent.
355
+ * Array (non-json) and bytes columns throw, they have no PowDB equivalent.
236
356
  */
237
357
  function powqlColumnType(col) {
238
358
  if (col.isArray) {
239
359
  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
360
  }
361
+ if (isJsonColumn(col))
362
+ return 'json';
241
363
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
242
364
  if (ts === 'Date')
243
365
  return 'int'; // epoch micros
@@ -252,9 +374,6 @@ function powqlColumnType(col) {
252
374
  if (ts === 'Buffer' || ts === 'Uint8Array') {
253
375
  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
376
  }
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
377
  return 'str';
259
378
  }
260
379
  /** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
@@ -394,7 +513,8 @@ function quotePowqlIdent(name) {
394
513
  }
395
514
  return exports.POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
396
515
  }
397
- function powqlSchemaDDL(schema) {
516
+ function powqlSchemaDDL(schema, opts = {}) {
517
+ const caps = opts.capabilities;
398
518
  const stmts = [];
399
519
  for (const meta of Object.values(schema.tables)) {
400
520
  const pkSet = new Set(meta.primaryKey);
@@ -405,6 +525,12 @@ function powqlSchemaDDL(schema) {
405
525
  // cannot enforce the tuple's uniqueness at the engine level.
406
526
  const pkIsSingle = meta.primaryKey.length === 1;
407
527
  const fields = meta.columns.map((col) => {
528
+ const powqlType = powqlColumnType(col);
529
+ // Gate `json` columns behind the engine's jsonDocs capability when a
530
+ // caller supplied one, an old engine has no `json` type and would reject
531
+ // the DDL. Pure-function callers (no opts) emit unconditionally.
532
+ if (powqlType === 'json' && caps)
533
+ requireCapability(caps, 'jsonDocs', 'JSON document columns');
408
534
  const mods = [];
409
535
  if (!col.nullable || pkSet.has(col.name))
410
536
  mods.push('required');
@@ -413,15 +539,57 @@ function powqlSchemaDDL(schema) {
413
539
  // `auto` = server-generated monotonic int. PowDB requires it be `int` and
414
540
  // rejects it alongside a `default`; non-int generated columns fall back to
415
541
  // a plain typed column (Turbine assigns the value client-side instead).
416
- if (col.isGenerated && powqlColumnType(col) === 'int')
542
+ if (col.isGenerated && powqlType === 'int')
417
543
  mods.push('auto');
418
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
544
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
419
545
  });
420
546
  stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
547
+ // Track which single columns already carry a unique constraint (the
548
+ // single-column PK is inlined `required unique` in the type body above) so
549
+ // a redundant `add unique .col` is never emitted twice.
550
+ const emittedUnique = new Set();
551
+ if (pkIsSingle && meta.primaryKey[0] !== undefined)
552
+ emittedUnique.add(meta.primaryKey[0]);
421
553
  // Secondary unique constraints (beyond the PK) become unique indexes.
422
554
  for (const uniq of meta.uniqueColumns) {
423
555
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
424
556
  stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
557
+ emittedUnique.add(uniq[0]);
558
+ }
559
+ }
560
+ // Declared indexes: PowDB doc-field expression indexes (docPath) and plain
561
+ // single-column indexes. A doc-field index MUST be parenthesized (the engine
562
+ // rejects a bare JSON path); string path segments emit lexer-exact via the
563
+ // shared `encodePowqlString`, integer array indexes emit bare. A json
564
+ // document column reference stays dotted-bare (`.col`), which bypasses
565
+ // keyword lookup on every engine version exactly like a filter path.
566
+ for (const idx of meta.indexes) {
567
+ const kind = idx.unique ? 'unique' : 'index';
568
+ if (idx.docPath) {
569
+ if (caps)
570
+ requireCapability(caps, 'docFieldIndexes', 'JSON doc-field expression indexes');
571
+ const column = idx.columns[0];
572
+ if (column === undefined) {
573
+ throw new errors_js_1.ValidationError(`[turbine] Doc-field index "${idx.name}" on ${meta.name} has no target json column.`);
574
+ }
575
+ const segs = idx.docPath.map((s) => (typeof s === 'number' ? `->${s}` : `->${encodePowqlString(s)}`)).join('');
576
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${column}${segs})`);
577
+ }
578
+ else {
579
+ // Plain column index. PowDB has no composite index (`add index` takes a
580
+ // single `.column`), so a multi-column entry is a typed E017.
581
+ if (idx.columns.length !== 1) {
582
+ throw new errors_js_1.UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
583
+ `${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
584
+ }
585
+ const column = idx.columns[0];
586
+ // A unique index whose column already carries a unique constraint (the
587
+ // PK, or a column-level unique) would be a redundant duplicate, so skip it.
588
+ if (idx.unique && emittedUnique.has(column))
589
+ continue;
590
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} .${quotePowqlIdent(column)}`);
591
+ if (idx.unique)
592
+ emittedUnique.add(column);
425
593
  }
426
594
  }
427
595
  }
@@ -431,6 +599,10 @@ function powqlSchemaDDL(schema) {
431
599
  function toPowdbParam(value, col) {
432
600
  if (value instanceof PowdbFloatParam)
433
601
  return value.value; // wire-side: a float column takes the plain number
602
+ // json document: serialize to canonical JSON text and bind as a str param,
603
+ // the engine validates it as JSON and stores the canonical binary form.
604
+ if (value instanceof PowdbJsonParam)
605
+ return JSON.stringify(value.value);
434
606
  if (value === undefined || value === null)
435
607
  return null;
436
608
  if (value instanceof Date)
@@ -453,10 +625,23 @@ function toPowdbParam(value, col) {
453
625
  */
454
626
  function coerceValue(raw, col) {
455
627
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
628
+ const json = isJsonColumn(col);
456
629
  // NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
457
630
  // literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
458
- if (raw === 'null' && (ts !== 'string' || col.nullable))
631
+ // For a `json` column the bareword `null` (a legacy-wire rendering shared by an
632
+ // absent value AND a top-level JSON-null document, documented residual,
633
+ // resolved on the native transport by the WireValue path) maps to null; a JSON
634
+ // string document "null" renders WITH quotes (`"null"`) and parses distinctly.
635
+ if (raw === 'null' && (json || ts !== 'string' || col.nullable))
459
636
  return null;
637
+ if (json) {
638
+ try {
639
+ return JSON.parse(raw);
640
+ }
641
+ catch {
642
+ return raw; // defensive: canonical JSON text always parses
643
+ }
644
+ }
460
645
  if (ts === 'Date') {
461
646
  const micros = Number(raw);
462
647
  return Number.isFinite(micros) ? new Date(micros / 1000) : null;
@@ -473,19 +658,62 @@ function coerceValue(raw, col) {
473
658
  return raw; // string / uuid-as-string
474
659
  }
475
660
  /**
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.
661
+ * Coerce a single cell that arrived over the NATIVE typed wire (decoded from a
662
+ * {@link PowdbWireValue}, so already a JS `bigint`/`number`/`boolean`/`string`/
663
+ * `NativeJson`/`Uint8Array`/`null`, never a bare `"null"` string). Unlike
664
+ * {@link coerceValue} this NEVER collapses the string `"null"` to `null`: an
665
+ * absent value already decoded to `null` (from the `empty` cell), so a genuine
666
+ * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
667
+ * native transport). `datetime`-shaped cells (int micros) become `Date`; a
668
+ * bigint on a `number` column follows the int8 safe-integer policy.
669
+ */
670
+ function coerceNativeValue(value, col) {
671
+ if (value === undefined || value === null)
672
+ return null;
673
+ if (isDateColumn(col)) {
674
+ if (typeof value === 'bigint')
675
+ return new Date(Number(value) / 1000);
676
+ if (typeof value === 'number')
677
+ return new Date(value / 1000);
678
+ return value;
679
+ }
680
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
681
+ if (typeof value === 'bigint') {
682
+ if (ts === 'bigint')
683
+ return value;
684
+ if (ts === 'number') {
685
+ const n = Number(value);
686
+ return Number.isSafeInteger(n) ? n : value.toString(); // int8 policy: keep big ints as strings
687
+ }
688
+ return value;
689
+ }
690
+ return value; // number / boolean / string / NativeJson document / Uint8Array
691
+ }
692
+ /**
693
+ * Map one raw PowDB row into a typed entity (camelCase fields, coerced values).
694
+ * Only the columns present in `raw` are emitted, so partial `select`
695
+ * projections round-trip unchanged. `native` selects the coercion policy: the
696
+ * default `false` handles the legacy string wire (every cell is a string, via
697
+ * {@link coerceValue}); `true` handles the native typed wire, where non-string
698
+ * cells arrive pre-typed and go through {@link coerceNativeValue} (see F3).
699
+ * Callers on the native transport pass `this.pool.capabilities.nativeRaw`.
480
700
  */
481
- function rowToEntity(raw, meta) {
701
+ function rowToEntity(raw, meta, native = false) {
482
702
  const byName = new Map(meta.columns.map((c) => [c.name, c]));
483
703
  const out = {};
484
704
  for (const snake of Object.keys(raw)) {
485
705
  const col = byName.get(snake);
486
706
  const field = meta.reverseColumnMap[snake] ?? snake;
487
707
  const value = raw[snake];
488
- out[field] = col && typeof value === 'string' ? coerceValue(value, col) : value;
708
+ if (!col) {
709
+ out[field] = value;
710
+ }
711
+ else if (native) {
712
+ out[field] = coerceNativeValue(value, col);
713
+ }
714
+ else {
715
+ out[field] = typeof value === 'string' ? coerceValue(value, col) : value;
716
+ }
489
717
  }
490
718
  return out;
491
719
  }
@@ -511,7 +739,7 @@ function wrapPowdbError(err) {
511
739
  const e = err;
512
740
  const msg = e.message ?? 'unknown PowDB error';
513
741
  // Unique-constraint — message-based on both transports.
514
- if (/unique constraint violation/i.test(msg)) {
742
+ if (/unique (constraint|expression index) violation/i.test(msg)) {
515
743
  const m = /on\s+\S+\.(\w+)/i.exec(msg);
516
744
  return new errors_js_1.UniqueConstraintError({ constraint: m?.[1], cause: err });
517
745
  }
@@ -521,40 +749,123 @@ function wrapPowdbError(err) {
521
749
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
522
750
  return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
523
751
  }
524
- // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
525
- // no .code classify by message so both transports surface E004.
526
- if (/pool closed|pool acquire timeout/i.test(msg)) {
527
- return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
752
+ // Driver pool lifecycle errors (acquire after close, acquire timeout, or a
753
+ // statement reaching an already-closed embedded handle) carry no .code:
754
+ // classify by message so both transports surface E004.
755
+ if (/pool closed|pool acquire timeout|database is closed/i.test(msg)) {
756
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
528
757
  }
529
758
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
530
759
  // connection held the single global write lock past the server's
531
760
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
532
761
  if (/transaction gate timeout/i.test(msg)) {
533
- return new errors_js_1.TimeoutError(0, 'PowDB transaction gate');
762
+ return new errors_js_1.TimeoutError(0, 'PowDB transaction gate', { cause: err });
763
+ }
764
+ // Stale / violated WIRE state → ConnectionError (E004), NOT a query defect.
765
+ // A `protocol_error`-class failure means the socket's framing state is gone
766
+ // (the client cannot safely reuse it and the pool must destroy it). The
767
+ // canonical trigger is the "received unexpected frame from server" that a
768
+ // fresh request hits after a multi-minute idle gap; sibling shapes are an
769
+ // unknown message type, a truncated payload, or bad framing. Runs BEFORE the
770
+ // validation regex below, whose `unexpected` token would otherwise misclass
771
+ // "received unexpected frame" as an E003 query defect. `.cause` preserved so
772
+ // callers (and the opt-in stale-read retry) can inspect the driver code.
773
+ if (e.code === 'protocol_error' ||
774
+ /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
775
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
534
776
  }
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.
777
+ // Read-only refusal ReadOnlyError (E018). Two engine shapes, both mapped by
778
+ // substring (the networked transport prefixes the message with `query failed:
779
+ // `, so never anchor on the start): an embedded database opened read-only for
780
+ // snapshot serving (`readonly mode: statement requires a writer …`), and a
781
+ // networked read-only role (`permission denied: role '<role>' cannot execute
782
+ // write statements`). These run BEFORE the generic validation regex below so a
783
+ // read-only write is surfaced as the routing signal E018, not a query defect.
784
+ // The driver spec (0.15) distinguishes them via `reason`: snapshot mode
785
+ // means "nothing can write here; route writes to the primary", RBAC means
786
+ // "this connection's role may not write here".
787
+ if (/readonly mode: statement requires a writer/i.test(msg)) {
788
+ return new errors_js_1.ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
789
+ cause: err,
790
+ reason: 'snapshot',
791
+ });
792
+ }
793
+ if (/permission denied: role/i.test(msg)) {
794
+ return new errors_js_1.ReadOnlyError(`PowDB refused a write for a read-only role: ${msg}.`, { cause: err, reason: 'rbac' });
795
+ }
796
+ // Open-time read-only failure: a read-only handle over a directory whose WAL
797
+ // still has uncommitted frames is refused (`cannot open read-only: the WAL is
798
+ // not empty …`). It is a connection failure (E004), not a query defect, the
799
+ // fix is to recover the directory with a writable open first.
800
+ if (/cannot open read-only: the WAL is not empty/i.test(msg)) {
801
+ return new errors_js_1.ConnectionError(`[turbine] PowDB could not open the directory read-only: ${msg}. Open it once with a writable handle to ` +
802
+ 'flush the WAL (recover the directory), then reopen it read-only for snapshot serving.', { cause: err });
803
+ }
804
+ // Per-query deadline → TimeoutError (E002). Message-path so it fires on the
805
+ // embedded transport too (code is always 'GenericFailure' there); retryable.
806
+ // Pass the engine prose through the message override (same pattern as the
807
+ // transaction-gate timeout below) so the real "query timeout after <n>ms"
808
+ // survives instead of rendering the placeholder "timed out after 0ms".
809
+ if (/query timeout after/i.test(msg)) {
810
+ return new errors_js_1.TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
811
+ }
812
+ // Client-initiated cancellation → ConnectionError (E004). This is FINAL: the
813
+ // issuing client disconnected, so the query was a clean early return, never
814
+ // auto-retry it (the opt-in stale-read retry only replays stale-FRAME reads).
815
+ if (/query cancelled by client disconnect/i.test(msg)) {
816
+ return new errors_js_1.ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
817
+ }
818
+ // Bounded join rejection → ValidationError (E003). The engine rejects a pure
819
+ // nested-loop join whose candidate-pair count (or result row count) exceeds
820
+ // the safety bound BEFORE executing, and names the fix in the message, keep
821
+ // that fix-hint intact so the caller knows how to make the join eligible.
822
+ if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
823
+ return new errors_js_1.ValidationError(`[turbine] PowDB join rejected: ${msg}`);
824
+ }
825
+ // Type mismatch / parse / execution / storage / unexpected(token) / row too
826
+ // large → validation (E003). On the embedded transport these are the only
827
+ // signal we get (code is always 'GenericFailure'); on the networked path they
828
+ // are a safety net before the .code switch.
539
829
  if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
540
830
  return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
541
831
  }
542
832
  switch (e.code) {
543
833
  case 'connect_failed':
544
834
  case 'closed':
545
- return new errors_js_1.ConnectionError(`[turbine] PowDB connection failed: ${msg}`);
835
+ return new errors_js_1.ConnectionError(`[turbine] PowDB connection failed: ${msg}`, { cause: err });
836
+ case 'auth_failed':
837
+ // Connection-establishment class, non-retryable: the handshake was
838
+ // rejected. Surface E004 with a concrete remediation hint instead of
839
+ // letting it fall through to the raw error.
840
+ return new errors_js_1.ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
546
841
  case 'timeout':
547
842
  case 'aborted':
548
- return new errors_js_1.TimeoutError(0, 'PowDB query');
843
+ return new errors_js_1.TimeoutError(0, 'PowDB query', { cause: err });
549
844
  case 'query_failed':
550
845
  case 'type_coercion_failed':
551
- case 'protocol_error':
552
846
  case 'size_exceeded':
553
847
  return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
554
848
  default:
555
- return err instanceof Error ? err : new errors_js_1.ConnectionError(`[turbine] PowDB error: ${msg}`);
849
+ return err instanceof Error ? err : new errors_js_1.ConnectionError(`[turbine] PowDB error: ${msg}`, { cause: err });
556
850
  }
557
851
  }
852
+ /**
853
+ * True when `err` is the stale-wire-frame {@link ConnectionError} produced by
854
+ * {@link wrapPowdbError} (its `.cause` is a `protocol_error` PowDBError, or the
855
+ * message carries the invalid-state signature). The opt-in read retry
856
+ * (`retryStaleReads`, evaluated in {@link PowqlInterface}'s exec seam) uses this
857
+ * to decide whether a first-statement READ may be replayed once on a fresh
858
+ * connection; writes are NEVER retried (an ambiguous mutation reply is unsafe
859
+ * to replay, matching the client's own native-path policy).
860
+ */
861
+ function isStaleFramePowdbError(err) {
862
+ if (!(err instanceof errors_js_1.ConnectionError))
863
+ return false;
864
+ const cause = err.cause;
865
+ if (cause && typeof cause === 'object' && cause.code === 'protocol_error')
866
+ return true;
867
+ return /PowDB connection is in an invalid state/.test(err.message);
868
+ }
558
869
  function normalizeQueryArgs(arg, values) {
559
870
  if (typeof arg === 'string')
560
871
  return { text: arg, params: values ?? [] };
@@ -733,7 +1044,7 @@ class PowdbTxGate {
733
1044
  return hold;
734
1045
  }
735
1046
  }
736
- /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
1047
+ /** Adapt a PowDB (legacy string wire) result into the pg-compat `{ rows, rowCount, fields }` shape. */
737
1048
  function adaptResult(r) {
738
1049
  switch (r.kind) {
739
1050
  case 'rows': {
@@ -744,21 +1055,86 @@ function adaptResult(r) {
744
1055
  });
745
1056
  return o;
746
1057
  });
747
- return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
1058
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: false };
748
1059
  }
749
1060
  case 'ok':
750
- return { rows: [], rowCount: Number(r.affected), fields: [] };
1061
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
751
1062
  case 'scalar':
752
- return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
1063
+ return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }], native: false };
753
1064
  default:
754
- return { rows: [], rowCount: 0, fields: [] };
1065
+ return { rows: [], rowCount: 0, fields: [], native: false };
1066
+ }
1067
+ }
1068
+ /** Format 16 raw UUID bytes as a canonical `8-4-4-4-12` hex string. */
1069
+ function uuidBytesToHex(bytes) {
1070
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1071
+ if (hex.length !== 32)
1072
+ return hex; // defensive: non-16B payloads pass through as raw hex
1073
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1074
+ }
1075
+ /**
1076
+ * Decode one native {@link PowdbWireValue} cell into a JS value. `empty` →
1077
+ * `null` (an unset value; for a json column this cleanly distinguishes absent
1078
+ * from a JSON-null document, which arrives as `{ type: 'json', value: null }`).
1079
+ * `int`/`datetime` stay `bigint` so the row layer ({@link coerceNativeValue})
1080
+ * applies the int8 policy / Date conversion by column; `uuid` becomes canonical
1081
+ * hex; `bytes` stay `Uint8Array`; `json` passes the decoded document through
1082
+ * with no re-parse (its `pj1` raw bytes are dropped).
1083
+ */
1084
+ function decodeWireValue(cell) {
1085
+ switch (cell.type) {
1086
+ case 'empty':
1087
+ return null;
1088
+ case 'int':
1089
+ case 'datetime':
1090
+ return cell.value; // bigint; row layer decides Date vs number vs bigint per column
1091
+ case 'float':
1092
+ case 'bool':
1093
+ case 'str':
1094
+ return cell.value;
1095
+ case 'uuid':
1096
+ return uuidBytesToHex(cell.value);
1097
+ case 'bytes':
1098
+ return cell.value; // Uint8Array
1099
+ case 'json':
1100
+ return cell.value; // NativeJson document, already recursive data
1101
+ }
1102
+ }
1103
+ /** Adapt a native (typed-wire) PowDB result into the pg-compat shape, decoding every {@link PowdbWireValue} cell. */
1104
+ function adaptNativeResult(r) {
1105
+ switch (r.kind) {
1106
+ case 'rows': {
1107
+ const rows = r.rows.map((row) => {
1108
+ const o = {};
1109
+ r.columns.forEach((c, i) => {
1110
+ o[c] = decodeWireValue(row[i] ?? { type: 'empty' });
1111
+ });
1112
+ return o;
1113
+ });
1114
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: true };
1115
+ }
1116
+ case 'ok':
1117
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: true };
1118
+ case 'scalar':
1119
+ return {
1120
+ rows: [{ value: decodeWireValue(r.value) }],
1121
+ rowCount: 1,
1122
+ fields: [{ name: 'value', dataTypeID: 0 }],
1123
+ native: true,
1124
+ };
1125
+ default:
1126
+ return { rows: [], rowCount: 0, fields: [], native: true };
755
1127
  }
756
1128
  }
757
1129
  /**
758
1130
  * 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).
1131
+ * `text` is **PowQL**, not SQL ({@link PowqlInterface} generates it). On the
1132
+ * legacy string wire cells come back as strings; when `capabilities.nativeRaw`
1133
+ * is set (server 0.13 + a client exposing `queryNativeRaw`) this pool routes
1134
+ * through the typed native wire instead, so cells arrive pre-typed (a json int
1135
+ * as `bigint`, etc.) and each result is tagged with the wire that served it
1136
+ * ({@link PowdbTaggedResult}). Per-column JS coercion still happens in
1137
+ * `PowqlInterface` (it owns the schema metadata), keyed on that per-result tag.
762
1138
  */
763
1139
  class PowdbPool {
764
1140
  pool;
@@ -782,10 +1158,37 @@ class PowdbPool {
782
1158
  * live socket holding the process open until the server's idle timeout.
783
1159
  */
784
1160
  checkedOut = new Set();
1161
+ /** Feature capabilities of the bound server (probed version + native-wire feature-detect). */
1162
+ capabilities;
1163
+ /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1164
+ retryStaleReads;
1165
+ /**
1166
+ * True when the caller marked this pool read-only (`readonly: true`). Read by
1167
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1168
+ * wire; the engine's own read-only-role refusal (mapped by
1169
+ * {@link wrapPowdbError}) is the backstop for raw / injected paths.
1170
+ */
1171
+ readonly;
785
1172
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
786
1173
  this.pool = pool;
787
1174
  this.toParam = toParam;
788
1175
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1176
+ this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1177
+ this.retryStaleReads = options.retryStaleReads ?? false;
1178
+ this.readonly = options.readonly ?? false;
1179
+ }
1180
+ /**
1181
+ * Run one statement on `c`, choosing the lossless native typed wire when the
1182
+ * server supports it (`capabilities.nativeRaw`) AND this client exposes
1183
+ * `queryNativeRaw` (a defensive per-call feature-detect, so a heterogeneous
1184
+ * injected pool cannot crash). Otherwise the legacy string wire, unchanged.
1185
+ */
1186
+ async runOnClient(c, powql, params) {
1187
+ const bound = params.map(this.toParam);
1188
+ if (this.capabilities.nativeRaw && typeof c.queryNativeRaw === 'function') {
1189
+ return adaptNativeResult(await c.queryNativeRaw(powql, bound));
1190
+ }
1191
+ return adaptResult(await c.query(powql, bound));
789
1192
  }
790
1193
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
791
1194
  async query(text, values) {
@@ -806,8 +1209,7 @@ class PowdbPool {
806
1209
  return { rows: [], rowCount: 0, fields: [] };
807
1210
  }
808
1211
  try {
809
- const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
810
- return adaptResult(result);
1212
+ return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
811
1213
  }
812
1214
  catch (err) {
813
1215
  if (ctl === 'begin') {
@@ -878,7 +1280,7 @@ class PowdbPool {
878
1280
  return { rows: [], rowCount: 0, fields: [] };
879
1281
  }
880
1282
  try {
881
- return adaptResult(await client.query(powql, params.map(this.toParam)));
1283
+ return await this.runOnClient(client, powql, params);
882
1284
  }
883
1285
  catch (err) {
884
1286
  broken = true;
@@ -1005,6 +1407,10 @@ function encodePowqlLiteral(value) {
1005
1407
  // Force a float-form literal so an integer-valued float column stays a float.
1006
1408
  return Number.isInteger(n) ? `${n}.0` : String(n);
1007
1409
  }
1410
+ // json document: emit the canonical JSON text as a PowQL string literal (the
1411
+ // embedded engine validates and stores it as a json document).
1412
+ if (value instanceof PowdbJsonParam)
1413
+ return encodePowqlString(JSON.stringify(value.value));
1008
1414
  if (value === undefined || value === null)
1009
1415
  return 'null';
1010
1416
  if (value instanceof Date)
@@ -1060,10 +1466,14 @@ function materializePowql(powql, params) {
1060
1466
  }
1061
1467
  /**
1062
1468
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
1063
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
1064
- * accepts only a string — so this pool materializes each positional `$N` into a
1065
- * PowQL literal via {@link materializePowql} before handing the text to the
1066
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
1469
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
1470
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
1471
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
1472
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
1473
+ * legacy string wire, which takes **no params array** (its `query(powql)`
1474
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
1475
+ * literal via {@link materializePowql} before the text is handed to the engine.
1476
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
1067
1477
  * `rollback`) are issued serially as ordinary queries.
1068
1478
  */
1069
1479
  class PowdbEmbeddedPool {
@@ -1082,12 +1492,46 @@ class PowdbEmbeddedPool {
1082
1492
  txGate;
1083
1493
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1084
1494
  poolHoldRef = { hold: null };
1495
+ /**
1496
+ * Feature capabilities of the embedded engine (resolved from the addon
1497
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
1498
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
1499
+ * addon has no such method, so it stays false and the legacy string wire is
1500
+ * used.
1501
+ */
1502
+ capabilities;
1503
+ /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1504
+ retryStaleReads;
1505
+ /**
1506
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
1507
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
1508
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1509
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
1510
+ * backstop for raw / injected paths.
1511
+ */
1512
+ readonly;
1085
1513
  constructor(db, options = {}) {
1086
1514
  this.db = db;
1087
1515
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1516
+ this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1517
+ this.retryStaleReads = options.retryStaleReads ?? false;
1518
+ this.readonly = options.readonly ?? false;
1088
1519
  }
1089
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1520
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
1090
1521
  exec(powql, params) {
1522
+ // Native typed wire (addon ≥ 0.14): bind positional params with the SAME
1523
+ // binder the networked transport uses ({@link toPowdbParam} yields exactly
1524
+ // the NativeParam union null|bigint|number|boolean|string) and decode the
1525
+ // typed cells: a genuine str "null" survives, a json-null document stays
1526
+ // distinct from an absent value. Gated on the resolved capability AND a
1527
+ // per-call feature-detect so a heterogeneous injected handle cannot crash.
1528
+ if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
1529
+ const bound = params.map((v) => toPowdbParam(v));
1530
+ return adaptNativeResult(this.db.queryWithParams(powql, bound));
1531
+ }
1532
+ // Legacy string wire (addon < 0.14): the engine takes no params array, so
1533
+ // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1534
+ // live and tested as the pre-0.14 fallback.
1091
1535
  const materialized = materializePowql(powql, params);
1092
1536
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1093
1537
  }
@@ -1107,6 +1551,15 @@ class PowdbEmbeddedPool {
1107
1551
  // transaction callback throws re-entrant E017 fast; independent
1108
1552
  // concurrent ones wait their FIFO turn.
1109
1553
  holdRef.hold = await this.txGate.acquire();
1554
+ // The gate may have handed us the slot AFTER disconnect() closed the
1555
+ // handle (a transaction queued behind an in-flight one, released as the
1556
+ // pool shut down). Re-check before touching the now-closed engine, and
1557
+ // release the slot we just took so the queue keeps draining.
1558
+ if (this.closed) {
1559
+ holdRef.hold.finish();
1560
+ holdRef.hold = null;
1561
+ throw new errors_js_1.ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1562
+ }
1110
1563
  }
1111
1564
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1112
1565
  // This context never acquired the gate — its `begin` never ran (the
@@ -1182,19 +1635,24 @@ class PowdbEmbeddedPool {
1182
1635
  async end() {
1183
1636
  if (this.closed)
1184
1637
  return;
1185
- // The addon exposes no explicit close — drop the reference and let GC /
1186
- // the engine's checkpoint flush. Marking the pool closed makes later
1187
- // queries fail with a typed ConnectionError instead of silently running
1188
- // against a handle the caller believes is gone. Caveat: durability is
1189
- // checkpoint-bound, so hold the process open long enough for the final
1190
- // WAL flush in short scripts.
1191
1638
  this.closed = true;
1639
+ // Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
1640
+ // the final WAL flush completes deterministically before the handle is
1641
+ // dropped. An older addon has no close, dropping the reference and letting
1642
+ // GC / the engine's checkpoint flush is the fallback (durability is then
1643
+ // checkpoint-bound, so a short script must hold the process open long enough
1644
+ // for the final flush). Marking the pool closed makes later queries fail
1645
+ // with a typed ConnectionError instead of running against a gone handle.
1646
+ this.db.close?.();
1192
1647
  }
1193
1648
  }
1194
1649
  exports.PowdbEmbeddedPool = PowdbEmbeddedPool;
1195
1650
  // ---------------------------------------------------------------------------
1196
1651
  // PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
1197
1652
  // ---------------------------------------------------------------------------
1653
+ // `describe`-based introspection (programmatic API; see powdb-introspect.ts).
1654
+ var powdb_introspect_js_1 = require("./powdb-introspect.js");
1655
+ Object.defineProperty(exports, "introspectPowdbDatabase", { enumerable: true, get: function () { return powdb_introspect_js_1.introspectPowdbDatabase; } });
1198
1656
  var powql_js_1 = require("./powql.js");
1199
1657
  Object.defineProperty(exports, "PowqlInterface", { enumerable: true, get: function () { return powql_js_1.PowqlInterface; } });
1200
1658
  /**
@@ -1242,13 +1700,47 @@ async function loadPowdbEmbedded() {
1242
1700
  }
1243
1701
  return mod;
1244
1702
  }
1703
+ /**
1704
+ * Resolve the embedded addon's engine version. The addon vendors the engine and
1705
+ * exports no version, but `@zvndev/powdb-embedded/package.json` has no `exports`
1706
+ * map, so a bare `require` of it resolves: the package version IS the engine
1707
+ * version. Delegated to the `.cts` optional-peer helper so the resolution uses a
1708
+ * real CommonJS `require` in BOTH build outputs; `import.meta.url` here would
1709
+ * fail `tsc` under `tsconfig.cjs.json` (module: CommonJS) and crash any CJS
1710
+ * consumer of `turbine-orm/powdb`. Returns `null` when it cannot be resolved.
1711
+ */
1712
+ function resolveEmbeddedVersion() {
1713
+ return optional_peer_import_cjs_1.default.peerPackageVersion('@zvndev/powdb-embedded');
1714
+ }
1245
1715
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1246
- async function openEmbeddedPool(target, poolOptions = {}) {
1247
- const mod = await loadPowdbEmbedded();
1248
- const { embedded: dir, syncMode, memoryLimit } = target;
1716
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
1717
+ const mod = injectedModule ?? (await loadPowdbEmbedded());
1718
+ const { embedded: dir, syncMode, memoryLimit, readonly } = target;
1719
+ // A read-only engine never writes, so a durability selector is meaningless
1720
+ // there, reject the combination loudly rather than silently ignoring one.
1721
+ if (readonly && syncMode !== undefined) {
1722
+ throw new errors_js_1.ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
1723
+ }
1249
1724
  let db;
1250
1725
  try {
1251
- if (memoryLimit !== undefined) {
1726
+ if (readonly) {
1727
+ // Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
1728
+ // constructors; feature-detect and fail with a clear version hint if the
1729
+ // installed addon predates them.
1730
+ if (memoryLimit !== undefined) {
1731
+ if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
1732
+ throw new errors_js_1.ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
1733
+ }
1734
+ db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
1735
+ }
1736
+ else {
1737
+ if (typeof mod.Database.openReadOnly !== 'function') {
1738
+ throw new errors_js_1.ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
1739
+ }
1740
+ db = mod.Database.openReadOnly(dir);
1741
+ }
1742
+ }
1743
+ else if (memoryLimit !== undefined) {
1252
1744
  if (typeof mod.Database.openWithMemoryLimit !== 'function') {
1253
1745
  throw new errors_js_1.ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
1254
1746
  }
@@ -1269,7 +1761,19 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1269
1761
  }
1270
1762
  db.setSyncMode(syncMode);
1271
1763
  }
1272
- return new PowdbEmbeddedPool(db, poolOptions);
1764
+ // Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
1765
+ // exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
1766
+ // applies via the version); an older addon has no such method → false.
1767
+ const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1768
+ hasNativeRaw: typeof db.queryWithParams === 'function',
1769
+ });
1770
+ // A read-only target forces the pool's readonly flag; otherwise honor whatever
1771
+ // `poolOptions` (threaded from `options.readonly`) carried.
1772
+ return new PowdbEmbeddedPool(db, {
1773
+ ...poolOptions,
1774
+ capabilities,
1775
+ readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
1776
+ });
1273
1777
  }
1274
1778
  /**
1275
1779
  * Bind Turbine to PowDB. `target` is one of:
@@ -1292,30 +1796,39 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1292
1796
  async function turbinePowDB(target, schema, options = {}) {
1293
1797
  let pool;
1294
1798
  let owns = false;
1295
- const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1799
+ const poolOptions = {
1800
+ transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1801
+ retryStaleReads: options.retryStaleReads,
1802
+ readonly: options.readonly,
1803
+ };
1804
+ const max = options.connectionLimit ?? 10;
1296
1805
  if (typeof target === 'string') {
1297
1806
  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);
1807
+ const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max });
1808
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1809
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1301
1810
  owns = true;
1302
1811
  }
1303
1812
  else if (target instanceof PowdbPool) {
1304
- // An injected PowdbPool carries its own PowdbPoolOptions.
1813
+ // An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
1305
1814
  pool = target;
1306
1815
  }
1307
1816
  else if (isEmbeddedTarget(target)) {
1308
- pool = await openEmbeddedPool(target, poolOptions);
1817
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
1309
1818
  owns = true;
1310
1819
  }
1311
1820
  else if (isPowdbClientPool(target)) {
1312
- pool = new PowdbPool(target, undefined, poolOptions);
1821
+ // Injected client pool: run the SAME probe as the URL / host+port paths so
1822
+ // it gets real capabilities AND the version-floor check (this branch used
1823
+ // to skip the probe entirely (an injected pool silently bypassed both).
1824
+ const capabilities = await assertNetworkedVersion(target, options.assumeEngineVersion);
1825
+ pool = new PowdbPool(target, undefined, { ...poolOptions, capabilities });
1313
1826
  }
1314
1827
  else {
1315
1828
  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);
1829
+ const clientPool = new mod.Pool({ ...target, max });
1830
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1831
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1319
1832
  owns = true;
1320
1833
  }
1321
1834
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -1328,6 +1841,7 @@ async function turbinePowDB(target, schema, options = {}) {
1328
1841
  logging: options.logging,
1329
1842
  defaultLimit: options.defaultLimit,
1330
1843
  warnOnUnlimited: options.warnOnUnlimited,
1844
+ relationLoadStrategy: options.relationLoadStrategy,
1331
1845
  queryInterfaceFactory,
1332
1846
  }, schema);
1333
1847
  if (owns) {
@@ -1353,14 +1867,20 @@ async function turbinePowDB(target, schema, options = {}) {
1353
1867
  return client;
1354
1868
  }
1355
1869
  /**
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.
1870
+ * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}),
1871
+ * fail fast if the server is older than {@link MIN_POWDB_VERSION}, and derive
1872
+ * the {@link PowdbCapabilities} for it: the version gates PLUS `nativeRaw`
1873
+ * (server ≥ 0.13 AND the client exposes `queryNativeRaw`, feature-detected
1874
+ * here). `assumeEngineVersion` overrides the version used for capability
1875
+ * derivation (the floor check still runs against the real reported version).
1876
+ * Errors from the probe itself surface as the normal connect failure.
1360
1877
  */
1361
- async function assertNetworkedVersion(clientPool) {
1362
- await clientPool.withClient(async (c) => {
1878
+ async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
1879
+ return clientPool.withClient(async (c) => {
1363
1880
  assertSupportedPowdbVersion(c.serverVersion);
1881
+ const version = assumeEngineVersion ?? c.serverVersion;
1882
+ const hasNativeRaw = typeof c.queryNativeRaw === 'function';
1883
+ return capabilitiesFromVersion(version, { hasNativeRaw });
1364
1884
  });
1365
1885
  }
1366
1886
  function isPowdbClientPool(x) {