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/powdb.js CHANGED
@@ -55,7 +55,7 @@
55
55
  import { AsyncLocalStorage } from 'node:async_hooks';
56
56
  import { TurbineClient, } from './client.js';
57
57
  import { postgresDialect } from './dialect.js';
58
- import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
58
+ import { ConnectionError, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
59
59
  import importOptionalPeer from './optional-peer-import.cjs';
60
60
  /**
61
61
  * Capability descriptor for PowDB. PowQL generation is owned by
@@ -124,6 +124,22 @@ export class PowdbFloatParam {
124
124
  this.value = value;
125
125
  }
126
126
  }
127
+ /**
128
+ * Marker wrapper for a JS object/array bound to a `json` document column. Both
129
+ * transports serialize `value` with `JSON.stringify` and send the text as a
130
+ * `str` param / string literal, exactly how the PowDB docs insert a json
131
+ * document (the engine validates it as JSON text and stores the canonical
132
+ * binary form). Constructed in {@link PowqlInterface.param} when the target
133
+ * column is `json` and the value is a non-null object/array; a JS string
134
+ * written to a json column passes through RAW (same contract as pg jsonb,
135
+ * pass `'"x"'` to store the JSON string `"x"`), and `null` stays `null`.
136
+ */
137
+ export class PowdbJsonParam {
138
+ value;
139
+ constructor(value) {
140
+ this.value = value;
141
+ }
142
+ }
127
143
  /** Minimum PowDB server version the networked transport requires. */
128
144
  export const MIN_POWDB_VERSION = '0.7.0';
129
145
  /**
@@ -174,19 +190,119 @@ export function assertSupportedPowdbVersion(version) {
174
190
  throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
175
191
  'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
176
192
  }
193
+ /** Minimum engine version each gated feature needs, for the E017 hint text. */
194
+ const POWDB_FEATURE_MIN_VERSION = {
195
+ introspection: '0.10',
196
+ jsonDocs: '0.12',
197
+ docFieldIndexes: '0.13',
198
+ serverJoins: '0.13',
199
+ };
200
+ /**
201
+ * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
202
+ * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
203
+ * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
204
+ * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
205
+ * actual wire path and must only be enabled after a real server-version probe,
206
+ * never inferred from a bare construction.
207
+ */
208
+ export const ALL_POWDB_CAPABILITIES = {
209
+ engineVersion: null,
210
+ jsonDocs: true,
211
+ docFieldIndexes: true,
212
+ introspection: true,
213
+ serverJoins: true,
214
+ nativeRaw: false,
215
+ };
216
+ /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
217
+ function parsePowdbSemver(version) {
218
+ const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
219
+ if (!m)
220
+ return null;
221
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
222
+ }
223
+ /** Is `sem` at least `major.minor`? */
224
+ function atLeastVersion(sem, major, minor) {
225
+ return sem.major > major || (sem.major === major && sem.minor >= minor);
226
+ }
227
+ /**
228
+ * Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
229
+ * unknown version turns every gate OFF (the E017 hint then tells the caller to
230
+ * upgrade or pass `assumeEngineVersion`). `nativeRaw` requires BOTH the client
231
+ * to expose `queryNativeRaw` (passed in) AND server ≥ 0.13.
232
+ */
233
+ export function capabilitiesFromVersion(version, opts = {}) {
234
+ const sem = parsePowdbSemver(version);
235
+ if (!sem) {
236
+ return {
237
+ engineVersion: version ?? null,
238
+ jsonDocs: false,
239
+ docFieldIndexes: false,
240
+ introspection: false,
241
+ serverJoins: false,
242
+ nativeRaw: false,
243
+ };
244
+ }
245
+ return {
246
+ engineVersion: `${sem.major}.${sem.minor}.${sem.patch}`,
247
+ introspection: atLeastVersion(sem, 0, 10),
248
+ jsonDocs: atLeastVersion(sem, 0, 12),
249
+ docFieldIndexes: atLeastVersion(sem, 0, 13),
250
+ serverJoins: atLeastVersion(sem, 0, 13),
251
+ nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
252
+ };
253
+ }
254
+ /**
255
+ * Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
256
+ * PowQL feature is used on an engine that does not support it. Keeps old engines
257
+ * getting clean typed errors instead of raw PowQL parse failures.
258
+ */
259
+ export function requireCapability(caps, key, feature) {
260
+ if (caps[key])
261
+ return;
262
+ const min = POWDB_FEATURE_MIN_VERSION[key];
263
+ const reported = caps.engineVersion
264
+ ? `this connection reports ${caps.engineVersion}`
265
+ : 'this connection could not report a version';
266
+ throw new UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
267
+ '(or pass `assumeEngineVersion` if the version cannot be detected).');
268
+ }
269
+ /**
270
+ * Does this column map to PowDB's native `json` document type? A Postgres
271
+ * `json`/`jsonb` type (via `dialectType`/`pgType`) is authoritative; otherwise
272
+ * the tsType heuristic (`Record<…>`, `object`, `unknown`, an object/array
273
+ * literal) that the four scalar branches do not claim. Array columns never map
274
+ * to json, a PowDB array only exists INSIDE a json document, so a Postgres
275
+ * array column has no PowDB shape and still throws in {@link powqlColumnType}.
276
+ */
277
+ export function isJsonColumn(col) {
278
+ if (col.isArray)
279
+ return false;
280
+ const dbType = (col.dialectType ?? col.pgType ?? '').toLowerCase();
281
+ if (dbType === 'json' || dbType === 'jsonb')
282
+ return true;
283
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
284
+ if (ts === 'Date' || ts === 'boolean' || ts === 'number' || ts === 'bigint' || ts === 'string')
285
+ return false;
286
+ if (ts === 'Buffer' || ts === 'Uint8Array')
287
+ return false;
288
+ return /Record<|object|unknown|\[\]|\{/.test(ts);
289
+ }
177
290
  /**
178
291
  * Map a Turbine column to the PowQL DDL type used in `defineSchema` →
179
292
  * `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
180
293
  * which cannot hold client-supplied values on the wire (no literal, no cast):
181
294
  * - `Date` → `int` (epoch micros) - `boolean` → `bool`
182
295
  * - integral `number`/`bigint` → `int` - fractional `number` → `float`
296
+ * - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
183
297
  * - everything else (incl. UUID/PK strings) → `str`
184
- * Array / JSON / bytes columns throw they have no PowDB equivalent.
298
+ * Array (non-json) and bytes columns throw, they have no PowDB equivalent.
185
299
  */
186
300
  export function powqlColumnType(col) {
187
301
  if (col.isArray) {
188
302
  throw new ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
189
303
  }
304
+ if (isJsonColumn(col))
305
+ return 'json';
190
306
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
191
307
  if (ts === 'Date')
192
308
  return 'int'; // epoch micros
@@ -201,9 +317,6 @@ export function powqlColumnType(col) {
201
317
  if (ts === 'Buffer' || ts === 'Uint8Array') {
202
318
  throw new ValidationError(`[turbine] Column "${col.name}" is binary — PowDB cannot store client-supplied bytes on the wire. Use a string (e.g. base64) instead.`);
203
319
  }
204
- if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
205
- throw new ValidationError(`[turbine] Column "${col.name}" (${col.tsType}) maps to JSON/object, which PowDB has no type for. Flatten it or store a JSON string.`);
206
- }
207
320
  return 'str';
208
321
  }
209
322
  /** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
@@ -343,7 +456,8 @@ export function quotePowqlIdent(name) {
343
456
  }
344
457
  return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
345
458
  }
346
- export function powqlSchemaDDL(schema) {
459
+ export function powqlSchemaDDL(schema, opts = {}) {
460
+ const caps = opts.capabilities;
347
461
  const stmts = [];
348
462
  for (const meta of Object.values(schema.tables)) {
349
463
  const pkSet = new Set(meta.primaryKey);
@@ -354,6 +468,12 @@ export function powqlSchemaDDL(schema) {
354
468
  // cannot enforce the tuple's uniqueness at the engine level.
355
469
  const pkIsSingle = meta.primaryKey.length === 1;
356
470
  const fields = meta.columns.map((col) => {
471
+ const powqlType = powqlColumnType(col);
472
+ // Gate `json` columns behind the engine's jsonDocs capability when a
473
+ // caller supplied one, an old engine has no `json` type and would reject
474
+ // the DDL. Pure-function callers (no opts) emit unconditionally.
475
+ if (powqlType === 'json' && caps)
476
+ requireCapability(caps, 'jsonDocs', 'JSON document columns');
357
477
  const mods = [];
358
478
  if (!col.nullable || pkSet.has(col.name))
359
479
  mods.push('required');
@@ -362,15 +482,57 @@ export function powqlSchemaDDL(schema) {
362
482
  // `auto` = server-generated monotonic int. PowDB requires it be `int` and
363
483
  // rejects it alongside a `default`; non-int generated columns fall back to
364
484
  // a plain typed column (Turbine assigns the value client-side instead).
365
- if (col.isGenerated && powqlColumnType(col) === 'int')
485
+ if (col.isGenerated && powqlType === 'int')
366
486
  mods.push('auto');
367
- return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlColumnType(col)}`;
487
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
368
488
  });
369
489
  stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
490
+ // Track which single columns already carry a unique constraint (the
491
+ // single-column PK is inlined `required unique` in the type body above) so
492
+ // a redundant `add unique .col` is never emitted twice.
493
+ const emittedUnique = new Set();
494
+ if (pkIsSingle && meta.primaryKey[0] !== undefined)
495
+ emittedUnique.add(meta.primaryKey[0]);
370
496
  // Secondary unique constraints (beyond the PK) become unique indexes.
371
497
  for (const uniq of meta.uniqueColumns) {
372
498
  if (uniq.length === 1 && !pkSet.has(uniq[0])) {
373
499
  stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
500
+ emittedUnique.add(uniq[0]);
501
+ }
502
+ }
503
+ // Declared indexes: PowDB doc-field expression indexes (docPath) and plain
504
+ // single-column indexes. A doc-field index MUST be parenthesized (the engine
505
+ // rejects a bare JSON path); string path segments emit lexer-exact via the
506
+ // shared `encodePowqlString`, integer array indexes emit bare. A json
507
+ // document column reference stays dotted-bare (`.col`), which bypasses
508
+ // keyword lookup on every engine version exactly like a filter path.
509
+ for (const idx of meta.indexes) {
510
+ const kind = idx.unique ? 'unique' : 'index';
511
+ if (idx.docPath) {
512
+ if (caps)
513
+ requireCapability(caps, 'docFieldIndexes', 'JSON doc-field expression indexes');
514
+ const column = idx.columns[0];
515
+ if (column === undefined) {
516
+ throw new ValidationError(`[turbine] Doc-field index "${idx.name}" on ${meta.name} has no target json column.`);
517
+ }
518
+ const segs = idx.docPath.map((s) => (typeof s === 'number' ? `->${s}` : `->${encodePowqlString(s)}`)).join('');
519
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${column}${segs})`);
520
+ }
521
+ else {
522
+ // Plain column index. PowDB has no composite index (`add index` takes a
523
+ // single `.column`), so a multi-column entry is a typed E017.
524
+ if (idx.columns.length !== 1) {
525
+ throw new UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
526
+ `${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
527
+ }
528
+ const column = idx.columns[0];
529
+ // A unique index whose column already carries a unique constraint (the
530
+ // PK, or a column-level unique) would be a redundant duplicate, so skip it.
531
+ if (idx.unique && emittedUnique.has(column))
532
+ continue;
533
+ stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} .${quotePowqlIdent(column)}`);
534
+ if (idx.unique)
535
+ emittedUnique.add(column);
374
536
  }
375
537
  }
376
538
  }
@@ -380,6 +542,10 @@ export function powqlSchemaDDL(schema) {
380
542
  function toPowdbParam(value, col) {
381
543
  if (value instanceof PowdbFloatParam)
382
544
  return value.value; // wire-side: a float column takes the plain number
545
+ // json document: serialize to canonical JSON text and bind as a str param,
546
+ // the engine validates it as JSON and stores the canonical binary form.
547
+ if (value instanceof PowdbJsonParam)
548
+ return JSON.stringify(value.value);
383
549
  if (value === undefined || value === null)
384
550
  return null;
385
551
  if (value instanceof Date)
@@ -402,10 +568,23 @@ function toPowdbParam(value, col) {
402
568
  */
403
569
  export function coerceValue(raw, col) {
404
570
  const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
571
+ const json = isJsonColumn(col);
405
572
  // NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
406
573
  // literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
407
- if (raw === 'null' && (ts !== 'string' || col.nullable))
574
+ // For a `json` column the bareword `null` (a legacy-wire rendering shared by an
575
+ // absent value AND a top-level JSON-null document, documented residual,
576
+ // resolved on the native transport by the WireValue path) maps to null; a JSON
577
+ // string document "null" renders WITH quotes (`"null"`) and parses distinctly.
578
+ if (raw === 'null' && (json || ts !== 'string' || col.nullable))
408
579
  return null;
580
+ if (json) {
581
+ try {
582
+ return JSON.parse(raw);
583
+ }
584
+ catch {
585
+ return raw; // defensive: canonical JSON text always parses
586
+ }
587
+ }
409
588
  if (ts === 'Date') {
410
589
  const micros = Number(raw);
411
590
  return Number.isFinite(micros) ? new Date(micros / 1000) : null;
@@ -422,19 +601,62 @@ export function coerceValue(raw, col) {
422
601
  return raw; // string / uuid-as-string
423
602
  }
424
603
  /**
425
- * Map one raw PowDB row (snake-cased columns raw wire strings, as produced by
426
- * {@link PowdbPool}) into a typed entity (camelCase fields, coerced values).
427
- * Only the columns present in `raw` are emitted, so partial `select` projections
428
- * round-trip unchanged.
604
+ * Coerce a single cell that arrived over the NATIVE typed wire (decoded from a
605
+ * {@link PowdbWireValue}, so already a JS `bigint`/`number`/`boolean`/`string`/
606
+ * `NativeJson`/`Uint8Array`/`null`, never a bare `"null"` string). Unlike
607
+ * {@link coerceValue} this NEVER collapses the string `"null"` to `null`: an
608
+ * absent value already decoded to `null` (from the `empty` cell), so a genuine
609
+ * str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
610
+ * native transport). `datetime`-shaped cells (int micros) become `Date`; a
611
+ * bigint on a `number` column follows the int8 safe-integer policy.
612
+ */
613
+ export function coerceNativeValue(value, col) {
614
+ if (value === undefined || value === null)
615
+ return null;
616
+ if (isDateColumn(col)) {
617
+ if (typeof value === 'bigint')
618
+ return new Date(Number(value) / 1000);
619
+ if (typeof value === 'number')
620
+ return new Date(value / 1000);
621
+ return value;
622
+ }
623
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
624
+ if (typeof value === 'bigint') {
625
+ if (ts === 'bigint')
626
+ return value;
627
+ if (ts === 'number') {
628
+ const n = Number(value);
629
+ return Number.isSafeInteger(n) ? n : value.toString(); // int8 policy: keep big ints as strings
630
+ }
631
+ return value;
632
+ }
633
+ return value; // number / boolean / string / NativeJson document / Uint8Array
634
+ }
635
+ /**
636
+ * Map one raw PowDB row into a typed entity (camelCase fields, coerced values).
637
+ * Only the columns present in `raw` are emitted, so partial `select`
638
+ * projections round-trip unchanged. `native` selects the coercion policy: the
639
+ * default `false` handles the legacy string wire (every cell is a string, via
640
+ * {@link coerceValue}); `true` handles the native typed wire, where non-string
641
+ * cells arrive pre-typed and go through {@link coerceNativeValue} (see F3).
642
+ * Callers on the native transport pass `this.pool.capabilities.nativeRaw`.
429
643
  */
430
- export function rowToEntity(raw, meta) {
644
+ export function rowToEntity(raw, meta, native = false) {
431
645
  const byName = new Map(meta.columns.map((c) => [c.name, c]));
432
646
  const out = {};
433
647
  for (const snake of Object.keys(raw)) {
434
648
  const col = byName.get(snake);
435
649
  const field = meta.reverseColumnMap[snake] ?? snake;
436
650
  const value = raw[snake];
437
- out[field] = col && typeof value === 'string' ? coerceValue(value, col) : value;
651
+ if (!col) {
652
+ out[field] = value;
653
+ }
654
+ else if (native) {
655
+ out[field] = coerceNativeValue(value, col);
656
+ }
657
+ else {
658
+ out[field] = typeof value === 'string' ? coerceValue(value, col) : value;
659
+ }
438
660
  }
439
661
  return out;
440
662
  }
@@ -460,7 +682,7 @@ export function wrapPowdbError(err) {
460
682
  const e = err;
461
683
  const msg = e.message ?? 'unknown PowDB error';
462
684
  // Unique-constraint — message-based on both transports.
463
- if (/unique constraint violation/i.test(msg)) {
685
+ if (/unique (constraint|expression index) violation/i.test(msg)) {
464
686
  const m = /on\s+\S+\.(\w+)/i.exec(msg);
465
687
  return new UniqueConstraintError({ constraint: m?.[1], cause: err });
466
688
  }
@@ -470,40 +692,123 @@ export function wrapPowdbError(err) {
470
692
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
471
693
  return new NotNullViolationError({ column: m?.[1], cause: err });
472
694
  }
473
- // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
474
- // no .code classify by message so both transports surface E004.
475
- if (/pool closed|pool acquire timeout/i.test(msg)) {
476
- return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
695
+ // Driver pool lifecycle errors (acquire after close, acquire timeout, or a
696
+ // statement reaching an already-closed embedded handle) carry no .code:
697
+ // classify by message so both transports surface E004.
698
+ if (/pool closed|pool acquire timeout|database is closed/i.test(msg)) {
699
+ return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
477
700
  }
478
701
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
479
702
  // connection held the single global write lock past the server's
480
703
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
481
704
  if (/transaction gate timeout/i.test(msg)) {
482
- return new TimeoutError(0, 'PowDB transaction gate');
705
+ return new TimeoutError(0, 'PowDB transaction gate', { cause: err });
706
+ }
707
+ // Stale / violated WIRE state → ConnectionError (E004), NOT a query defect.
708
+ // A `protocol_error`-class failure means the socket's framing state is gone
709
+ // (the client cannot safely reuse it and the pool must destroy it). The
710
+ // canonical trigger is the "received unexpected frame from server" that a
711
+ // fresh request hits after a multi-minute idle gap; sibling shapes are an
712
+ // unknown message type, a truncated payload, or bad framing. Runs BEFORE the
713
+ // validation regex below, whose `unexpected` token would otherwise misclass
714
+ // "received unexpected frame" as an E003 query defect. `.cause` preserved so
715
+ // callers (and the opt-in stale-read retry) can inspect the driver code.
716
+ if (e.code === 'protocol_error' ||
717
+ /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
718
+ return new ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
483
719
  }
484
- // Type mismatch / parse / execution / storage / unexpected → validation
485
- // (E003). On the embedded transport these are the only signal we get
486
- // (code is always 'GenericFailure'); on the networked path they are a
487
- // safety net before the .code switch.
720
+ // Read-only refusal ReadOnlyError (E018). Two engine shapes, both mapped by
721
+ // substring (the networked transport prefixes the message with `query failed:
722
+ // `, so never anchor on the start): an embedded database opened read-only for
723
+ // snapshot serving (`readonly mode: statement requires a writer …`), and a
724
+ // networked read-only role (`permission denied: role '<role>' cannot execute
725
+ // write statements`). These run BEFORE the generic validation regex below so a
726
+ // read-only write is surfaced as the routing signal E018, not a query defect.
727
+ // The driver spec (0.15) distinguishes them via `reason`: snapshot mode
728
+ // means "nothing can write here; route writes to the primary", RBAC means
729
+ // "this connection's role may not write here".
730
+ if (/readonly mode: statement requires a writer/i.test(msg)) {
731
+ return new ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
732
+ cause: err,
733
+ reason: 'snapshot',
734
+ });
735
+ }
736
+ if (/permission denied: role/i.test(msg)) {
737
+ return new ReadOnlyError(`PowDB refused a write for a read-only role: ${msg}.`, { cause: err, reason: 'rbac' });
738
+ }
739
+ // Open-time read-only failure: a read-only handle over a directory whose WAL
740
+ // still has uncommitted frames is refused (`cannot open read-only: the WAL is
741
+ // not empty …`). It is a connection failure (E004), not a query defect, the
742
+ // fix is to recover the directory with a writable open first.
743
+ if (/cannot open read-only: the WAL is not empty/i.test(msg)) {
744
+ return new ConnectionError(`[turbine] PowDB could not open the directory read-only: ${msg}. Open it once with a writable handle to ` +
745
+ 'flush the WAL (recover the directory), then reopen it read-only for snapshot serving.', { cause: err });
746
+ }
747
+ // Per-query deadline → TimeoutError (E002). Message-path so it fires on the
748
+ // embedded transport too (code is always 'GenericFailure' there); retryable.
749
+ // Pass the engine prose through the message override (same pattern as the
750
+ // transaction-gate timeout below) so the real "query timeout after <n>ms"
751
+ // survives instead of rendering the placeholder "timed out after 0ms".
752
+ if (/query timeout after/i.test(msg)) {
753
+ return new TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
754
+ }
755
+ // Client-initiated cancellation → ConnectionError (E004). This is FINAL: the
756
+ // issuing client disconnected, so the query was a clean early return, never
757
+ // auto-retry it (the opt-in stale-read retry only replays stale-FRAME reads).
758
+ if (/query cancelled by client disconnect/i.test(msg)) {
759
+ return new ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
760
+ }
761
+ // Bounded join rejection → ValidationError (E003). The engine rejects a pure
762
+ // nested-loop join whose candidate-pair count (or result row count) exceeds
763
+ // the safety bound BEFORE executing, and names the fix in the message, keep
764
+ // that fix-hint intact so the caller knows how to make the join eligible.
765
+ if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
766
+ return new ValidationError(`[turbine] PowDB join rejected: ${msg}`);
767
+ }
768
+ // Type mismatch / parse / execution / storage / unexpected(token) / row too
769
+ // large → validation (E003). On the embedded transport these are the only
770
+ // signal we get (code is always 'GenericFailure'); on the networked path they
771
+ // are a safety net before the .code switch.
488
772
  if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
489
773
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
490
774
  }
491
775
  switch (e.code) {
492
776
  case 'connect_failed':
493
777
  case 'closed':
494
- return new ConnectionError(`[turbine] PowDB connection failed: ${msg}`);
778
+ return new ConnectionError(`[turbine] PowDB connection failed: ${msg}`, { cause: err });
779
+ case 'auth_failed':
780
+ // Connection-establishment class, non-retryable: the handshake was
781
+ // rejected. Surface E004 with a concrete remediation hint instead of
782
+ // letting it fall through to the raw error.
783
+ return new ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
495
784
  case 'timeout':
496
785
  case 'aborted':
497
- return new TimeoutError(0, 'PowDB query');
786
+ return new TimeoutError(0, 'PowDB query', { cause: err });
498
787
  case 'query_failed':
499
788
  case 'type_coercion_failed':
500
- case 'protocol_error':
501
789
  case 'size_exceeded':
502
790
  return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
503
791
  default:
504
- return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}`);
792
+ return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}`, { cause: err });
505
793
  }
506
794
  }
795
+ /**
796
+ * True when `err` is the stale-wire-frame {@link ConnectionError} produced by
797
+ * {@link wrapPowdbError} (its `.cause` is a `protocol_error` PowDBError, or the
798
+ * message carries the invalid-state signature). The opt-in read retry
799
+ * (`retryStaleReads`, evaluated in {@link PowqlInterface}'s exec seam) uses this
800
+ * to decide whether a first-statement READ may be replayed once on a fresh
801
+ * connection; writes are NEVER retried (an ambiguous mutation reply is unsafe
802
+ * to replay, matching the client's own native-path policy).
803
+ */
804
+ export function isStaleFramePowdbError(err) {
805
+ if (!(err instanceof ConnectionError))
806
+ return false;
807
+ const cause = err.cause;
808
+ if (cause && typeof cause === 'object' && cause.code === 'protocol_error')
809
+ return true;
810
+ return /PowDB connection is in an invalid state/.test(err.message);
811
+ }
507
812
  function normalizeQueryArgs(arg, values) {
508
813
  if (typeof arg === 'string')
509
814
  return { text: arg, params: values ?? [] };
@@ -682,7 +987,7 @@ class PowdbTxGate {
682
987
  return hold;
683
988
  }
684
989
  }
685
- /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
990
+ /** Adapt a PowDB (legacy string wire) result into the pg-compat `{ rows, rowCount, fields }` shape. */
686
991
  function adaptResult(r) {
687
992
  switch (r.kind) {
688
993
  case 'rows': {
@@ -693,21 +998,86 @@ function adaptResult(r) {
693
998
  });
694
999
  return o;
695
1000
  });
696
- return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
1001
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: false };
697
1002
  }
698
1003
  case 'ok':
699
- return { rows: [], rowCount: Number(r.affected), fields: [] };
1004
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
700
1005
  case 'scalar':
701
- return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
1006
+ return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }], native: false };
702
1007
  default:
703
- return { rows: [], rowCount: 0, fields: [] };
1008
+ return { rows: [], rowCount: 0, fields: [], native: false };
1009
+ }
1010
+ }
1011
+ /** Format 16 raw UUID bytes as a canonical `8-4-4-4-12` hex string. */
1012
+ function uuidBytesToHex(bytes) {
1013
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1014
+ if (hex.length !== 32)
1015
+ return hex; // defensive: non-16B payloads pass through as raw hex
1016
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1017
+ }
1018
+ /**
1019
+ * Decode one native {@link PowdbWireValue} cell into a JS value. `empty` →
1020
+ * `null` (an unset value; for a json column this cleanly distinguishes absent
1021
+ * from a JSON-null document, which arrives as `{ type: 'json', value: null }`).
1022
+ * `int`/`datetime` stay `bigint` so the row layer ({@link coerceNativeValue})
1023
+ * applies the int8 policy / Date conversion by column; `uuid` becomes canonical
1024
+ * hex; `bytes` stay `Uint8Array`; `json` passes the decoded document through
1025
+ * with no re-parse (its `pj1` raw bytes are dropped).
1026
+ */
1027
+ function decodeWireValue(cell) {
1028
+ switch (cell.type) {
1029
+ case 'empty':
1030
+ return null;
1031
+ case 'int':
1032
+ case 'datetime':
1033
+ return cell.value; // bigint; row layer decides Date vs number vs bigint per column
1034
+ case 'float':
1035
+ case 'bool':
1036
+ case 'str':
1037
+ return cell.value;
1038
+ case 'uuid':
1039
+ return uuidBytesToHex(cell.value);
1040
+ case 'bytes':
1041
+ return cell.value; // Uint8Array
1042
+ case 'json':
1043
+ return cell.value; // NativeJson document, already recursive data
1044
+ }
1045
+ }
1046
+ /** Adapt a native (typed-wire) PowDB result into the pg-compat shape, decoding every {@link PowdbWireValue} cell. */
1047
+ function adaptNativeResult(r) {
1048
+ switch (r.kind) {
1049
+ case 'rows': {
1050
+ const rows = r.rows.map((row) => {
1051
+ const o = {};
1052
+ r.columns.forEach((c, i) => {
1053
+ o[c] = decodeWireValue(row[i] ?? { type: 'empty' });
1054
+ });
1055
+ return o;
1056
+ });
1057
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: true };
1058
+ }
1059
+ case 'ok':
1060
+ return { rows: [], rowCount: Number(r.affected), fields: [], native: true };
1061
+ case 'scalar':
1062
+ return {
1063
+ rows: [{ value: decodeWireValue(r.value) }],
1064
+ rowCount: 1,
1065
+ fields: [{ name: 'value', dataTypeID: 0 }],
1066
+ native: true,
1067
+ };
1068
+ default:
1069
+ return { rows: [], rowCount: 0, fields: [], native: true };
704
1070
  }
705
1071
  }
706
1072
  /**
707
1073
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
708
- * `text` is **PowQL**, not SQL {@link PowqlInterface} generates it. Rows come
709
- * back as raw strings here; per-column JS coercion happens in `PowqlInterface`
710
- * (it owns the schema metadata).
1074
+ * `text` is **PowQL**, not SQL ({@link PowqlInterface} generates it). On the
1075
+ * legacy string wire cells come back as strings; when `capabilities.nativeRaw`
1076
+ * is set (server 0.13 + a client exposing `queryNativeRaw`) this pool routes
1077
+ * through the typed native wire instead, so cells arrive pre-typed (a json int
1078
+ * as `bigint`, etc.) and each result is tagged with the wire that served it
1079
+ * ({@link PowdbTaggedResult}). Per-column JS coercion still happens in
1080
+ * `PowqlInterface` (it owns the schema metadata), keyed on that per-result tag.
711
1081
  */
712
1082
  export class PowdbPool {
713
1083
  pool;
@@ -731,10 +1101,37 @@ export class PowdbPool {
731
1101
  * live socket holding the process open until the server's idle timeout.
732
1102
  */
733
1103
  checkedOut = new Set();
1104
+ /** Feature capabilities of the bound server (probed version + native-wire feature-detect). */
1105
+ capabilities;
1106
+ /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1107
+ retryStaleReads;
1108
+ /**
1109
+ * True when the caller marked this pool read-only (`readonly: true`). Read by
1110
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1111
+ * wire; the engine's own read-only-role refusal (mapped by
1112
+ * {@link wrapPowdbError}) is the backstop for raw / injected paths.
1113
+ */
1114
+ readonly;
734
1115
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
735
1116
  this.pool = pool;
736
1117
  this.toParam = toParam;
737
1118
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1119
+ this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1120
+ this.retryStaleReads = options.retryStaleReads ?? false;
1121
+ this.readonly = options.readonly ?? false;
1122
+ }
1123
+ /**
1124
+ * Run one statement on `c`, choosing the lossless native typed wire when the
1125
+ * server supports it (`capabilities.nativeRaw`) AND this client exposes
1126
+ * `queryNativeRaw` (a defensive per-call feature-detect, so a heterogeneous
1127
+ * injected pool cannot crash). Otherwise the legacy string wire, unchanged.
1128
+ */
1129
+ async runOnClient(c, powql, params) {
1130
+ const bound = params.map(this.toParam);
1131
+ if (this.capabilities.nativeRaw && typeof c.queryNativeRaw === 'function') {
1132
+ return adaptNativeResult(await c.queryNativeRaw(powql, bound));
1133
+ }
1134
+ return adaptResult(await c.query(powql, bound));
738
1135
  }
739
1136
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
740
1137
  async query(text, values) {
@@ -755,8 +1152,7 @@ export class PowdbPool {
755
1152
  return { rows: [], rowCount: 0, fields: [] };
756
1153
  }
757
1154
  try {
758
- const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
759
- return adaptResult(result);
1155
+ return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
760
1156
  }
761
1157
  catch (err) {
762
1158
  if (ctl === 'begin') {
@@ -827,7 +1223,7 @@ export class PowdbPool {
827
1223
  return { rows: [], rowCount: 0, fields: [] };
828
1224
  }
829
1225
  try {
830
- return adaptResult(await client.query(powql, params.map(this.toParam)));
1226
+ return await this.runOnClient(client, powql, params);
831
1227
  }
832
1228
  catch (err) {
833
1229
  broken = true;
@@ -953,6 +1349,10 @@ export function encodePowqlLiteral(value) {
953
1349
  // Force a float-form literal so an integer-valued float column stays a float.
954
1350
  return Number.isInteger(n) ? `${n}.0` : String(n);
955
1351
  }
1352
+ // json document: emit the canonical JSON text as a PowQL string literal (the
1353
+ // embedded engine validates and stores it as a json document).
1354
+ if (value instanceof PowdbJsonParam)
1355
+ return encodePowqlString(JSON.stringify(value.value));
956
1356
  if (value === undefined || value === null)
957
1357
  return 'null';
958
1358
  if (value instanceof Date)
@@ -1008,10 +1408,14 @@ export function materializePowql(powql, params) {
1008
1408
  }
1009
1409
  /**
1010
1410
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
1011
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
1012
- * accepts only a string — so this pool materializes each positional `$N` into a
1013
- * PowQL literal via {@link materializePowql} before handing the text to the
1014
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
1411
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
1412
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
1413
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
1414
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
1415
+ * legacy string wire, which takes **no params array** (its `query(powql)`
1416
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
1417
+ * literal via {@link materializePowql} before the text is handed to the engine.
1418
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
1015
1419
  * `rollback`) are issued serially as ordinary queries.
1016
1420
  */
1017
1421
  export class PowdbEmbeddedPool {
@@ -1030,12 +1434,46 @@ export class PowdbEmbeddedPool {
1030
1434
  txGate;
1031
1435
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
1032
1436
  poolHoldRef = { hold: null };
1437
+ /**
1438
+ * Feature capabilities of the embedded engine (resolved from the addon
1439
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
1440
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
1441
+ * addon has no such method, so it stays false and the legacy string wire is
1442
+ * used.
1443
+ */
1444
+ capabilities;
1445
+ /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1446
+ retryStaleReads;
1447
+ /**
1448
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
1449
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
1450
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1451
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
1452
+ * backstop for raw / injected paths.
1453
+ */
1454
+ readonly;
1033
1455
  constructor(db, options = {}) {
1034
1456
  this.db = db;
1035
1457
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1458
+ this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1459
+ this.retryStaleReads = options.retryStaleReads ?? false;
1460
+ this.readonly = options.readonly ?? false;
1036
1461
  }
1037
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1462
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
1038
1463
  exec(powql, params) {
1464
+ // Native typed wire (addon ≥ 0.14): bind positional params with the SAME
1465
+ // binder the networked transport uses ({@link toPowdbParam} yields exactly
1466
+ // the NativeParam union null|bigint|number|boolean|string) and decode the
1467
+ // typed cells: a genuine str "null" survives, a json-null document stays
1468
+ // distinct from an absent value. Gated on the resolved capability AND a
1469
+ // per-call feature-detect so a heterogeneous injected handle cannot crash.
1470
+ if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
1471
+ const bound = params.map((v) => toPowdbParam(v));
1472
+ return adaptNativeResult(this.db.queryWithParams(powql, bound));
1473
+ }
1474
+ // Legacy string wire (addon < 0.14): the engine takes no params array, so
1475
+ // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1476
+ // live and tested as the pre-0.14 fallback.
1039
1477
  const materialized = materializePowql(powql, params);
1040
1478
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1041
1479
  }
@@ -1055,6 +1493,15 @@ export class PowdbEmbeddedPool {
1055
1493
  // transaction callback throws re-entrant E017 fast; independent
1056
1494
  // concurrent ones wait their FIFO turn.
1057
1495
  holdRef.hold = await this.txGate.acquire();
1496
+ // The gate may have handed us the slot AFTER disconnect() closed the
1497
+ // handle (a transaction queued behind an in-flight one, released as the
1498
+ // pool shut down). Re-check before touching the now-closed engine, and
1499
+ // release the slot we just took so the queue keeps draining.
1500
+ if (this.closed) {
1501
+ holdRef.hold.finish();
1502
+ holdRef.hold = null;
1503
+ throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1504
+ }
1058
1505
  }
1059
1506
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1060
1507
  // This context never acquired the gate — its `begin` never ran (the
@@ -1130,18 +1577,22 @@ export class PowdbEmbeddedPool {
1130
1577
  async end() {
1131
1578
  if (this.closed)
1132
1579
  return;
1133
- // The addon exposes no explicit close — drop the reference and let GC /
1134
- // the engine's checkpoint flush. Marking the pool closed makes later
1135
- // queries fail with a typed ConnectionError instead of silently running
1136
- // against a handle the caller believes is gone. Caveat: durability is
1137
- // checkpoint-bound, so hold the process open long enough for the final
1138
- // WAL flush in short scripts.
1139
1580
  this.closed = true;
1581
+ // Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
1582
+ // the final WAL flush completes deterministically before the handle is
1583
+ // dropped. An older addon has no close, dropping the reference and letting
1584
+ // GC / the engine's checkpoint flush is the fallback (durability is then
1585
+ // checkpoint-bound, so a short script must hold the process open long enough
1586
+ // for the final flush). Marking the pool closed makes later queries fail
1587
+ // with a typed ConnectionError instead of running against a gone handle.
1588
+ this.db.close?.();
1140
1589
  }
1141
1590
  }
1142
1591
  // ---------------------------------------------------------------------------
1143
1592
  // PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
1144
1593
  // ---------------------------------------------------------------------------
1594
+ // `describe`-based introspection (programmatic API; see powdb-introspect.ts).
1595
+ export { introspectPowdbDatabase, } from './powdb-introspect.js';
1145
1596
  export { PowqlInterface } from './powql.js';
1146
1597
  /**
1147
1598
  * Dynamically load `@zvndev/powdb-client`. Kept out of the static import graph so
@@ -1188,13 +1639,47 @@ async function loadPowdbEmbedded() {
1188
1639
  }
1189
1640
  return mod;
1190
1641
  }
1642
+ /**
1643
+ * Resolve the embedded addon's engine version. The addon vendors the engine and
1644
+ * exports no version, but `@zvndev/powdb-embedded/package.json` has no `exports`
1645
+ * map, so a bare `require` of it resolves: the package version IS the engine
1646
+ * version. Delegated to the `.cts` optional-peer helper so the resolution uses a
1647
+ * real CommonJS `require` in BOTH build outputs; `import.meta.url` here would
1648
+ * fail `tsc` under `tsconfig.cjs.json` (module: CommonJS) and crash any CJS
1649
+ * consumer of `turbine-orm/powdb`. Returns `null` when it cannot be resolved.
1650
+ */
1651
+ function resolveEmbeddedVersion() {
1652
+ return importOptionalPeer.peerPackageVersion('@zvndev/powdb-embedded');
1653
+ }
1191
1654
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1192
- async function openEmbeddedPool(target, poolOptions = {}) {
1193
- const mod = await loadPowdbEmbedded();
1194
- const { embedded: dir, syncMode, memoryLimit } = target;
1655
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
1656
+ const mod = injectedModule ?? (await loadPowdbEmbedded());
1657
+ const { embedded: dir, syncMode, memoryLimit, readonly } = target;
1658
+ // A read-only engine never writes, so a durability selector is meaningless
1659
+ // there, reject the combination loudly rather than silently ignoring one.
1660
+ if (readonly && syncMode !== undefined) {
1661
+ throw new ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
1662
+ }
1195
1663
  let db;
1196
1664
  try {
1197
- if (memoryLimit !== undefined) {
1665
+ if (readonly) {
1666
+ // Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
1667
+ // constructors; feature-detect and fail with a clear version hint if the
1668
+ // installed addon predates them.
1669
+ if (memoryLimit !== undefined) {
1670
+ if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
1671
+ throw new ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
1672
+ }
1673
+ db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
1674
+ }
1675
+ else {
1676
+ if (typeof mod.Database.openReadOnly !== 'function') {
1677
+ throw new ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
1678
+ }
1679
+ db = mod.Database.openReadOnly(dir);
1680
+ }
1681
+ }
1682
+ else if (memoryLimit !== undefined) {
1198
1683
  if (typeof mod.Database.openWithMemoryLimit !== 'function') {
1199
1684
  throw new ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
1200
1685
  }
@@ -1215,7 +1700,19 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1215
1700
  }
1216
1701
  db.setSyncMode(syncMode);
1217
1702
  }
1218
- return new PowdbEmbeddedPool(db, poolOptions);
1703
+ // Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
1704
+ // exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
1705
+ // applies via the version); an older addon has no such method → false.
1706
+ const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1707
+ hasNativeRaw: typeof db.queryWithParams === 'function',
1708
+ });
1709
+ // A read-only target forces the pool's readonly flag; otherwise honor whatever
1710
+ // `poolOptions` (threaded from `options.readonly`) carried.
1711
+ return new PowdbEmbeddedPool(db, {
1712
+ ...poolOptions,
1713
+ capabilities,
1714
+ readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
1715
+ });
1219
1716
  }
1220
1717
  /**
1221
1718
  * Bind Turbine to PowDB. `target` is one of:
@@ -1238,30 +1735,39 @@ async function openEmbeddedPool(target, poolOptions = {}) {
1238
1735
  export async function turbinePowDB(target, schema, options = {}) {
1239
1736
  let pool;
1240
1737
  let owns = false;
1241
- const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1738
+ const poolOptions = {
1739
+ transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1740
+ retryStaleReads: options.retryStaleReads,
1741
+ readonly: options.readonly,
1742
+ };
1743
+ const max = options.connectionLimit ?? 10;
1242
1744
  if (typeof target === 'string') {
1243
1745
  const mod = options.powdbClientModule ?? (await loadPowdb());
1244
- const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
1245
- await assertNetworkedVersion(clientPool);
1246
- pool = new PowdbPool(clientPool, undefined, poolOptions);
1746
+ const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max });
1747
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1748
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1247
1749
  owns = true;
1248
1750
  }
1249
1751
  else if (target instanceof PowdbPool) {
1250
- // An injected PowdbPool carries its own PowdbPoolOptions.
1752
+ // An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
1251
1753
  pool = target;
1252
1754
  }
1253
1755
  else if (isEmbeddedTarget(target)) {
1254
- pool = await openEmbeddedPool(target, poolOptions);
1756
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
1255
1757
  owns = true;
1256
1758
  }
1257
1759
  else if (isPowdbClientPool(target)) {
1258
- pool = new PowdbPool(target, undefined, poolOptions);
1760
+ // Injected client pool: run the SAME probe as the URL / host+port paths so
1761
+ // it gets real capabilities AND the version-floor check (this branch used
1762
+ // to skip the probe entirely (an injected pool silently bypassed both).
1763
+ const capabilities = await assertNetworkedVersion(target, options.assumeEngineVersion);
1764
+ pool = new PowdbPool(target, undefined, { ...poolOptions, capabilities });
1259
1765
  }
1260
1766
  else {
1261
1767
  const mod = options.powdbClientModule ?? (await loadPowdb());
1262
- const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
1263
- await assertNetworkedVersion(clientPool);
1264
- pool = new PowdbPool(clientPool, undefined, poolOptions);
1768
+ const clientPool = new mod.Pool({ ...target, max });
1769
+ const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
1770
+ pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
1265
1771
  owns = true;
1266
1772
  }
1267
1773
  // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
@@ -1274,6 +1780,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1274
1780
  logging: options.logging,
1275
1781
  defaultLimit: options.defaultLimit,
1276
1782
  warnOnUnlimited: options.warnOnUnlimited,
1783
+ relationLoadStrategy: options.relationLoadStrategy,
1277
1784
  queryInterfaceFactory,
1278
1785
  }, schema);
1279
1786
  if (owns) {
@@ -1299,14 +1806,20 @@ export async function turbinePowDB(target, schema, options = {}) {
1299
1806
  return client;
1300
1807
  }
1301
1808
  /**
1302
- * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}) and
1303
- * fail fast if the server is older than {@link MIN_POWDB_VERSION}. Best-effort:
1304
- * a driver that does not surface a version is left untouched (we cannot prove it
1305
- * too old). Errors from the probe itself surface as the normal connect failure.
1809
+ * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}),
1810
+ * fail fast if the server is older than {@link MIN_POWDB_VERSION}, and derive
1811
+ * the {@link PowdbCapabilities} for it: the version gates PLUS `nativeRaw`
1812
+ * (server ≥ 0.13 AND the client exposes `queryNativeRaw`, feature-detected
1813
+ * here). `assumeEngineVersion` overrides the version used for capability
1814
+ * derivation (the floor check still runs against the real reported version).
1815
+ * Errors from the probe itself surface as the normal connect failure.
1306
1816
  */
1307
- async function assertNetworkedVersion(clientPool) {
1308
- await clientPool.withClient(async (c) => {
1817
+ async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
1818
+ return clientPool.withClient(async (c) => {
1309
1819
  assertSupportedPowdbVersion(c.serverVersion);
1820
+ const version = assumeEngineVersion ?? c.serverVersion;
1821
+ const hasNativeRaw = typeof c.queryNativeRaw === 'function';
1822
+ return capabilitiesFromVersion(version, { hasNativeRaw });
1310
1823
  });
1311
1824
  }
1312
1825
  function isPowdbClientPool(x) {