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.
@@ -177,6 +177,11 @@ class QueryInterface {
177
177
  warnOnUnlimited;
178
178
  utcTimestamps;
179
179
  preparedStatementsEnabled;
180
+ /**
181
+ * Whether the SQL template cache is active. Set once in the constructor.
182
+ * Mutable (not `readonly`) only so {@link withSqlCacheDisabled} can flip it
183
+ * off around a single synchronous compile (see {@link explain}).
184
+ */
180
185
  sqlCacheEnabled;
181
186
  dialect;
182
187
  /** Client-level default relation-loading strategy ('join' unless configured). */
@@ -870,6 +875,61 @@ class QueryInterface {
870
875
  return deferred.transform(result);
871
876
  });
872
877
  }
878
+ /**
879
+ * Return the engine's query plan for a {@link findMany}-shaped query as plain
880
+ * text lines: a diagnostic surface for inspecting how the database will run a
881
+ * query (index usage, join strategy, scan type).
882
+ *
883
+ * The compiled SELECT is prefixed with the dialect's explain syntax
884
+ * (Postgres `EXPLAIN`, SQLite `EXPLAIN QUERY PLAN`, MySQL `EXPLAIN
885
+ * FORMAT=TREE`) and run as a read. The findMany args are compiled with the
886
+ * SQL template cache disabled, so an explain never reads or writes the shared
887
+ * cache. Middleware is NOT applied: the returned rows are plan text, not
888
+ * entity rows. Each result row is flattened to one line by joining its column
889
+ * values with a single space (Postgres returns one `QUERY PLAN` text column,
890
+ * SQLite's `EXPLAIN QUERY PLAN` returns four, MySQL's tree format one).
891
+ *
892
+ * Only `findMany` shapes are supported (where / orderBy / with / limit /
893
+ * pagination). Engines whose plan cannot be requested in-band from a compiled
894
+ * query (SQL Server, whose SHOWPLAN is a session toggle) throw
895
+ * {@link UnsupportedFeatureError} (E017).
896
+ *
897
+ * The plan text itself is engine-owned and NOT covered by semver: its content
898
+ * and formatting can change with the underlying database version.
899
+ */
900
+ async explain(args) {
901
+ const explainSyntax = this.dialect.explainQuery;
902
+ if (!explainSyntax) {
903
+ throw new errors_js_1.UnsupportedFeatureError('explain()', this.dialect.name, 'This engine cannot explain a compiled query in-band.');
904
+ }
905
+ // Compile the findMany SQL with the cache disabled: the prefixed EXPLAIN
906
+ // statement is a one-off diagnostic and must never read or write the shared
907
+ // query-template cache.
908
+ const deferred = this.withSqlCacheDisabled(() => this.buildFindMany(args));
909
+ const sql = `${explainSyntax.prefix} ${deferred.sql}`;
910
+ this.currentAction = 'explain';
911
+ // No preparedName: keep the diagnostic statement out of the prepared path.
912
+ const result = await this.queryWithTimeout(sql, deferred.params, args?.timeout);
913
+ return result.rows.map((row) => Object.values(row)
914
+ .map((value) => (typeof value === 'string' ? value : String(value)))
915
+ .join(' '));
916
+ }
917
+ /**
918
+ * Run `fn` with the SQL template cache forced off, restoring the prior state
919
+ * afterward. Used by {@link explain}, whose one-off prefixed statement must
920
+ * neither read nor write the shared cache. `fn` is synchronous, so no query
921
+ * interleaves between the toggle and its restore.
922
+ */
923
+ withSqlCacheDisabled(fn) {
924
+ const prev = this.sqlCacheEnabled;
925
+ this.sqlCacheEnabled = false;
926
+ try {
927
+ return fn();
928
+ }
929
+ finally {
930
+ this.sqlCacheEnabled = prev;
931
+ }
932
+ }
873
933
  /**
874
934
  * Emit a one-time `console.warn` when {@link findMany} is called without an
875
935
  * explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
@@ -380,6 +380,9 @@ exports.sqliteDialect = {
380
380
  supportsAdvisoryLock: false,
381
381
  // No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
382
382
  supportsLateralJoin: false,
383
+ // SQLite explains a compiled query with `EXPLAIN QUERY PLAN` (four columns:
384
+ // id, parent, notused, detail), overriding the inherited Postgres `EXPLAIN`.
385
+ explainQuery: { prefix: 'EXPLAIN QUERY PLAN' },
383
386
  // json_group_array / json_object have no inline ORDER BY argument, so every
384
387
  // ordered to-many relation is forced through the inner-subquery rewrite.
385
388
  aggSupportsInlineOrderBy: false,
package/dist/client.d.ts CHANGED
@@ -181,13 +181,17 @@ export interface TurbineConfig {
181
181
  * Default strategy for resolving `with`-clause relations, applied to every
182
182
  * `findMany`/`findUnique`/`findFirst` unless overridden per query.
183
183
  *
184
- * - `'join'` (default) — one SQL statement using correlated
185
- * `json_agg(json_build_object(...))` subqueries.
186
- * - `'batched'` run the base query, then one flat follow-up query per
184
+ * - `'join'`: one SQL statement using correlated
185
+ * `json_agg(json_build_object(...))` subqueries. On PowDB, `'join'` opts
186
+ * into native server-side joins where eligible instead.
187
+ * - `'batched'`: run the base query, then one flat follow-up query per
187
188
  * relation (`WHERE fk = ANY($1)`), stitching children client-side. Wins
188
189
  * when child FK columns are unindexed or result sets are large.
189
190
  *
190
- * Precedence: per-query `relationLoadStrategy` arg > this config > `'join'`.
191
+ * Precedence: per-query `relationLoadStrategy` arg > this config > the engine
192
+ * default. On SQL engines the default is `'join'`; on PowDB the default is the
193
+ * batched loaders (an ineligible relation falls back to them silently even
194
+ * under `'join'`).
191
195
  */
192
196
  relationLoadStrategy?: RelationLoadStrategy;
193
197
  /**
@@ -341,11 +345,34 @@ export declare class TransactionClient {
341
345
  readonly schema: SchemaMetadata;
342
346
  private readonly middlewares;
343
347
  private readonly queryOptions?;
348
+ /**
349
+ * The parent pool this transaction runs on. Only its `readonly` and
350
+ * `capabilities` are read (both PowDB-only flags), so the transaction-scoped
351
+ * proxy pool built by {@link createTxPool} carries them through: without this
352
+ * a read-only client's `$transaction` writes bypass the E018 guard, and an
353
+ * older-engine client falls back to ALL_POWDB_CAPABILITIES inside the tx
354
+ * (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
355
+ * for a plain pg pool leave the proxy unchanged.
356
+ */
357
+ private readonly sourcePool?;
344
358
  private readonly tableCache;
345
359
  private savepointCounter;
346
360
  /** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
347
361
  private readonly dialect;
348
- constructor(client: pg.PoolClient, schema: SchemaMetadata, middlewares: Middleware[], queryOptions?: QueryInterfaceOptions | undefined);
362
+ constructor(client: pg.PoolClient, schema: SchemaMetadata, middlewares: Middleware[], queryOptions?: QueryInterfaceOptions | undefined,
363
+ /**
364
+ * The parent pool this transaction runs on. Only its `readonly` and
365
+ * `capabilities` are read (both PowDB-only flags), so the transaction-scoped
366
+ * proxy pool built by {@link createTxPool} carries them through: without this
367
+ * a read-only client's `$transaction` writes bypass the E018 guard, and an
368
+ * older-engine client falls back to ALL_POWDB_CAPABILITIES inside the tx
369
+ * (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
370
+ * for a plain pg pool leave the proxy unchanged.
371
+ */
372
+ sourcePool?: {
373
+ readonly readonly?: boolean;
374
+ readonly capabilities?: unknown;
375
+ } | undefined);
349
376
  /**
350
377
  * Get a QueryInterface for a table within this transaction.
351
378
  * Uses the dedicated transaction connection instead of the pool.
package/dist/client.js CHANGED
@@ -104,15 +104,27 @@ export class TransactionClient {
104
104
  schema;
105
105
  middlewares;
106
106
  queryOptions;
107
+ sourcePool;
107
108
  tableCache = new Map();
108
109
  savepointCounter = 0;
109
110
  /** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
110
111
  dialect;
111
- constructor(client, schema, middlewares, queryOptions) {
112
+ constructor(client, schema, middlewares, queryOptions,
113
+ /**
114
+ * The parent pool this transaction runs on. Only its `readonly` and
115
+ * `capabilities` are read (both PowDB-only flags), so the transaction-scoped
116
+ * proxy pool built by {@link createTxPool} carries them through: without this
117
+ * a read-only client's `$transaction` writes bypass the E018 guard, and an
118
+ * older-engine client falls back to ALL_POWDB_CAPABILITIES inside the tx
119
+ * (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
120
+ * for a plain pg pool leave the proxy unchanged.
121
+ */
122
+ sourcePool) {
112
123
  this.client = client;
113
124
  this.schema = schema;
114
125
  this.middlewares = middlewares;
115
126
  this.queryOptions = queryOptions;
127
+ this.sourcePool = sourcePool;
116
128
  this.dialect = queryOptions?.dialect ?? postgresDialect;
117
129
  // Auto-create typed table accessors for all tables in the schema
118
130
  for (const tableName of Object.keys(schema.tables)) {
@@ -192,7 +204,7 @@ export class TransactionClient {
192
204
  const client = this.client;
193
205
  // Return a minimal pool-compatible object that routes queries
194
206
  // through the transaction client
195
- return {
207
+ const txPool = {
196
208
  query: async (textOrConfig, values) => {
197
209
  try {
198
210
  if (typeof textOrConfig === 'string') {
@@ -209,6 +221,14 @@ export class TransactionClient {
209
221
  },
210
222
  connect: () => Promise.resolve(client),
211
223
  };
224
+ // Carry the parent pool's PowDB-only flags through so a transaction-scoped
225
+ // PowqlInterface reads the same read-only guard and capabilities it would
226
+ // outside the transaction (a plain pg pool has neither, so nothing changes).
227
+ if (this.sourcePool?.readonly !== undefined)
228
+ txPool.readonly = this.sourcePool.readonly;
229
+ if (this.sourcePool?.capabilities !== undefined)
230
+ txPool.capabilities = this.sourcePool.capabilities;
231
+ return txPool;
212
232
  }
213
233
  }
214
234
  // ---------------------------------------------------------------------------
@@ -876,8 +896,10 @@ export class TurbineClient {
876
896
  await client.query(cfg.sql, cfg.params);
877
897
  }
878
898
  }
879
- // Create the transaction client with typed table accessors
880
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
899
+ // Create the transaction client with typed table accessors. Pass the
900
+ // parent pool so its read-only guard + PowDB capabilities flow into the
901
+ // transaction-scoped proxy pool (see TransactionClient.createTxPool).
902
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, this.pool);
881
903
  // Dynamically attach table accessors to tx
882
904
  for (const tableName of Object.keys(this.schema.tables)) {
883
905
  const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
package/dist/dialect.d.ts CHANGED
@@ -302,6 +302,19 @@ export interface Dialect {
302
302
  * lateral plan (else E017). PostgreSQL only in this release.
303
303
  */
304
304
  readonly supportsLateralJoin?: boolean;
305
+ /**
306
+ * How this dialect surfaces a query plan for a compiled SELECT. When present,
307
+ * `QueryInterface.explain()` prepends `prefix` (plus a single space) to the
308
+ * compiled findMany SQL and runs it as a read, returning the plan text lines.
309
+ * PostgreSQL / CockroachDB / YugabyteDB and MySQL use `EXPLAIN`, SQLite
310
+ * `EXPLAIN QUERY PLAN`. Absent means the engine
311
+ * cannot explain a compiled query in-band (SQL Server, whose SHOWPLAN needs a
312
+ * separate session toggle), so `QueryInterface.explain()` throws E017.
313
+ * Optional: dialects that predate this hook keep throwing E017.
314
+ */
315
+ readonly explainQuery?: {
316
+ prefix: string;
317
+ };
305
318
  /** Build a dialect-specific RETURNING clause. Return an empty string when unsupported. */
306
319
  buildReturningClause(selection?: string): string;
307
320
  /** Build a single-row INSERT statement. Inputs are SQL-ready quoted fragments. */
package/dist/dialect.js CHANGED
@@ -23,6 +23,7 @@ export const postgresDialect = {
23
23
  supportsRLS: true,
24
24
  supportsAdvisoryLock: true,
25
25
  supportsLateralJoin: true,
26
+ explainQuery: { prefix: 'EXPLAIN' },
26
27
  paramPlaceholder(index) {
27
28
  return `$${index}`;
28
29
  },
package/dist/errors.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare const TurbineErrorCode: {
23
23
  readonly OPTIMISTIC_LOCK: "TURBINE_E015";
24
24
  readonly EXCLUSION_VIOLATION: "TURBINE_E016";
25
25
  readonly UNSUPPORTED_FEATURE: "TURBINE_E017";
26
+ readonly READ_ONLY: "TURBINE_E018";
26
27
  };
27
28
  export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErrorCode];
28
29
  /** Base error class for all Turbine errors */
@@ -303,6 +304,41 @@ export declare class UnsupportedFeatureError extends TurbineError {
303
304
  readonly dialect: string;
304
305
  constructor(feature: string, dialect: string, hint?: string);
305
306
  }
307
+ /**
308
+ * Thrown when a write or DDL statement is refused because the target is
309
+ * read-only. Two shapes reach here, both on PowDB:
310
+ * - an embedded database opened read-only for snapshot serving refuses a write
311
+ * with `readonly mode: statement requires a writer …`;
312
+ * - a networked read-only role refuses a write with `permission denied: role
313
+ * '<role>' cannot execute write statements` (translated by `wrapPowdbError`).
314
+ * It is also raised locally, before the wire, when a write is issued on a pool
315
+ * the caller marked read-only (fail-fast). The message carries the engine text
316
+ * plus a hint to route writes to a writable primary.
317
+ *
318
+ * NOT retryable: the same write against the same read-only target fails
319
+ * identically; route it to a writable primary instead.
320
+ */
321
+ export declare class ReadOnlyError extends TurbineError {
322
+ /**
323
+ * Why the write was refused. `'snapshot'`: the database itself is read-only
324
+ * (snapshot serving, an embedded `readonly: true` open, or the client-level
325
+ * fail-fast flag), so NOTHING can write here and writes must route to the
326
+ * primary. `'rbac'`: the database is writable but THIS connection's role may
327
+ * not write (per-connection permission), so re-authenticating may suffice.
328
+ */
329
+ readonly reason: 'snapshot' | 'rbac';
330
+ /**
331
+ * @param detail human-readable description of the refused write (the engine
332
+ * message, or a local fail-fast description). A "route writes to a writable
333
+ * primary" hint is always appended.
334
+ * @param options optional driver `cause` to preserve when wrapping a refusal,
335
+ * and the refusal `reason` (default `'snapshot'`).
336
+ */
337
+ constructor(detail: string, options?: {
338
+ cause?: unknown;
339
+ reason?: 'snapshot' | 'rbac';
340
+ });
341
+ }
306
342
  /**
307
343
  * Translate a pg driver error into a typed Turbine error.
308
344
  * If the error doesn't match a known constraint code, returns it unchanged.
package/dist/errors.js CHANGED
@@ -23,6 +23,7 @@ export const TurbineErrorCode = {
23
23
  OPTIMISTIC_LOCK: 'TURBINE_E015',
24
24
  EXCLUSION_VIOLATION: 'TURBINE_E016',
25
25
  UNSUPPORTED_FEATURE: 'TURBINE_E017',
26
+ READ_ONLY: 'TURBINE_E018',
26
27
  };
27
28
  /**
28
29
  * Prefix a human message with its stable error code so logs are greppable
@@ -479,6 +480,44 @@ export class UnsupportedFeatureError extends TurbineError {
479
480
  this.dialect = dialect;
480
481
  }
481
482
  }
483
+ /**
484
+ * Thrown when a write or DDL statement is refused because the target is
485
+ * read-only. Two shapes reach here, both on PowDB:
486
+ * - an embedded database opened read-only for snapshot serving refuses a write
487
+ * with `readonly mode: statement requires a writer …`;
488
+ * - a networked read-only role refuses a write with `permission denied: role
489
+ * '<role>' cannot execute write statements` (translated by `wrapPowdbError`).
490
+ * It is also raised locally, before the wire, when a write is issued on a pool
491
+ * the caller marked read-only (fail-fast). The message carries the engine text
492
+ * plus a hint to route writes to a writable primary.
493
+ *
494
+ * NOT retryable: the same write against the same read-only target fails
495
+ * identically; route it to a writable primary instead.
496
+ */
497
+ export class ReadOnlyError extends TurbineError {
498
+ /**
499
+ * Why the write was refused. `'snapshot'`: the database itself is read-only
500
+ * (snapshot serving, an embedded `readonly: true` open, or the client-level
501
+ * fail-fast flag), so NOTHING can write here and writes must route to the
502
+ * primary. `'rbac'`: the database is writable but THIS connection's role may
503
+ * not write (per-connection permission), so re-authenticating may suffice.
504
+ */
505
+ reason;
506
+ /**
507
+ * @param detail human-readable description of the refused write (the engine
508
+ * message, or a local fail-fast description). A "route writes to a writable
509
+ * primary" hint is always appended.
510
+ * @param options optional driver `cause` to preserve when wrapping a refusal,
511
+ * and the refusal `reason` (default `'snapshot'`).
512
+ */
513
+ constructor(detail, options) {
514
+ super(TurbineErrorCode.READ_ONLY, `[turbine] ${detail} Route writes to a writable primary.`, {
515
+ cause: options?.cause,
516
+ });
517
+ this.name = 'ReadOnlyError';
518
+ this.reason = options?.reason ?? 'snapshot';
519
+ }
520
+ }
482
521
  /**
483
522
  * Parse column names out of a pg `detail` string like:
484
523
  * "Key (email)=(foo@bar) already exists."
package/dist/index.d.ts CHANGED
@@ -37,13 +37,13 @@ export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapt
37
37
  export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, type RetryOptions, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, type TurbineDriver, withRetry, } from './client.js';
38
38
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, DialectIntrospector, DialectMigrator, DialectName, InsertStatementInput, IntrospectOptions as DialectIntrospectOptions, ResultStrategy, StreamableConnection, UpsertStatementInput, } from './dialect.js';
39
39
  export { postgresDialect } from './dialect.js';
40
- export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, type PipelineResultSlot, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
40
+ export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, type PipelineResultSlot, ReadOnlyError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
41
41
  export { type GenerateOptions, generate } from './generate.js';
42
42
  export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapt
37
37
  export { TransactionClient, TurbineClient, withRetry, } from './client.js';
38
38
  export { postgresDialect } from './dialect.js';
39
39
  // Error types
40
- export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
40
+ export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, ReadOnlyError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
41
41
  // Code generation
42
42
  export { generate } from './generate.js';
43
43
  // Introspection
package/dist/mssql.js CHANGED
@@ -472,6 +472,11 @@ export const mssqlDialect = {
472
472
  supportsLateralJoin: false,
473
473
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
474
474
  supportsAdvisoryLock: true,
475
+ // No in-band EXPLAIN: SQL Server's SHOWPLAN is a session toggle
476
+ // (SET SHOWPLAN_ALL ON), not a statement prefix, so a compiled query cannot
477
+ // be explained in one round-trip. Override the inherited Postgres `EXPLAIN`
478
+ // to absent → QueryInterface.explain() throws E017.
479
+ explainQuery: undefined,
475
480
  // FOR JSON over zero rows is NULL → coalesced in the relation override.
476
481
  aggSupportsInlineOrderBy: false,
477
482
  jsonPathSupport: 'limited',
package/dist/mysql.js CHANGED
@@ -373,6 +373,10 @@ export const mysqlDialect = {
373
373
  supportsLateralJoin: false,
374
374
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
375
375
  supportsAdvisoryLock: true,
376
+ // Plain `EXPLAIN` (one row of tabular plan columns) works on every supported
377
+ // MySQL 8.0.x; the readable `FORMAT=TREE` variant only exists from 8.0.16 and
378
+ // the engine floor here is 8.0.0. Plan text is a diagnostic, not a contract.
379
+ explainQuery: { prefix: 'EXPLAIN' },
376
380
  // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
377
381
  // rewrite for every ordered to-many relation.
378
382
  aggSupportsInlineOrderBy: false,
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
@@ -539,10 +591,14 @@ export declare function encodePowqlLiteral(value: unknown): string;
539
591
  export declare function materializePowql(powql: string, params: unknown[]): string;
540
592
  /**
541
593
  * 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`/
594
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
595
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
596
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
597
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
598
+ * legacy string wire, which takes **no params array** (its `query(powql)`
599
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
600
+ * literal via {@link materializePowql} before the text is handed to the engine.
601
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
546
602
  * `rollback`) are issued serially as ordinary queries.
547
603
  */
548
604
  export declare class PowdbEmbeddedPool implements PgCompatPool {
@@ -563,14 +619,24 @@ export declare class PowdbEmbeddedPool implements PgCompatPool {
563
619
  private readonly poolHoldRef;
564
620
  /**
565
621
  * 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).
622
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
623
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
624
+ * addon has no such method, so it stays false and the legacy string wire is
625
+ * used.
568
626
  */
569
627
  readonly capabilities: PowdbCapabilities;
570
628
  /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
571
629
  readonly retryStaleReads: boolean;
630
+ /**
631
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
632
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
633
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
634
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
635
+ * backstop for raw / injected paths.
636
+ */
637
+ readonly readonly: boolean;
572
638
  constructor(db: EmbeddedDatabase, options?: PowdbPoolOptions);
573
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
639
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
574
640
  private exec;
575
641
  /**
576
642
  * Run one statement, gating transaction control. `holdRef` scopes the gate
@@ -586,7 +652,17 @@ export declare class PowdbEmbeddedPool implements PgCompatPool {
586
652
  export { introspectPowdbDatabase, type PowdbExec, type PowdbIntrospectOptions, } from './powdb-introspect.js';
587
653
  export { PowqlInterface } from './powql.js';
588
654
  /** Options for {@link turbinePowDB}. */
589
- export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited'> {
655
+ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited' | 'relationLoadStrategy'> {
656
+ /**
657
+ * Client-level default `with`-relation load strategy. On PowDB the default is
658
+ * the batched N+1 loaders; setting `'join'` opts INTO native PowQL server-side
659
+ * joins for eligible top-level relations (ineligible ones, e.g. a paged parent
660
+ * or a nested `with`, fall back to the loaders per-relation and silently). A
661
+ * per-query `relationLoadStrategy` arg still overrides this. Requires an engine
662
+ * that advertises `serverJoins` (PowDB ≥ 0.13); a per-query `'join'` on an
663
+ * older engine throws E017, a client-level default silently falls back.
664
+ */
665
+ relationLoadStrategy?: TurbineConfig['relationLoadStrategy'];
590
666
  /** Max pooled connections (default 10). Networked transport only. */
591
667
  connectionLimit?: number;
592
668
  /**
@@ -623,6 +699,18 @@ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'de
623
699
  * E017).
624
700
  */
625
701
  assumeEngineVersion?: string;
702
+ /**
703
+ * Mark the client read-only: a write (or a transaction `begin`) fails fast
704
+ * locally with a {@link ReadOnlyError} (E018) before it reaches the wire,
705
+ * rather than round-tripping to the engine's refusal. Works on both
706
+ * transports (a networked pool bound to a read-only role, or an embedded
707
+ * handle). An `{ embedded, readonly: true }` target implies this. Default
708
+ * `false`.
709
+ *
710
+ * Ignored when you inject an already-constructed {@link PowdbPool} (it carries
711
+ * its own {@link PowdbPoolOptions}); set it on that pool's constructor instead.
712
+ */
713
+ readonly?: boolean;
626
714
  /**
627
715
  * Driver-module injection for the networked target forms (URL / host+port):
628
716
  * bypasses the dynamic `import('@zvndev/powdb-client')` and uses this object
@@ -630,6 +718,14 @@ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'de
630
718
  * connections) and advanced embedding; everyday callers never set it.
631
719
  */
632
720
  powdbClientModule?: PowdbModule;
721
+ /**
722
+ * Driver-module injection for the **embedded** target form (`{ embedded }`):
723
+ * bypasses the dynamic `import('@zvndev/powdb-embedded')` and uses this object
724
+ * as the addon module instead. Intended for tests (a fake `Database` factory
725
+ * that records how the handle was opened) and advanced embedding; everyday
726
+ * callers never set it. The symmetric counterpart to {@link powdbClientModule}.
727
+ */
728
+ powdbEmbeddedModule?: EmbeddedModule;
633
729
  }
634
730
  /**
635
731
  * Selects the **embedded** transport — an in-process `@zvndev/powdb-embedded`
@@ -655,6 +751,16 @@ export interface TurbinePowdbEmbeddedTarget {
655
751
  syncMode?: 'full' | 'normal' | 'off';
656
752
  /** Per-query memory budget in bytes (requires `@zvndev/powdb-embedded` ≥ 0.7.1). */
657
753
  memoryLimit?: number;
754
+ /**
755
+ * Open the data directory read-only for snapshot serving (requires
756
+ * `@zvndev/powdb-embedded` ≥ 0.14: `openReadOnly` / `openReadOnlyWithMemoryLimit`).
757
+ * A write through a read-only handle is refused by the engine with
758
+ * `readonly mode: statement requires a writer …` (→ {@link ReadOnlyError}, E018),
759
+ * and Turbine additionally fails writes fast locally (this implies the pool's
760
+ * `readonly` flag). Meaningless together with `syncMode` (a read-only engine
761
+ * never writes), setting both throws a {@link ValidationError}.
762
+ */
763
+ readonly?: boolean;
658
764
  }
659
765
  /**
660
766
  * Bind Turbine to PowDB. `target` is one of: