turbine-orm 0.34.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.
@@ -111,15 +111,27 @@ class TransactionClient {
111
111
  schema;
112
112
  middlewares;
113
113
  queryOptions;
114
+ sourcePool;
114
115
  tableCache = new Map();
115
116
  savepointCounter = 0;
116
117
  /** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
117
118
  dialect;
118
- constructor(client, schema, middlewares, queryOptions) {
119
+ constructor(client, schema, middlewares, queryOptions,
120
+ /**
121
+ * The parent pool this transaction runs on. Only its `readonly` and
122
+ * `capabilities` are read (both PowDB-only flags), so the transaction-scoped
123
+ * proxy pool built by {@link createTxPool} carries them through: without this
124
+ * a read-only client's `$transaction` writes bypass the E018 guard, and an
125
+ * older-engine client falls back to ALL_POWDB_CAPABILITIES inside the tx
126
+ * (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
127
+ * for a plain pg pool leave the proxy unchanged.
128
+ */
129
+ sourcePool) {
119
130
  this.client = client;
120
131
  this.schema = schema;
121
132
  this.middlewares = middlewares;
122
133
  this.queryOptions = queryOptions;
134
+ this.sourcePool = sourcePool;
123
135
  this.dialect = queryOptions?.dialect ?? dialect_js_1.postgresDialect;
124
136
  // Auto-create typed table accessors for all tables in the schema
125
137
  for (const tableName of Object.keys(schema.tables)) {
@@ -199,7 +211,7 @@ class TransactionClient {
199
211
  const client = this.client;
200
212
  // Return a minimal pool-compatible object that routes queries
201
213
  // through the transaction client
202
- return {
214
+ const txPool = {
203
215
  query: async (textOrConfig, values) => {
204
216
  try {
205
217
  if (typeof textOrConfig === 'string') {
@@ -216,6 +228,14 @@ class TransactionClient {
216
228
  },
217
229
  connect: () => Promise.resolve(client),
218
230
  };
231
+ // Carry the parent pool's PowDB-only flags through so a transaction-scoped
232
+ // PowqlInterface reads the same read-only guard and capabilities it would
233
+ // outside the transaction (a plain pg pool has neither, so nothing changes).
234
+ if (this.sourcePool?.readonly !== undefined)
235
+ txPool.readonly = this.sourcePool.readonly;
236
+ if (this.sourcePool?.capabilities !== undefined)
237
+ txPool.capabilities = this.sourcePool.capabilities;
238
+ return txPool;
219
239
  }
220
240
  }
221
241
  exports.TransactionClient = TransactionClient;
@@ -884,8 +904,10 @@ class TurbineClient {
884
904
  await client.query(cfg.sql, cfg.params);
885
905
  }
886
906
  }
887
- // Create the transaction client with typed table accessors
888
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
907
+ // Create the transaction client with typed table accessors. Pass the
908
+ // parent pool so its read-only guard + PowDB capabilities flow into the
909
+ // transaction-scoped proxy pool (see TransactionClient.createTxPool).
910
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, this.pool);
889
911
  // Dynamically attach table accessors to tx
890
912
  for (const tableName of Object.keys(this.schema.tables)) {
891
913
  const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
@@ -59,6 +59,7 @@ exports.postgresDialect = {
59
59
  supportsRLS: true,
60
60
  supportsAdvisoryLock: true,
61
61
  supportsLateralJoin: true,
62
+ explainQuery: { prefix: 'EXPLAIN' },
62
63
  paramPlaceholder(index) {
63
64
  return `$${index}`;
64
65
  },
@@ -6,7 +6,7 @@
6
6
  * All Turbine errors extend TurbineError which includes a `code` property.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
9
+ exports.ReadOnlyError = exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
10
10
  exports.setErrorMessageMode = setErrorMessageMode;
11
11
  exports.getErrorMessageMode = getErrorMessageMode;
12
12
  exports.wrapPgError = wrapPgError;
@@ -29,6 +29,7 @@ exports.TurbineErrorCode = {
29
29
  OPTIMISTIC_LOCK: 'TURBINE_E015',
30
30
  EXCLUSION_VIOLATION: 'TURBINE_E016',
31
31
  UNSUPPORTED_FEATURE: 'TURBINE_E017',
32
+ READ_ONLY: 'TURBINE_E018',
32
33
  };
33
34
  /**
34
35
  * Prefix a human message with its stable error code so logs are greppable
@@ -503,6 +504,45 @@ class UnsupportedFeatureError extends TurbineError {
503
504
  }
504
505
  }
505
506
  exports.UnsupportedFeatureError = UnsupportedFeatureError;
507
+ /**
508
+ * Thrown when a write or DDL statement is refused because the target is
509
+ * read-only. Two shapes reach here, both on PowDB:
510
+ * - an embedded database opened read-only for snapshot serving refuses a write
511
+ * with `readonly mode: statement requires a writer …`;
512
+ * - a networked read-only role refuses a write with `permission denied: role
513
+ * '<role>' cannot execute write statements` (translated by `wrapPowdbError`).
514
+ * It is also raised locally, before the wire, when a write is issued on a pool
515
+ * the caller marked read-only (fail-fast). The message carries the engine text
516
+ * plus a hint to route writes to a writable primary.
517
+ *
518
+ * NOT retryable: the same write against the same read-only target fails
519
+ * identically; route it to a writable primary instead.
520
+ */
521
+ class ReadOnlyError extends TurbineError {
522
+ /**
523
+ * Why the write was refused. `'snapshot'`: the database itself is read-only
524
+ * (snapshot serving, an embedded `readonly: true` open, or the client-level
525
+ * fail-fast flag), so NOTHING can write here and writes must route to the
526
+ * primary. `'rbac'`: the database is writable but THIS connection's role may
527
+ * not write (per-connection permission), so re-authenticating may suffice.
528
+ */
529
+ reason;
530
+ /**
531
+ * @param detail human-readable description of the refused write (the engine
532
+ * message, or a local fail-fast description). A "route writes to a writable
533
+ * primary" hint is always appended.
534
+ * @param options optional driver `cause` to preserve when wrapping a refusal,
535
+ * and the refusal `reason` (default `'snapshot'`).
536
+ */
537
+ constructor(detail, options) {
538
+ super(exports.TurbineErrorCode.READ_ONLY, `[turbine] ${detail} Route writes to a writable primary.`, {
539
+ cause: options?.cause,
540
+ });
541
+ this.name = 'ReadOnlyError';
542
+ this.reason = options?.reason ?? 'snapshot';
543
+ }
544
+ }
545
+ exports.ReadOnlyError = ReadOnlyError;
506
546
  /**
507
547
  * Parse column names out of a pg `detail` string like:
508
548
  * "Key (email)=(foo@bar) already exists."
package/dist/cjs/index.js CHANGED
@@ -34,8 +34,8 @@
34
34
  * ```
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.ColumnBuilder = exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = void 0;
37
+ exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -63,6 +63,7 @@ Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: functio
63
63
  Object.defineProperty(exports, "NotNullViolationError", { enumerable: true, get: function () { return errors_js_1.NotNullViolationError; } });
64
64
  Object.defineProperty(exports, "OptimisticLockError", { enumerable: true, get: function () { return errors_js_1.OptimisticLockError; } });
65
65
  Object.defineProperty(exports, "PipelineError", { enumerable: true, get: function () { return errors_js_1.PipelineError; } });
66
+ Object.defineProperty(exports, "ReadOnlyError", { enumerable: true, get: function () { return errors_js_1.ReadOnlyError; } });
66
67
  Object.defineProperty(exports, "RelationError", { enumerable: true, get: function () { return errors_js_1.RelationError; } });
67
68
  Object.defineProperty(exports, "SerializationFailureError", { enumerable: true, get: function () { return errors_js_1.SerializationFailureError; } });
68
69
  Object.defineProperty(exports, "setErrorMessageMode", { enumerable: true, get: function () { return errors_js_1.setErrorMessageMode; } });
package/dist/cjs/mssql.js CHANGED
@@ -483,6 +483,11 @@ exports.mssqlDialect = {
483
483
  supportsLateralJoin: false,
484
484
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
485
485
  supportsAdvisoryLock: true,
486
+ // No in-band EXPLAIN: SQL Server's SHOWPLAN is a session toggle
487
+ // (SET SHOWPLAN_ALL ON), not a statement prefix, so a compiled query cannot
488
+ // be explained in one round-trip. Override the inherited Postgres `EXPLAIN`
489
+ // to absent → QueryInterface.explain() throws E017.
490
+ explainQuery: undefined,
486
491
  // FOR JSON over zero rows is NULL → coalesced in the relation override.
487
492
  aggSupportsInlineOrderBy: false,
488
493
  jsonPathSupport: 'limited',
package/dist/cjs/mysql.js CHANGED
@@ -384,6 +384,10 @@ exports.mysqlDialect = {
384
384
  supportsLateralJoin: false,
385
385
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
386
386
  supportsAdvisoryLock: true,
387
+ // Plain `EXPLAIN` (one row of tabular plan columns) works on every supported
388
+ // MySQL 8.0.x; the readable `FORMAT=TREE` variant only exists from 8.0.16 and
389
+ // the engine floor here is 8.0.0. Plan text is a diagnostic, not a contract.
390
+ explainQuery: { prefix: 'EXPLAIN' },
387
391
  // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
388
392
  // rewrite for every ordered to-many relation.
389
393
  aggSupportsInlineOrderBy: false,
package/dist/cjs/powdb.js CHANGED
@@ -252,6 +252,7 @@ const POWDB_FEATURE_MIN_VERSION = {
252
252
  introspection: '0.10',
253
253
  jsonDocs: '0.12',
254
254
  docFieldIndexes: '0.13',
255
+ serverJoins: '0.13',
255
256
  };
256
257
  /**
257
258
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -266,6 +267,7 @@ exports.ALL_POWDB_CAPABILITIES = {
266
267
  jsonDocs: true,
267
268
  docFieldIndexes: true,
268
269
  introspection: true,
270
+ serverJoins: true,
269
271
  nativeRaw: false,
270
272
  };
271
273
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -293,6 +295,7 @@ function capabilitiesFromVersion(version, opts = {}) {
293
295
  jsonDocs: false,
294
296
  docFieldIndexes: false,
295
297
  introspection: false,
298
+ serverJoins: false,
296
299
  nativeRaw: false,
297
300
  };
298
301
  }
@@ -301,6 +304,7 @@ function capabilitiesFromVersion(version, opts = {}) {
301
304
  introspection: atLeastVersion(sem, 0, 10),
302
305
  jsonDocs: atLeastVersion(sem, 0, 12),
303
306
  docFieldIndexes: atLeastVersion(sem, 0, 13),
307
+ serverJoins: atLeastVersion(sem, 0, 13),
304
308
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
305
309
  };
306
310
  }
@@ -745,9 +749,10 @@ function wrapPowdbError(err) {
745
749
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
746
750
  return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
747
751
  }
748
- // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
749
- // no .code classify by message so both transports surface E004.
750
- if (/pool closed|pool acquire timeout/i.test(msg)) {
752
+ // Driver pool lifecycle errors (acquire after close, acquire timeout, or a
753
+ // statement reaching an already-closed embedded handle) carry no .code:
754
+ // classify by message so both transports surface E004.
755
+ if (/pool closed|pool acquire timeout|database is closed/i.test(msg)) {
751
756
  return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
752
757
  }
753
758
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
@@ -769,6 +774,54 @@ function wrapPowdbError(err) {
769
774
  /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
770
775
  return new errors_js_1.ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
771
776
  }
777
+ // Read-only refusal → ReadOnlyError (E018). Two engine shapes, both mapped by
778
+ // substring (the networked transport prefixes the message with `query failed:
779
+ // `, so never anchor on the start): an embedded database opened read-only for
780
+ // snapshot serving (`readonly mode: statement requires a writer …`), and a
781
+ // networked read-only role (`permission denied: role '<role>' cannot execute
782
+ // write statements`). These run BEFORE the generic validation regex below so a
783
+ // read-only write is surfaced as the routing signal E018, not a query defect.
784
+ // The driver spec (0.15) distinguishes them via `reason`: snapshot mode
785
+ // means "nothing can write here; route writes to the primary", RBAC means
786
+ // "this connection's role may not write here".
787
+ if (/readonly mode: statement requires a writer/i.test(msg)) {
788
+ return new errors_js_1.ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
789
+ cause: err,
790
+ reason: 'snapshot',
791
+ });
792
+ }
793
+ if (/permission denied: role/i.test(msg)) {
794
+ return new errors_js_1.ReadOnlyError(`PowDB refused a write for a read-only role: ${msg}.`, { cause: err, reason: 'rbac' });
795
+ }
796
+ // Open-time read-only failure: a read-only handle over a directory whose WAL
797
+ // still has uncommitted frames is refused (`cannot open read-only: the WAL is
798
+ // not empty …`). It is a connection failure (E004), not a query defect, the
799
+ // fix is to recover the directory with a writable open first.
800
+ if (/cannot open read-only: the WAL is not empty/i.test(msg)) {
801
+ return new errors_js_1.ConnectionError(`[turbine] PowDB could not open the directory read-only: ${msg}. Open it once with a writable handle to ` +
802
+ 'flush the WAL (recover the directory), then reopen it read-only for snapshot serving.', { cause: err });
803
+ }
804
+ // Per-query deadline → TimeoutError (E002). Message-path so it fires on the
805
+ // embedded transport too (code is always 'GenericFailure' there); retryable.
806
+ // Pass the engine prose through the message override (same pattern as the
807
+ // transaction-gate timeout below) so the real "query timeout after <n>ms"
808
+ // survives instead of rendering the placeholder "timed out after 0ms".
809
+ if (/query timeout after/i.test(msg)) {
810
+ return new errors_js_1.TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
811
+ }
812
+ // Client-initiated cancellation → ConnectionError (E004). This is FINAL: the
813
+ // issuing client disconnected, so the query was a clean early return, never
814
+ // auto-retry it (the opt-in stale-read retry only replays stale-FRAME reads).
815
+ if (/query cancelled by client disconnect/i.test(msg)) {
816
+ return new errors_js_1.ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
817
+ }
818
+ // Bounded join rejection → ValidationError (E003). The engine rejects a pure
819
+ // nested-loop join whose candidate-pair count (or result row count) exceeds
820
+ // the safety bound BEFORE executing, and names the fix in the message, keep
821
+ // that fix-hint intact so the caller knows how to make the join eligible.
822
+ if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
823
+ return new errors_js_1.ValidationError(`[turbine] PowDB join rejected: ${msg}`);
824
+ }
772
825
  // Type mismatch / parse / execution / storage / unexpected(token) / row too
773
826
  // large → validation (E003). On the embedded transport these are the only
774
827
  // signal we get (code is always 'GenericFailure'); on the networked path they
@@ -1109,12 +1162,20 @@ class PowdbPool {
1109
1162
  capabilities;
1110
1163
  /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1111
1164
  retryStaleReads;
1165
+ /**
1166
+ * True when the caller marked this pool read-only (`readonly: true`). Read by
1167
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1168
+ * wire; the engine's own read-only-role refusal (mapped by
1169
+ * {@link wrapPowdbError}) is the backstop for raw / injected paths.
1170
+ */
1171
+ readonly;
1112
1172
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
1113
1173
  this.pool = pool;
1114
1174
  this.toParam = toParam;
1115
1175
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1116
1176
  this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1117
1177
  this.retryStaleReads = options.retryStaleReads ?? false;
1178
+ this.readonly = options.readonly ?? false;
1118
1179
  }
1119
1180
  /**
1120
1181
  * Run one statement on `c`, choosing the lossless native typed wire when the
@@ -1405,10 +1466,14 @@ function materializePowql(powql, params) {
1405
1466
  }
1406
1467
  /**
1407
1468
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
1408
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
1409
- * accepts only a string — so this pool materializes each positional `$N` into a
1410
- * PowQL literal via {@link materializePowql} before handing the text to the
1411
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
1469
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
1470
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
1471
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
1472
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
1473
+ * legacy string wire, which takes **no params array** (its `query(powql)`
1474
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
1475
+ * literal via {@link materializePowql} before the text is handed to the engine.
1476
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
1412
1477
  * `rollback`) are issued serially as ordinary queries.
1413
1478
  */
1414
1479
  class PowdbEmbeddedPool {
@@ -1429,20 +1494,44 @@ class PowdbEmbeddedPool {
1429
1494
  poolHoldRef = { hold: null };
1430
1495
  /**
1431
1496
  * Feature capabilities of the embedded engine (resolved from the addon
1432
- * package version). `nativeRaw` is always false: the embedded addon exposes
1433
- * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
1497
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
1498
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
1499
+ * addon has no such method, so it stays false and the legacy string wire is
1500
+ * used.
1434
1501
  */
1435
1502
  capabilities;
1436
1503
  /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1437
1504
  retryStaleReads;
1505
+ /**
1506
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
1507
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
1508
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1509
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
1510
+ * backstop for raw / injected paths.
1511
+ */
1512
+ readonly;
1438
1513
  constructor(db, options = {}) {
1439
1514
  this.db = db;
1440
1515
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1441
1516
  this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1442
1517
  this.retryStaleReads = options.retryStaleReads ?? false;
1518
+ this.readonly = options.readonly ?? false;
1443
1519
  }
1444
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1520
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
1445
1521
  exec(powql, params) {
1522
+ // Native typed wire (addon ≥ 0.14): bind positional params with the SAME
1523
+ // binder the networked transport uses ({@link toPowdbParam} yields exactly
1524
+ // the NativeParam union null|bigint|number|boolean|string) and decode the
1525
+ // typed cells: a genuine str "null" survives, a json-null document stays
1526
+ // distinct from an absent value. Gated on the resolved capability AND a
1527
+ // per-call feature-detect so a heterogeneous injected handle cannot crash.
1528
+ if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
1529
+ const bound = params.map((v) => toPowdbParam(v));
1530
+ return adaptNativeResult(this.db.queryWithParams(powql, bound));
1531
+ }
1532
+ // Legacy string wire (addon < 0.14): the engine takes no params array, so
1533
+ // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1534
+ // live and tested as the pre-0.14 fallback.
1446
1535
  const materialized = materializePowql(powql, params);
1447
1536
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1448
1537
  }
@@ -1462,6 +1551,15 @@ class PowdbEmbeddedPool {
1462
1551
  // transaction callback throws re-entrant E017 fast; independent
1463
1552
  // concurrent ones wait their FIFO turn.
1464
1553
  holdRef.hold = await this.txGate.acquire();
1554
+ // The gate may have handed us the slot AFTER disconnect() closed the
1555
+ // handle (a transaction queued behind an in-flight one, released as the
1556
+ // pool shut down). Re-check before touching the now-closed engine, and
1557
+ // release the slot we just took so the queue keeps draining.
1558
+ if (this.closed) {
1559
+ holdRef.hold.finish();
1560
+ holdRef.hold = null;
1561
+ throw new errors_js_1.ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1562
+ }
1465
1563
  }
1466
1564
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1467
1565
  // This context never acquired the gate — its `begin` never ran (the
@@ -1537,13 +1635,15 @@ class PowdbEmbeddedPool {
1537
1635
  async end() {
1538
1636
  if (this.closed)
1539
1637
  return;
1540
- // The addon exposes no explicit close — drop the reference and let GC /
1541
- // the engine's checkpoint flush. Marking the pool closed makes later
1542
- // queries fail with a typed ConnectionError instead of silently running
1543
- // against a handle the caller believes is gone. Caveat: durability is
1544
- // checkpoint-bound, so hold the process open long enough for the final
1545
- // WAL flush in short scripts.
1546
1638
  this.closed = true;
1639
+ // Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
1640
+ // the final WAL flush completes deterministically before the handle is
1641
+ // dropped. An older addon has no close, dropping the reference and letting
1642
+ // GC / the engine's checkpoint flush is the fallback (durability is then
1643
+ // checkpoint-bound, so a short script must hold the process open long enough
1644
+ // for the final flush). Marking the pool closed makes later queries fail
1645
+ // with a typed ConnectionError instead of running against a gone handle.
1646
+ this.db.close?.();
1547
1647
  }
1548
1648
  }
1549
1649
  exports.PowdbEmbeddedPool = PowdbEmbeddedPool;
@@ -1613,12 +1713,34 @@ function resolveEmbeddedVersion() {
1613
1713
  return optional_peer_import_cjs_1.default.peerPackageVersion('@zvndev/powdb-embedded');
1614
1714
  }
1615
1715
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1616
- async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1617
- const mod = await loadPowdbEmbedded();
1618
- const { embedded: dir, syncMode, memoryLimit } = target;
1716
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
1717
+ const mod = injectedModule ?? (await loadPowdbEmbedded());
1718
+ const { embedded: dir, syncMode, memoryLimit, readonly } = target;
1719
+ // A read-only engine never writes, so a durability selector is meaningless
1720
+ // there, reject the combination loudly rather than silently ignoring one.
1721
+ if (readonly && syncMode !== undefined) {
1722
+ throw new errors_js_1.ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
1723
+ }
1619
1724
  let db;
1620
1725
  try {
1621
- if (memoryLimit !== undefined) {
1726
+ if (readonly) {
1727
+ // Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
1728
+ // constructors; feature-detect and fail with a clear version hint if the
1729
+ // installed addon predates them.
1730
+ if (memoryLimit !== undefined) {
1731
+ if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
1732
+ throw new errors_js_1.ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
1733
+ }
1734
+ db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
1735
+ }
1736
+ else {
1737
+ if (typeof mod.Database.openReadOnly !== 'function') {
1738
+ throw new errors_js_1.ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
1739
+ }
1740
+ db = mod.Database.openReadOnly(dir);
1741
+ }
1742
+ }
1743
+ else if (memoryLimit !== undefined) {
1622
1744
  if (typeof mod.Database.openWithMemoryLimit !== 'function') {
1623
1745
  throw new errors_js_1.ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
1624
1746
  }
@@ -1639,11 +1761,19 @@ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1639
1761
  }
1640
1762
  db.setSyncMode(syncMode);
1641
1763
  }
1642
- // Embedded exposes no native typed-wire surface, so nativeRaw is always false.
1764
+ // Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
1765
+ // exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
1766
+ // applies via the version); an older addon has no such method → false.
1643
1767
  const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1644
- hasNativeRaw: false,
1768
+ hasNativeRaw: typeof db.queryWithParams === 'function',
1769
+ });
1770
+ // A read-only target forces the pool's readonly flag; otherwise honor whatever
1771
+ // `poolOptions` (threaded from `options.readonly`) carried.
1772
+ return new PowdbEmbeddedPool(db, {
1773
+ ...poolOptions,
1774
+ capabilities,
1775
+ readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
1645
1776
  });
1646
- return new PowdbEmbeddedPool(db, { ...poolOptions, capabilities });
1647
1777
  }
1648
1778
  /**
1649
1779
  * Bind Turbine to PowDB. `target` is one of:
@@ -1669,6 +1799,7 @@ async function turbinePowDB(target, schema, options = {}) {
1669
1799
  const poolOptions = {
1670
1800
  transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1671
1801
  retryStaleReads: options.retryStaleReads,
1802
+ readonly: options.readonly,
1672
1803
  };
1673
1804
  const max = options.connectionLimit ?? 10;
1674
1805
  if (typeof target === 'string') {
@@ -1683,7 +1814,7 @@ async function turbinePowDB(target, schema, options = {}) {
1683
1814
  pool = target;
1684
1815
  }
1685
1816
  else if (isEmbeddedTarget(target)) {
1686
- pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1817
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
1687
1818
  owns = true;
1688
1819
  }
1689
1820
  else if (isPowdbClientPool(target)) {
@@ -1710,6 +1841,7 @@ async function turbinePowDB(target, schema, options = {}) {
1710
1841
  logging: options.logging,
1711
1842
  defaultLimit: options.defaultLimit,
1712
1843
  warnOnUnlimited: options.warnOnUnlimited,
1844
+ relationLoadStrategy: options.relationLoadStrategy,
1713
1845
  queryInterfaceFactory,
1714
1846
  }, schema);
1715
1847
  if (owns) {