turbine-orm 0.34.0 → 0.36.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 (76) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/client.js +26 -4
  8. package/dist/cjs/dialect.js +2 -1
  9. package/dist/cjs/errors.js +41 -1
  10. package/dist/cjs/generate.js +23 -2
  11. package/dist/cjs/index.js +4 -2
  12. package/dist/cjs/mssql.js +27 -5
  13. package/dist/cjs/mysql.js +4 -0
  14. package/dist/cjs/powdb.js +197 -25
  15. package/dist/cjs/powql.js +515 -51
  16. package/dist/cjs/query/aggregates.js +683 -0
  17. package/dist/cjs/query/batched-loader.js +2 -0
  18. package/dist/cjs/query/builder.js +361 -4508
  19. package/dist/cjs/query/filters.js +12 -0
  20. package/dist/cjs/query/relations.js +1698 -0
  21. package/dist/cjs/query/where-compile.js +180 -0
  22. package/dist/cjs/query/where.js +1491 -0
  23. package/dist/cjs/query/writes.js +680 -0
  24. package/dist/cjs/schema-builder.js +6 -0
  25. package/dist/cjs/schema-metadata.js +4 -0
  26. package/dist/cjs/schema-sql.js +265 -3
  27. package/dist/cjs/sqlite.js +4 -1
  28. package/dist/cli/index.d.ts +8 -2
  29. package/dist/cli/index.js +111 -18
  30. package/dist/cli/migrate.d.ts +24 -1
  31. package/dist/cli/migrate.js +77 -3
  32. package/dist/cli/studio-ui.generated.js +1 -1
  33. package/dist/cli/studio.d.ts +46 -13
  34. package/dist/cli/studio.js +331 -23
  35. package/dist/cli/ui.js +7 -1
  36. package/dist/client.d.ts +32 -5
  37. package/dist/client.js +26 -4
  38. package/dist/dialect.d.ts +28 -6
  39. package/dist/dialect.js +2 -1
  40. package/dist/errors.d.ts +36 -0
  41. package/dist/errors.js +39 -0
  42. package/dist/generate.js +23 -2
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.js +2 -2
  45. package/dist/mssql.js +27 -5
  46. package/dist/mysql.js +4 -0
  47. package/dist/powdb.d.ts +135 -9
  48. package/dist/powdb.js +197 -25
  49. package/dist/powql.d.ts +166 -4
  50. package/dist/powql.js +516 -52
  51. package/dist/query/aggregates.d.ts +74 -0
  52. package/dist/query/aggregates.js +641 -0
  53. package/dist/query/batched-loader.d.ts +6 -0
  54. package/dist/query/batched-loader.js +2 -0
  55. package/dist/query/builder.d.ts +98 -830
  56. package/dist/query/builder.js +366 -4513
  57. package/dist/query/deferred.d.ts +13 -2
  58. package/dist/query/filters.d.ts +7 -0
  59. package/dist/query/filters.js +11 -0
  60. package/dist/query/relations.d.ts +441 -0
  61. package/dist/query/relations.js +1627 -0
  62. package/dist/query/types.d.ts +25 -6
  63. package/dist/query/where-compile.d.ts +139 -0
  64. package/dist/query/where-compile.js +175 -0
  65. package/dist/query/where.d.ts +494 -0
  66. package/dist/query/where.js +1431 -0
  67. package/dist/query/writes.d.ts +131 -0
  68. package/dist/query/writes.js +626 -0
  69. package/dist/schema-builder.d.ts +18 -3
  70. package/dist/schema-builder.js +6 -0
  71. package/dist/schema-metadata.js +4 -0
  72. package/dist/schema-sql.d.ts +60 -3
  73. package/dist/schema-sql.js +261 -4
  74. package/dist/schema.d.ts +10 -0
  75. package/dist/sqlite.js +4 -1
  76. package/package.json +4 -4
package/dist/powdb.d.ts CHANGED
@@ -252,11 +252,13 @@ export interface PowdbCapabilities {
252
252
  docFieldIndexes: boolean;
253
253
  /** ≥ 0.10: `schema` / `describe` introspection statements. */
254
254
  introspection: boolean;
255
+ /** ≥ 0.13: server-side joins, hash-accelerated and bounded. */
256
+ serverJoins: boolean;
255
257
  /** Networked only: server ≥ 0.13 AND the client exposes `queryNativeRaw`. */
256
258
  nativeRaw: boolean;
257
259
  }
258
260
  /** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
259
- type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection';
261
+ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins';
260
262
  /**
261
263
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
262
264
  * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
@@ -439,6 +441,15 @@ export interface PowdbPoolOptions {
439
441
  * Default `false` (typed-error-only).
440
442
  */
441
443
  retryStaleReads?: boolean;
444
+ /**
445
+ * Mark this pool read-only: {@link PowqlInterface}'s exec seam then fails a
446
+ * write (or a tx-control `begin`) fast with a {@link ReadOnlyError} (E018)
447
+ * before it reaches the wire. An `{ embedded, readonly: true }` target forces
448
+ * this true; a networked pool bound to a read-only role can also set it so
449
+ * writes are rejected locally instead of round-tripping to the engine's
450
+ * refusal. Default `false`.
451
+ */
452
+ readonly?: boolean;
442
453
  }
443
454
  /**
444
455
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
@@ -476,6 +487,13 @@ export declare class PowdbPool implements PgCompatPool {
476
487
  readonly capabilities: PowdbCapabilities;
477
488
  /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
478
489
  readonly retryStaleReads: boolean;
490
+ /**
491
+ * True when the caller marked this pool read-only (`readonly: true`). Read by
492
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
493
+ * wire; the engine's own read-only-role refusal (mapped by
494
+ * {@link wrapPowdbError}) is the backstop for raw / injected paths.
495
+ */
496
+ readonly readonly: boolean;
479
497
  constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam, options?: PowdbPoolOptions);
480
498
  /**
481
499
  * Run one statement on `c`, choosing the lossless native typed wire when the
@@ -503,6 +521,14 @@ interface EmbeddedQueryResult {
503
521
  affected?: bigint;
504
522
  message?: string;
505
523
  }
524
+ /**
525
+ * The embedded addon's native typed result (`@zvndev/powdb-embedded` ≥ 0.14).
526
+ * Mirrors {@link PowdbRawNativeResult}, the same tagged {@link PowdbWireValue}
527
+ * cells (embedded `bytes` arrive as a `Buffer`, which IS a `Uint8Array`, so the
528
+ * decode path is unchanged), including the `message` kind for DDL / status
529
+ * replies, which {@link adaptNativeResult}'s default branch handles at runtime.
530
+ */
531
+ type EmbeddedNativeResult = PowdbRawNativeResult;
506
532
  /** A single in-process embedded database handle (`@zvndev/powdb-embedded`). */
507
533
  interface EmbeddedDatabase {
508
534
  query(powql: string): EmbeddedQueryResult;
@@ -511,6 +537,32 @@ interface EmbeddedDatabase {
511
537
  isPoisoned(): boolean;
512
538
  /** WAL durability selector — `@zvndev/powdb-embedded` ≥ 0.7.1. */
513
539
  setSyncMode?(mode: string): void;
540
+ /**
541
+ * Lossless typed native wire (`@zvndev/powdb-embedded` ≥ 0.14). All optional
542
+ * and feature-detected: an older addon omits them, so {@link PowdbEmbeddedPool}
543
+ * falls back to {@link materializePowql} + {@link query}. `queryWithParams`
544
+ * binds positional `$N` params as {@link PowdbParam} values (the NativeParam
545
+ * union) instead of materializing literals.
546
+ */
547
+ queryNative?(powql: string): EmbeddedNativeResult;
548
+ queryReadonlyNative?(powql: string): EmbeddedNativeResult;
549
+ queryWithParams?(powql: string, params: PowdbParam[]): EmbeddedNativeResult;
550
+ /** Checkpoint-flushing close (`@zvndev/powdb-embedded` ≥ 0.14). Optional (feature-detected). */
551
+ close?(): void;
552
+ }
553
+ interface EmbeddedModule {
554
+ Database: {
555
+ open(dir: string): EmbeddedDatabase;
556
+ /** Open with a per-query memory budget — `@zvndev/powdb-embedded` ≥ 0.7.1. */
557
+ openWithMemoryLimit?(dir: string, limitBytes: number): EmbeddedDatabase;
558
+ /**
559
+ * Open a read-only handle for snapshot serving (`@zvndev/powdb-embedded` ≥
560
+ * 0.14). Optional (feature-detected); a write through such a handle is
561
+ * refused with `readonly mode: statement requires a writer …` (→ E018).
562
+ */
563
+ openReadOnly?(dir: string): EmbeddedDatabase;
564
+ openReadOnlyWithMemoryLimit?(dir: string, limitBytes: number): EmbeddedDatabase;
565
+ };
514
566
  }
515
567
  /**
516
568
  * Encode a JS value as a **PowQL literal** for the embedded driver, which takes
@@ -528,6 +580,26 @@ interface EmbeddedDatabase {
528
580
  * as data and cannot break out of the literal or inject a second statement.
529
581
  */
530
582
  export declare function encodePowqlLiteral(value: unknown): string;
583
+ /**
584
+ * The newest PowDB engine LINE (major.minor) whose lexer escape handling
585
+ * {@link encodePowqlString} is VERIFIED against by reading
586
+ * `crates/query/src/lexer.rs`. The legacy materialize path
587
+ * ({@link materializePowql}) inlines encoded string literals directly into query
588
+ * text, so it is only injection-safe while the lexer recognizes exactly the
589
+ * escape set the escaper emits (`\"`, `\\`, `\n`, `\t`, everything else raw). If
590
+ * a future engine line teaches the lexer new escapes (e.g. `\u`, `\x`), a string
591
+ * that the escaper leaves raw could be re-interpreted by the lexer and break out
592
+ * of the literal, turning the fallback into an injection primitive.
593
+ *
594
+ * CONTRACT: bump this ceiling ONLY after re-verifying the escape handling in
595
+ * `crates/query/src/lexer.rs` for the newer line AND confirming
596
+ * {@link encodePowqlString} still escapes every breakout vector the lexer
597
+ * recognizes. The legacy path guards on this value (see
598
+ * {@link PowdbEmbeddedPool.exec}): an embedded addon whose engine line exceeds
599
+ * the ceiling yet still routes through the string wire is refused rather than
600
+ * materialized.
601
+ */
602
+ export declare const POWQL_LEXER_TESTED_CEILING = "0.15";
531
603
  /**
532
604
  * Substitute every `$N` placeholder in a generator-produced PowQL template with
533
605
  * the encoded literal of `params[N-1]`. Safe because the template is produced by
@@ -539,10 +611,14 @@ export declare function encodePowqlLiteral(value: unknown): string;
539
611
  export declare function materializePowql(powql: string, params: unknown[]): string;
540
612
  /**
541
613
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
542
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
543
- * accepts only a string — so this pool materializes each positional `$N` into a
544
- * PowQL literal via {@link materializePowql} before handing the text to the
545
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
614
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
615
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
616
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
617
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
618
+ * legacy string wire, which takes **no params array** (its `query(powql)`
619
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
620
+ * literal via {@link materializePowql} before the text is handed to the engine.
621
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
546
622
  * `rollback`) are issued serially as ordinary queries.
547
623
  */
548
624
  export declare class PowdbEmbeddedPool implements PgCompatPool {
@@ -563,14 +639,24 @@ export declare class PowdbEmbeddedPool implements PgCompatPool {
563
639
  private readonly poolHoldRef;
564
640
  /**
565
641
  * Feature capabilities of the embedded engine (resolved from the addon
566
- * package version). `nativeRaw` is always false: the embedded addon exposes
567
- * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
642
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
643
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
644
+ * addon has no such method, so it stays false and the legacy string wire is
645
+ * used.
568
646
  */
569
647
  readonly capabilities: PowdbCapabilities;
570
648
  /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
571
649
  readonly retryStaleReads: boolean;
650
+ /**
651
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
652
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
653
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
654
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
655
+ * backstop for raw / injected paths.
656
+ */
657
+ readonly readonly: boolean;
572
658
  constructor(db: EmbeddedDatabase, options?: PowdbPoolOptions);
573
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
659
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
574
660
  private exec;
575
661
  /**
576
662
  * Run one statement, gating transaction control. `holdRef` scopes the gate
@@ -586,7 +672,17 @@ export declare class PowdbEmbeddedPool implements PgCompatPool {
586
672
  export { introspectPowdbDatabase, type PowdbExec, type PowdbIntrospectOptions, } from './powdb-introspect.js';
587
673
  export { PowqlInterface } from './powql.js';
588
674
  /** Options for {@link turbinePowDB}. */
589
- export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited'> {
675
+ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited' | 'relationLoadStrategy'> {
676
+ /**
677
+ * Client-level default `with`-relation load strategy. On PowDB the default is
678
+ * the batched N+1 loaders; setting `'join'` opts INTO native PowQL server-side
679
+ * joins for eligible top-level relations (ineligible ones, e.g. a paged parent
680
+ * or a nested `with`, fall back to the loaders per-relation and silently). A
681
+ * per-query `relationLoadStrategy` arg still overrides this. Requires an engine
682
+ * that advertises `serverJoins` (PowDB ≥ 0.13); a per-query `'join'` on an
683
+ * older engine throws E017, a client-level default silently falls back.
684
+ */
685
+ relationLoadStrategy?: TurbineConfig['relationLoadStrategy'];
590
686
  /** Max pooled connections (default 10). Networked transport only. */
591
687
  connectionLimit?: number;
592
688
  /**
@@ -623,6 +719,18 @@ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'de
623
719
  * E017).
624
720
  */
625
721
  assumeEngineVersion?: string;
722
+ /**
723
+ * Mark the client read-only: a write (or a transaction `begin`) fails fast
724
+ * locally with a {@link ReadOnlyError} (E018) before it reaches the wire,
725
+ * rather than round-tripping to the engine's refusal. Works on both
726
+ * transports (a networked pool bound to a read-only role, or an embedded
727
+ * handle). An `{ embedded, readonly: true }` target implies this. Default
728
+ * `false`.
729
+ *
730
+ * Ignored when you inject an already-constructed {@link PowdbPool} (it carries
731
+ * its own {@link PowdbPoolOptions}); set it on that pool's constructor instead.
732
+ */
733
+ readonly?: boolean;
626
734
  /**
627
735
  * Driver-module injection for the networked target forms (URL / host+port):
628
736
  * bypasses the dynamic `import('@zvndev/powdb-client')` and uses this object
@@ -630,6 +738,14 @@ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'de
630
738
  * connections) and advanced embedding; everyday callers never set it.
631
739
  */
632
740
  powdbClientModule?: PowdbModule;
741
+ /**
742
+ * Driver-module injection for the **embedded** target form (`{ embedded }`):
743
+ * bypasses the dynamic `import('@zvndev/powdb-embedded')` and uses this object
744
+ * as the addon module instead. Intended for tests (a fake `Database` factory
745
+ * that records how the handle was opened) and advanced embedding; everyday
746
+ * callers never set it. The symmetric counterpart to {@link powdbClientModule}.
747
+ */
748
+ powdbEmbeddedModule?: EmbeddedModule;
633
749
  }
634
750
  /**
635
751
  * Selects the **embedded** transport — an in-process `@zvndev/powdb-embedded`
@@ -655,6 +771,16 @@ export interface TurbinePowdbEmbeddedTarget {
655
771
  syncMode?: 'full' | 'normal' | 'off';
656
772
  /** Per-query memory budget in bytes (requires `@zvndev/powdb-embedded` ≥ 0.7.1). */
657
773
  memoryLimit?: number;
774
+ /**
775
+ * Open the data directory read-only for snapshot serving (requires
776
+ * `@zvndev/powdb-embedded` ≥ 0.14: `openReadOnly` / `openReadOnlyWithMemoryLimit`).
777
+ * A write through a read-only handle is refused by the engine with
778
+ * `readonly mode: statement requires a writer …` (→ {@link ReadOnlyError}, E018),
779
+ * and Turbine additionally fails writes fast locally (this implies the pool's
780
+ * `readonly` flag). Meaningless together with `syncMode` (a read-only engine
781
+ * never writes), setting both throws a {@link ValidationError}.
782
+ */
783
+ readonly?: boolean;
658
784
  }
659
785
  /**
660
786
  * Bind Turbine to PowDB. `target` is one of:
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
@@ -195,6 +195,7 @@ const POWDB_FEATURE_MIN_VERSION = {
195
195
  introspection: '0.10',
196
196
  jsonDocs: '0.12',
197
197
  docFieldIndexes: '0.13',
198
+ serverJoins: '0.13',
198
199
  };
199
200
  /**
200
201
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -209,6 +210,7 @@ export const ALL_POWDB_CAPABILITIES = {
209
210
  jsonDocs: true,
210
211
  docFieldIndexes: true,
211
212
  introspection: true,
213
+ serverJoins: true,
212
214
  nativeRaw: false,
213
215
  };
214
216
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -236,6 +238,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
236
238
  jsonDocs: false,
237
239
  docFieldIndexes: false,
238
240
  introspection: false,
241
+ serverJoins: false,
239
242
  nativeRaw: false,
240
243
  };
241
244
  }
@@ -244,6 +247,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
244
247
  introspection: atLeastVersion(sem, 0, 10),
245
248
  jsonDocs: atLeastVersion(sem, 0, 12),
246
249
  docFieldIndexes: atLeastVersion(sem, 0, 13),
250
+ serverJoins: atLeastVersion(sem, 0, 13),
247
251
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
248
252
  };
249
253
  }
@@ -688,9 +692,10 @@ export function wrapPowdbError(err) {
688
692
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
689
693
  return new NotNullViolationError({ column: m?.[1], cause: err });
690
694
  }
691
- // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
692
- // no .code classify by message so both transports surface E004.
693
- if (/pool closed|pool acquire timeout/i.test(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)) {
694
699
  return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
695
700
  }
696
701
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
@@ -712,6 +717,54 @@ export function wrapPowdbError(err) {
712
717
  /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
713
718
  return new ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
714
719
  }
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
+ }
715
768
  // Type mismatch / parse / execution / storage / unexpected(token) / row too
716
769
  // large → validation (E003). On the embedded transport these are the only
717
770
  // signal we get (code is always 'GenericFailure'); on the networked path they
@@ -1052,12 +1105,20 @@ export class PowdbPool {
1052
1105
  capabilities;
1053
1106
  /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1054
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;
1055
1115
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
1056
1116
  this.pool = pool;
1057
1117
  this.toParam = toParam;
1058
1118
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1059
1119
  this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1060
1120
  this.retryStaleReads = options.retryStaleReads ?? false;
1121
+ this.readonly = options.readonly ?? false;
1061
1122
  }
1062
1123
  /**
1063
1124
  * Run one statement on `c`, choosing the lossless native typed wire when the
@@ -1311,6 +1372,26 @@ export function encodePowqlLiteral(value) {
1311
1372
  return encodePowqlString(value);
1312
1373
  throw new ValidationError(`[turbine] Value of type ${typeof value} cannot be encoded as a PowDB literal.`);
1313
1374
  }
1375
+ /**
1376
+ * The newest PowDB engine LINE (major.minor) whose lexer escape handling
1377
+ * {@link encodePowqlString} is VERIFIED against by reading
1378
+ * `crates/query/src/lexer.rs`. The legacy materialize path
1379
+ * ({@link materializePowql}) inlines encoded string literals directly into query
1380
+ * text, so it is only injection-safe while the lexer recognizes exactly the
1381
+ * escape set the escaper emits (`\"`, `\\`, `\n`, `\t`, everything else raw). If
1382
+ * a future engine line teaches the lexer new escapes (e.g. `\u`, `\x`), a string
1383
+ * that the escaper leaves raw could be re-interpreted by the lexer and break out
1384
+ * of the literal, turning the fallback into an injection primitive.
1385
+ *
1386
+ * CONTRACT: bump this ceiling ONLY after re-verifying the escape handling in
1387
+ * `crates/query/src/lexer.rs` for the newer line AND confirming
1388
+ * {@link encodePowqlString} still escapes every breakout vector the lexer
1389
+ * recognizes. The legacy path guards on this value (see
1390
+ * {@link PowdbEmbeddedPool.exec}): an embedded addon whose engine line exceeds
1391
+ * the ceiling yet still routes through the string wire is refused rather than
1392
+ * materialized.
1393
+ */
1394
+ export const POWQL_LEXER_TESTED_CEILING = '0.15';
1314
1395
  /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
1315
1396
  function encodePowqlString(s) {
1316
1397
  let out = '"';
@@ -1347,10 +1428,14 @@ export function materializePowql(powql, params) {
1347
1428
  }
1348
1429
  /**
1349
1430
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
1350
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
1351
- * accepts only a string — so this pool materializes each positional `$N` into a
1352
- * PowQL literal via {@link materializePowql} before handing the text to the
1353
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
1431
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
1432
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
1433
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
1434
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
1435
+ * legacy string wire, which takes **no params array** (its `query(powql)`
1436
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
1437
+ * literal via {@link materializePowql} before the text is handed to the engine.
1438
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
1354
1439
  * `rollback`) are issued serially as ordinary queries.
1355
1440
  */
1356
1441
  export class PowdbEmbeddedPool {
@@ -1371,20 +1456,64 @@ export class PowdbEmbeddedPool {
1371
1456
  poolHoldRef = { hold: null };
1372
1457
  /**
1373
1458
  * Feature capabilities of the embedded engine (resolved from the addon
1374
- * package version). `nativeRaw` is always false: the embedded addon exposes
1375
- * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
1459
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
1460
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
1461
+ * addon has no such method, so it stays false and the legacy string wire is
1462
+ * used.
1376
1463
  */
1377
1464
  capabilities;
1378
1465
  /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1379
1466
  retryStaleReads;
1467
+ /**
1468
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
1469
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
1470
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1471
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
1472
+ * backstop for raw / injected paths.
1473
+ */
1474
+ readonly;
1380
1475
  constructor(db, options = {}) {
1381
1476
  this.db = db;
1382
1477
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1383
1478
  this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1384
1479
  this.retryStaleReads = options.retryStaleReads ?? false;
1480
+ this.readonly = options.readonly ?? false;
1385
1481
  }
1386
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1482
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
1387
1483
  exec(powql, params) {
1484
+ // Native typed wire (addon ≥ 0.14): bind positional params with the SAME
1485
+ // binder the networked transport uses ({@link toPowdbParam} yields exactly
1486
+ // the NativeParam union null|bigint|number|boolean|string) and decode the
1487
+ // typed cells: a genuine str "null" survives, a json-null document stays
1488
+ // distinct from an absent value. Gated on the resolved capability AND a
1489
+ // per-call feature-detect so a heterogeneous injected handle cannot crash.
1490
+ if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
1491
+ const bound = params.map((v) => toPowdbParam(v));
1492
+ return adaptNativeResult(this.db.queryWithParams(powql, bound));
1493
+ }
1494
+ // Legacy string wire (addon < 0.14): the engine takes no params array, so
1495
+ // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1496
+ // live and tested as the pre-0.14 fallback.
1497
+ //
1498
+ // Safety assertion (see {@link POWQL_LEXER_TESTED_CEILING}): an addon whose
1499
+ // engine line EXCEEDS the escaper's verified lexer ceiling must never be
1500
+ // materialized: a newer lexer may recognize escapes the escaper leaves raw,
1501
+ // making the inline path an injection primitive. Reachability: addons >= 0.14
1502
+ // expose `queryWithParams` and take the native path above, so the ONLY way to
1503
+ // reach this branch with a newer-than-ceiling engine is the anomalous
1504
+ // newer-addon-without-native case (feature-detect failed). Refuse it rather
1505
+ // than inline-encode against an unverified lexer.
1506
+ const engineSem = parsePowdbSemver(this.capabilities.engineVersion);
1507
+ const ceiling = parsePowdbSemver(POWQL_LEXER_TESTED_CEILING);
1508
+ if (engineSem &&
1509
+ ceiling &&
1510
+ (engineSem.major > ceiling.major || (engineSem.major === ceiling.major && engineSem.minor > ceiling.minor))) {
1511
+ throw new ValidationError(`[turbine] Refusing the PowDB legacy string wire: this embedded addon reports engine ` +
1512
+ `${this.capabilities.engineVersion}, which is newer than the escaper's verified lexer range ` +
1513
+ `(<= ${POWQL_LEXER_TESTED_CEILING}) AND such an addon exposes the parameterized native API, so ` +
1514
+ `reaching the legacy materialize path means the queryWithParams feature-detect failed. Upgrade ` +
1515
+ `turbine-orm so its PowQL escaper is verified against this engine line before running on it.`);
1516
+ }
1388
1517
  const materialized = materializePowql(powql, params);
1389
1518
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1390
1519
  }
@@ -1404,6 +1533,15 @@ export class PowdbEmbeddedPool {
1404
1533
  // transaction callback throws re-entrant E017 fast; independent
1405
1534
  // concurrent ones wait their FIFO turn.
1406
1535
  holdRef.hold = await this.txGate.acquire();
1536
+ // The gate may have handed us the slot AFTER disconnect() closed the
1537
+ // handle (a transaction queued behind an in-flight one, released as the
1538
+ // pool shut down). Re-check before touching the now-closed engine, and
1539
+ // release the slot we just took so the queue keeps draining.
1540
+ if (this.closed) {
1541
+ holdRef.hold.finish();
1542
+ holdRef.hold = null;
1543
+ throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1544
+ }
1407
1545
  }
1408
1546
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1409
1547
  // This context never acquired the gate — its `begin` never ran (the
@@ -1479,13 +1617,15 @@ export class PowdbEmbeddedPool {
1479
1617
  async end() {
1480
1618
  if (this.closed)
1481
1619
  return;
1482
- // The addon exposes no explicit close — drop the reference and let GC /
1483
- // the engine's checkpoint flush. Marking the pool closed makes later
1484
- // queries fail with a typed ConnectionError instead of silently running
1485
- // against a handle the caller believes is gone. Caveat: durability is
1486
- // checkpoint-bound, so hold the process open long enough for the final
1487
- // WAL flush in short scripts.
1488
1620
  this.closed = true;
1621
+ // Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
1622
+ // the final WAL flush completes deterministically before the handle is
1623
+ // dropped. An older addon has no close, dropping the reference and letting
1624
+ // GC / the engine's checkpoint flush is the fallback (durability is then
1625
+ // checkpoint-bound, so a short script must hold the process open long enough
1626
+ // for the final flush). Marking the pool closed makes later queries fail
1627
+ // with a typed ConnectionError instead of running against a gone handle.
1628
+ this.db.close?.();
1489
1629
  }
1490
1630
  }
1491
1631
  // ---------------------------------------------------------------------------
@@ -1552,12 +1692,34 @@ function resolveEmbeddedVersion() {
1552
1692
  return importOptionalPeer.peerPackageVersion('@zvndev/powdb-embedded');
1553
1693
  }
1554
1694
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1555
- async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1556
- const mod = await loadPowdbEmbedded();
1557
- const { embedded: dir, syncMode, memoryLimit } = target;
1695
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
1696
+ const mod = injectedModule ?? (await loadPowdbEmbedded());
1697
+ const { embedded: dir, syncMode, memoryLimit, readonly } = target;
1698
+ // A read-only engine never writes, so a durability selector is meaningless
1699
+ // there, reject the combination loudly rather than silently ignoring one.
1700
+ if (readonly && syncMode !== undefined) {
1701
+ throw new ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
1702
+ }
1558
1703
  let db;
1559
1704
  try {
1560
- if (memoryLimit !== undefined) {
1705
+ if (readonly) {
1706
+ // Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
1707
+ // constructors; feature-detect and fail with a clear version hint if the
1708
+ // installed addon predates them.
1709
+ if (memoryLimit !== undefined) {
1710
+ if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
1711
+ throw new ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
1712
+ }
1713
+ db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
1714
+ }
1715
+ else {
1716
+ if (typeof mod.Database.openReadOnly !== 'function') {
1717
+ throw new ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
1718
+ }
1719
+ db = mod.Database.openReadOnly(dir);
1720
+ }
1721
+ }
1722
+ else if (memoryLimit !== undefined) {
1561
1723
  if (typeof mod.Database.openWithMemoryLimit !== 'function') {
1562
1724
  throw new ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
1563
1725
  }
@@ -1578,11 +1740,19 @@ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1578
1740
  }
1579
1741
  db.setSyncMode(syncMode);
1580
1742
  }
1581
- // Embedded exposes no native typed-wire surface, so nativeRaw is always false.
1743
+ // Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
1744
+ // exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
1745
+ // applies via the version); an older addon has no such method → false.
1582
1746
  const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1583
- hasNativeRaw: false,
1747
+ hasNativeRaw: typeof db.queryWithParams === 'function',
1748
+ });
1749
+ // A read-only target forces the pool's readonly flag; otherwise honor whatever
1750
+ // `poolOptions` (threaded from `options.readonly`) carried.
1751
+ return new PowdbEmbeddedPool(db, {
1752
+ ...poolOptions,
1753
+ capabilities,
1754
+ readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
1584
1755
  });
1585
- return new PowdbEmbeddedPool(db, { ...poolOptions, capabilities });
1586
1756
  }
1587
1757
  /**
1588
1758
  * Bind Turbine to PowDB. `target` is one of:
@@ -1608,6 +1778,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1608
1778
  const poolOptions = {
1609
1779
  transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1610
1780
  retryStaleReads: options.retryStaleReads,
1781
+ readonly: options.readonly,
1611
1782
  };
1612
1783
  const max = options.connectionLimit ?? 10;
1613
1784
  if (typeof target === 'string') {
@@ -1622,7 +1793,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1622
1793
  pool = target;
1623
1794
  }
1624
1795
  else if (isEmbeddedTarget(target)) {
1625
- pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1796
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
1626
1797
  owns = true;
1627
1798
  }
1628
1799
  else if (isPowdbClientPool(target)) {
@@ -1649,6 +1820,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1649
1820
  logging: options.logging,
1650
1821
  defaultLimit: options.defaultLimit,
1651
1822
  warnOnUnlimited: options.warnOnUnlimited,
1823
+ relationLoadStrategy: options.relationLoadStrategy,
1652
1824
  queryInterfaceFactory,
1653
1825
  }, schema);
1654
1826
  if (owns) {