turbine-orm 0.30.0 → 0.31.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.
@@ -7,9 +7,10 @@
7
7
  * and execution rather than filter-shape bookkeeping.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = void 0;
10
+ exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = exports.COLUMN_REF_OPERATORS = void 0;
11
11
  exports.isWhereOperator = isWhereOperator;
12
12
  exports.isUnmatchedPlainObject = isUnmatchedPlainObject;
13
+ exports.isColumnRef = isColumnRef;
13
14
  exports.fingerprintOperatorShape = fingerprintOperatorShape;
14
15
  exports.assertBindableEqualsOperand = assertBindableEqualsOperand;
15
16
  exports.sortedKeys = sortedKeys;
@@ -24,6 +25,7 @@ exports.validateTextSearchConfig = validateTextSearchConfig;
24
25
  exports.isVectorFilter = isVectorFilter;
25
26
  exports.isVectorOrderBy = isVectorOrderBy;
26
27
  exports.isOrderBySpec = isOrderBySpec;
28
+ exports.isJsonPathOrderBy = isJsonPathOrderBy;
27
29
  exports.normalizeOrderBy = normalizeOrderBy;
28
30
  const errors_js_1 = require("../errors.js");
29
31
  const utils_js_1 = require("./utils.js");
@@ -56,17 +58,48 @@ function isUnmatchedPlainObject(value) {
56
58
  const proto = Object.getPrototypeOf(value);
57
59
  return proto === Object.prototype || proto === null;
58
60
  }
61
+ /**
62
+ * Operator keys that accept a {@link ColumnRef} (`{ col: 'otherField' }`)
63
+ * value for column-to-column comparison. `in`/`notIn` and the LIKE operators
64
+ * take values only.
65
+ */
66
+ exports.COLUMN_REF_OPERATORS = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte']);
67
+ /**
68
+ * Check if an operator value is a column reference: a plain object whose ONLY
69
+ * key is `col` with a string value. Anything else (extra keys, non-string
70
+ * `col`) is treated as a plain value so JSON payloads that merely contain a
71
+ * `col` property keep their equality meaning.
72
+ */
73
+ function isColumnRef(value) {
74
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
75
+ return false;
76
+ const keys = Object.keys(value);
77
+ return keys.length === 1 && keys[0] === 'col' && typeof value.col === 'string';
78
+ }
59
79
  /**
60
80
  * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
61
81
  * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
62
82
  * param pushed), so null-ness is part of the shape — without it a cache entry
63
83
  * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
84
+ *
85
+ * Column references ({@link ColumnRef}) compile the referenced column into the
86
+ * SQL TEXT (no param bound), so the referenced field name is part of the shape
87
+ *: `{ equals: { col: 'a' } }` and `{ equals: { col: 'b' } }` must never share
88
+ * a cache entry. The name is JSON-encoded so exotic field names cannot collide
89
+ * with other fingerprint tokens.
64
90
  */
65
91
  function fingerprintOperatorShape(value) {
66
92
  const obj = value;
67
93
  const opKeys = Object.keys(obj)
68
94
  .filter((k) => k !== 'mode')
69
- .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
95
+ .map((k) => {
96
+ const v = obj[k];
97
+ if ((k === 'equals' || k === 'not') && v === null)
98
+ return `${k}:null`;
99
+ if (exports.COLUMN_REF_OPERATORS.has(k) && isColumnRef(v))
100
+ return `${k}:col(${JSON.stringify(v.col)})`;
101
+ return k;
102
+ })
70
103
  .sort();
71
104
  const modeStr = value.mode === 'insensitive' ? ':i' : '';
72
105
  return `op(${opKeys.join(',')}${modeStr})`;
@@ -269,6 +302,20 @@ function isVectorOrderBy(value) {
269
302
  function isOrderBySpec(value) {
270
303
  return typeof value === 'object' && value !== null && !Array.isArray(value) && 'sort' in value;
271
304
  }
305
+ /**
306
+ * Check if an orderBy value is a JSON-path ordering: `{ path: [...] }` with an
307
+ * ARRAY path. The array requirement disambiguates from relation orderBy values
308
+ * (whose entries are directions/specs keyed by target column: a target column
309
+ * literally named `path` maps to a string direction, never an array), and the
310
+ * `distance`/`sort` exclusions keep vector and spec shapes out.
311
+ */
312
+ function isJsonPathOrderBy(value) {
313
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
314
+ return false;
315
+ if ('distance' in value || 'sort' in value)
316
+ return false;
317
+ return Array.isArray(value.path);
318
+ }
272
319
  /**
273
320
  * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
274
321
  * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
package/dist/client.d.ts CHANGED
@@ -68,6 +68,19 @@ export interface PgCompatPoolClient {
68
68
  * sequential.
69
69
  */
70
70
  readonly supportsPipelining?: boolean;
71
+ /**
72
+ * Optional engine seam: scope a transaction's user callback to its own
73
+ * async subtree. When present, `TurbineClient.transaction` / `$transaction`
74
+ * invoke the callback as `wrapTransactionCallback(() => fn(tx))` instead of
75
+ * `fn(tx)` directly. Single-writer engines (PowDB) implement it with
76
+ * `AsyncLocalStorage.run()` to plant their re-entrancy marker so that it
77
+ * exists ONLY inside the callback's async subtree: a transaction opened
78
+ * from inside the callback is detected as re-entrant (typed E017), while
79
+ * the CALLER's context stays unmarked, so same-tick sibling transactions
80
+ * queue FIFO instead of being falsely flagged. Absent on pg and every other
81
+ * engine, in which case the callback runs unwrapped (zero behavior change).
82
+ */
83
+ wrapTransactionCallback?<R>(fn: () => Promise<R>): Promise<R>;
71
84
  }
72
85
  /**
73
86
  * Minimal pg-compatible pool. Pass any driver that satisfies this interface
package/dist/client.js CHANGED
@@ -775,7 +775,12 @@ export class TurbineClient {
775
775
  try {
776
776
  await client.query(this.dialect.beginStatement());
777
777
  began = true;
778
- const result = await fn(client);
778
+ // Engine seam: single-writer engines scope their transaction re-entrancy
779
+ // marker to the callback's async subtree (see
780
+ // PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
781
+ const wrap = client.wrapTransactionCallback;
782
+ // `.call` erases the generic, so the callback's Promise<T> is re-asserted.
783
+ const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
779
784
  await client.query(this.dialect.commitStatement());
780
785
  return result;
781
786
  }
@@ -868,6 +873,12 @@ export class TurbineClient {
868
873
  }
869
874
  }
870
875
  let result;
876
+ // Engine seam: when the checked-out connection exposes
877
+ // wrapTransactionCallback (single-writer engines such as PowDB), run the user
878
+ // callback through it so the engine can scope its re-entrancy marker to
879
+ // the callback's async subtree. All other drivers: plain fn(tx).
880
+ const wrap = client.wrapTransactionCallback;
881
+ const runCallback = () => (wrap ? wrap.call(client, () => fn(tx)) : fn(tx));
871
882
  if (timeout) {
872
883
  // Race between the function and a timeout. If the timeout fires we
873
884
  // need to actually abort the in-flight query — otherwise the backend
@@ -889,14 +900,14 @@ export class TurbineClient {
889
900
  }, timeout);
890
901
  });
891
902
  try {
892
- result = await Promise.race([fn(tx), timeoutPromise]);
903
+ result = await Promise.race([runCallback(), timeoutPromise]);
893
904
  }
894
905
  finally {
895
906
  clearTimeout(timer);
896
907
  }
897
908
  }
898
909
  else {
899
- result = await fn(tx);
910
+ result = await runCallback();
900
911
  }
901
912
  await client.query(this.dialect.commitStatement());
902
913
  if (this.logging) {
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ 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 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 GroupByArgs, type HavingClause, type JsonFilter, 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 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 GroupByArgs, type HavingClause, type JsonFilter, 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 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/mssql.js CHANGED
@@ -745,11 +745,32 @@ function buildForJsonSubquery(dialect, ctx) {
745
745
  if (hasOrder) {
746
746
  const orderBy = orderEntries
747
747
  .map(([k, dir]) => {
748
- const col = camelToSnake(k);
748
+ // FOR JSON nested ordering supports plain directions and { sort }
749
+ // specs only. Object shapes the core builder compiles with params
750
+ // (JSON-path / vector / relation ordering) must throw here: the
751
+ // shared param-collect mirror is gated on the native path, so a
752
+ // silently-ignored object would desync SQL text from params.
753
+ let rawDir = dir;
754
+ if (typeof dir === 'object' && dir !== null) {
755
+ const sortValue = dir.sort;
756
+ if (typeof sortValue !== 'string') {
757
+ throw new ValidationError(`[turbine] Nested orderBy on "${k}" (table "${targetTable}"): only plain directions and ` +
758
+ `{ sort } specs are supported inside a relation orderBy on SQL Server.`);
759
+ }
760
+ if (dir.nulls !== undefined) {
761
+ throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', 'sqlserver', 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
762
+ }
763
+ rawDir = sortValue;
764
+ }
765
+ // columnMap-first resolution (camelToSnake fallback): matches the
766
+ // core builder's nested orderBy path so camelCase-named DB columns
767
+ // resolve on SQL Server too.
768
+ const col = targetMeta.columnMap[k] ?? camelToSnake(k);
749
769
  if (!targetMeta.allColumns.includes(col)) {
750
- throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
770
+ throw new ValidationError(`[turbine] Unknown field "${k}" in orderBy on table "${targetTable}". ` +
771
+ `Known fields: ${Object.keys(targetMeta.columnMap).join(', ') || '(none)'}.`);
751
772
  }
752
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
773
+ const safeDir = String(rawDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
753
774
  return `${a}.${q(col)} ${safeDir}`;
754
775
  })
755
776
  .join(', ');
package/dist/powdb.d.ts CHANGED
@@ -120,6 +120,18 @@ interface PowdbClientPool {
120
120
  withClient<T>(fn: (c: PowdbClient) => Promise<T>): Promise<T>;
121
121
  close(): Promise<void>;
122
122
  }
123
+ interface PowdbModule {
124
+ Client: {
125
+ connect(opts: PowdbConnOptions): Promise<PowdbClient>;
126
+ };
127
+ Pool: new (opts: PowdbConnOptions & {
128
+ max?: number;
129
+ }) => PowdbClientPool;
130
+ isPowDBError?(err: unknown): err is {
131
+ code: string;
132
+ message: string;
133
+ };
134
+ }
123
135
  /** Connection options for {@link turbinePowDB} — host/port, not a connection string. */
124
136
  export interface PowdbConnOptions {
125
137
  host: string;
@@ -260,8 +272,22 @@ export declare class PowdbPool implements PgCompatPool {
260
272
  private readonly txGate;
261
273
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
262
274
  private poolHold;
275
+ /**
276
+ * Clients currently checked out via {@link connect}. The driver pool's
277
+ * `close()` only closes IDLE clients (checked-out ones are documented as the
278
+ * caller's responsibility), so {@link end} destroys these explicitly;
279
+ * otherwise a `disconnect()` racing an unreleased connection would leave a
280
+ * live socket holding the process open until the server's idle timeout.
281
+ */
282
+ private readonly checkedOut;
263
283
  constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam, options?: PowdbPoolOptions);
264
284
  query(text: QueryArg, values?: unknown[]): Promise<any>;
285
+ /**
286
+ * Typed guard mirroring {@link PowdbEmbeddedPool}: after `end()` the driver
287
+ * pool throws a raw `Error('pool closed')` that {@link wrapPowdbError}
288
+ * cannot classify — surface the same ConnectionError on both transports.
289
+ */
290
+ private assertOpen;
265
291
  connect(): Promise<PgCompatPoolClient>;
266
292
  end(): Promise<void>;
267
293
  }
@@ -361,6 +387,13 @@ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'de
361
387
  * E017 — queueing that shape would deadlock.
362
388
  */
363
389
  transactionQueueTimeoutMs?: number;
390
+ /**
391
+ * Driver-module injection for the networked target forms (URL / host+port):
392
+ * bypasses the dynamic `import('@zvndev/powdb-client')` and uses this object
393
+ * as the driver module instead. Intended for tests (a fake pool that counts
394
+ * connections) and advanced embedding; everyday callers never set it.
395
+ */
396
+ powdbClientModule?: PowdbModule;
364
397
  }
365
398
  /**
366
399
  * Selects the **embedded** transport — an in-process `@zvndev/powdb-embedded`
package/dist/powdb.js CHANGED
@@ -467,6 +467,11 @@ export function wrapPowdbError(err) {
467
467
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
468
468
  return new NotNullViolationError({ column: m?.[1], cause: err });
469
469
  }
470
+ // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
471
+ // no .code — classify by message so both transports surface E004.
472
+ if (/pool closed|pool acquire timeout/i.test(msg)) {
473
+ return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
474
+ }
470
475
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
471
476
  // connection held the single global write lock past the server's
472
477
  // --tx-wait-timeout-ms. Retryable timeout, not a query defect.
@@ -544,6 +549,13 @@ function reentrantTransactionError() {
544
549
  * waits without limit.
545
550
  */
546
551
  export const DEFAULT_TX_QUEUE_TIMEOUT_MS = 30_000;
552
+ /**
553
+ * Upper bound on the best-effort `rollback` a release-with-open-hold fires
554
+ * before handing the gate to the next queued transaction. Keeps a dead socket
555
+ * from wedging the FIFO queue while still letting the engine drop its global
556
+ * write lock cleanly in the normal case.
557
+ */
558
+ const RELEASE_ROLLBACK_TIMEOUT_MS = 2_000;
547
559
  const powdbTxStorage = new AsyncLocalStorage();
548
560
  /**
549
561
  * FIFO gate serializing transactions across a whole pool. PowDB holds one
@@ -561,25 +573,35 @@ const powdbTxStorage = new AsyncLocalStorage();
561
573
  * timeout, then returns a {@link PowdbTxHold} the caller finishes on
562
574
  * commit / rollback / connection release.
563
575
  *
564
- * Context propagation relies on `AsyncLocalStorage.enterWith()` running in the
565
- * caller's *synchronous* execution: the pools call `acquire()` from the
566
- * synchronous prologue of `query('begin')`, so when `TurbineClient.$transaction`
567
- * awaits that begin, its continuation and therefore the user callback — runs
568
- * with the marker set, while sibling contexts (concurrent transactions, the
569
- * caller after `$transaction` resolves) captured their snapshots earlier and
570
- * never see it. Markers form a chain ({@link PowdbTxContext.parent}) so
571
- * transactions nested across DIFFERENT pools cannot shadow an outer marker on
572
- * this gate the re-entrancy check walks every live ancestor.
576
+ * Context propagation: `acquire()` only READS the async context (the
577
+ * chain-walking check in its prologue); it never writes it. The marker is
578
+ * planted by the pool's `wrapTransactionCallback` (`TurbineClient` invokes
579
+ * the user callback as `powdbTxStorage.run(hold.ctx, fn)`), so it exists
580
+ * exclusively inside the transaction CALLBACK's async subtree. Everything
581
+ * launched from inside the callback (table ops on the `tx` client, a
582
+ * fire-and-forget `db.$transaction`, nested-write implicit transactions)
583
+ * inherits it; the CALLER's context stays unmarked. This is load-bearing: the
584
+ * pre-0.31 implementation used `enterWith()` in acquire's prologue, which
585
+ * mutates the caller's shared context. On a cold client the FIRST same-tick
586
+ * burst of `db.$transaction` calls saw call #1's live marker from every
587
+ * sibling and falsely threw re-entrant E017 (9/10 rejected in production;
588
+ * one warm-up transaction masked it because its pruned `done` marker changed
589
+ * the propagation shape). With `run()` the sibling contexts are unmarked by
590
+ * construction, so they queue FIFO as intended. Markers form a chain
591
+ * ({@link PowdbTxContext.parent}) so transactions nested across DIFFERENT
592
+ * pools cannot shadow an outer marker on this gate; the re-entrancy check
593
+ * walks every live ancestor.
573
594
  *
574
- * **Residual limitation:** a re-entrant begin issued from an async context
575
- * created BEFORE the transaction opened (e.g. a job-queue worker loop whose
576
- * continuations captured their ALS snapshot up front) carries no marker, so it
577
- * cannot be told apart from a legitimate independent concurrent transaction.
578
- * It queues FIFO and because the open transaction is awaiting it times out
579
- * after `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather
580
- * than throwing E017 instantly. The 30s default is the backstop for exactly
581
- * this case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
582
- * paths that may start transactions from pre-existing async contexts.
595
+ * **Residual limitation:** a transaction begun OUTSIDE a `$transaction`
596
+ * callback plants no marker (a manual raw `begin` span, or a worker loop
597
+ * whose continuations captured their context before the transaction opened).
598
+ * A deadlocking re-entrant begin from such a context cannot be told apart
599
+ * from a legitimate independent concurrent transaction: it queues FIFO and,
600
+ * because the open transaction is awaiting it, times out after
601
+ * `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather than
602
+ * throwing E017 instantly. The 30s default is the backstop for exactly this
603
+ * case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
604
+ * paths that may start transactions from unmarked contexts.
583
605
  */
584
606
  class PowdbTxGate {
585
607
  queueTimeoutMs;
@@ -589,12 +611,14 @@ class PowdbTxGate {
589
611
  this.queueTimeoutMs = queueTimeoutMs;
590
612
  }
591
613
  /**
592
- * Take a place in the transaction queue. MUST be invoked in the same
593
- * synchronous execution as the caller's `begin` (no `await` before it) so
594
- * the re-entrancy marker propagates into the transaction's async scope.
614
+ * Take a place in the transaction queue. The prologue walks the caller's
615
+ * marker chain (planted around transaction callbacks by the pool's
616
+ * `wrapTransactionCallback`) and throws re-entrant E017 when any live
617
+ * ancestor holds THIS gate. It never marks the caller's context itself;
618
+ * the returned hold carries the fresh marker (`hold.ctx`) for
619
+ * `wrapTransactionCallback` to scope around the user callback.
595
620
  */
596
621
  async acquire() {
597
- // --- synchronous section (runs before the caller's first await) ---
598
622
  // Walk the WHOLE marker chain, not just the innermost marker: with two
599
623
  // pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
600
624
  // the dbA ancestor is still open — queueing the inner dbA begin behind it
@@ -611,7 +635,6 @@ class PowdbTxGate {
611
635
  }
612
636
  }
613
637
  const ctx = { gate: this, done: false, parent };
614
- powdbTxStorage.enterWith(ctx);
615
638
  let handOff;
616
639
  const finished = new Promise((resolve) => {
617
640
  handOff = resolve;
@@ -619,6 +642,7 @@ class PowdbTxGate {
619
642
  const ahead = this.tail;
620
643
  this.tail = ahead.then(() => finished);
621
644
  const hold = {
645
+ ctx,
622
646
  finish: () => {
623
647
  if (ctx.done)
624
648
  return;
@@ -696,6 +720,14 @@ export class PowdbPool {
696
720
  txGate;
697
721
  /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
698
722
  poolHold = null;
723
+ /**
724
+ * Clients currently checked out via {@link connect}. The driver pool's
725
+ * `close()` only closes IDLE clients (checked-out ones are documented as the
726
+ * caller's responsibility), so {@link end} destroys these explicitly;
727
+ * otherwise a `disconnect()` racing an unreleased connection would leave a
728
+ * live socket holding the process open until the server's idle timeout.
729
+ */
730
+ checkedOut = new Set();
699
731
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
700
732
  this.pool = pool;
701
733
  this.toParam = toParam;
@@ -703,12 +735,13 @@ export class PowdbPool {
703
735
  }
704
736
  // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
705
737
  async query(text, values) {
738
+ this.assertOpen();
706
739
  const { text: powql, params } = normalizeQueryArgs(text, values);
707
740
  const ctl = txControl(powql);
708
741
  if (ctl === 'begin') {
709
- // Gate BEFORE touching the engine: a re-entrant begin throws fast, an
710
- // independent concurrent one waits its FIFO turn. (acquire()'s
711
- // re-entrancy check + context mark run synchronously right here.)
742
+ // Gate BEFORE touching the engine: a begin from inside an active
743
+ // transaction callback throws re-entrant E017 fast; an independent
744
+ // concurrent one waits its FIFO turn.
712
745
  this.poolHold = await this.txGate.acquire();
713
746
  }
714
747
  if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
@@ -736,8 +769,26 @@ export class PowdbPool {
736
769
  }
737
770
  }
738
771
  }
772
+ /**
773
+ * Typed guard mirroring {@link PowdbEmbeddedPool}: after `end()` the driver
774
+ * pool throws a raw `Error('pool closed')` that {@link wrapPowdbError}
775
+ * cannot classify — surface the same ConnectionError on both transports.
776
+ */
777
+ assertOpen() {
778
+ if (this.closed) {
779
+ throw new ConnectionError('[turbine] The PowDB pool is closed — disconnect() was already called on this client.');
780
+ }
781
+ }
739
782
  async connect() {
740
- const client = await this.pool.acquire();
783
+ this.assertOpen();
784
+ let client;
785
+ try {
786
+ client = await this.pool.acquire();
787
+ }
788
+ catch (err) {
789
+ throw wrapPowdbError(err);
790
+ }
791
+ this.checkedOut.add(client);
741
792
  let broken = false;
742
793
  /** The gate hold of the transaction begun through THIS connection (if any). */
743
794
  let hold = null;
@@ -759,9 +810,10 @@ export class PowdbPool {
759
810
  const { text: powql, params } = normalizeQueryArgs(text, values);
760
811
  const ctl = txControl(powql);
761
812
  if (ctl === 'begin') {
762
- // Gate BEFORE hitting the engine a re-entrant begin throws fast
763
- // instead of blocking on the global write lock the open tx holds;
764
- // an independent concurrent begin queues FIFO.
813
+ // Gate BEFORE hitting the engine: a begin from inside an active
814
+ // transaction callback throws re-entrant E017 fast instead of
815
+ // blocking on the global write lock the open tx holds; an
816
+ // independent concurrent begin queues FIFO.
765
817
  hold = await this.txGate.acquire();
766
818
  }
767
819
  if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
@@ -789,16 +841,60 @@ export class PowdbPool {
789
841
  }
790
842
  }
791
843
  },
792
- release: () => {
793
- // Releasing this connection ends its transaction scope. Finish the
794
- // hold as a safety net so a tx torn down without an explicit
795
- // commit/rollback (e.g. a timeout that destroys the connection) hands
796
- // the gate to the next queued transaction instead of wedging the
797
- // queue. Only THIS connection's hold releasing an unrelated (read)
798
- // connection never touches another transaction's slot.
799
- hold?.finish();
844
+ // Single-writer re-entrancy scoping: TurbineClient runs the user's
845
+ // transaction callback through this, so the gate's marker lives ONLY
846
+ // in the callback's async subtree (everything inside it, tx table ops
847
+ // and fire-and-forget db.$transaction alike, inherits it; the caller's
848
+ // context stays unmarked). `hold.ctx` is the same object `finish()` flips, so
849
+ // done-pruning keeps working for contexts that outlive the callback.
850
+ wrapTransactionCallback: (fn) => {
851
+ const ctx = hold?.ctx;
852
+ return ctx ? powdbTxStorage.run(ctx, fn) : fn();
853
+ },
854
+ release: (err) => {
855
+ // Releasing this connection ends its transaction scope. pg semantics:
856
+ // a truthy `err` means "destroy, don't re-idle" — client.ts's
857
+ // $transaction timeout path relies on that to keep an abandoned
858
+ // callback's connection out of the pool. Additionally, an OPEN hold
859
+ // here means the tx begun on this connection never saw commit/rollback
860
+ // (timeout teardown or caller bug): fire a best-effort bounded
861
+ // `rollback` FIRST so the engine drops its global write lock and the
862
+ // server-side transaction ends (destroying the socket alone leaves it
863
+ // open until the server's idle timeout), THEN hand the gate to the
864
+ // next queued transaction. If the rollback fails or times out the
865
+ // connection is treated as broken and destroyed. Never throws — a
866
+ // teardown error must not mask the transaction's real outcome.
867
+ const openHold = hold;
800
868
  hold = null;
801
- return broken ? this.pool.destroy(client) : this.pool.release(client);
869
+ this.checkedOut.delete(client);
870
+ const teardown = async () => {
871
+ let rolledBack = false;
872
+ if (openHold) {
873
+ try {
874
+ await Promise.race([
875
+ client.query('rollback', []).then(() => {
876
+ rolledBack = true;
877
+ }),
878
+ new Promise((resolve) => {
879
+ const t = setTimeout(resolve, RELEASE_ROLLBACK_TIMEOUT_MS);
880
+ t.unref?.();
881
+ }),
882
+ ]);
883
+ }
884
+ catch {
885
+ /* best-effort */
886
+ }
887
+ openHold.finish();
888
+ }
889
+ const mustDestroy = broken || Boolean(err) || (openHold !== null && !rolledBack);
890
+ try {
891
+ await (mustDestroy ? this.pool.destroy(client) : this.pool.release(client));
892
+ }
893
+ catch {
894
+ /* the pool may already be closed */
895
+ }
896
+ };
897
+ void teardown();
802
898
  },
803
899
  };
804
900
  }
@@ -806,7 +902,16 @@ export class PowdbPool {
806
902
  if (this.closed)
807
903
  return;
808
904
  this.closed = true;
905
+ // close() rejects pending waiters and closes every IDLE client…
809
906
  await this.pool.close();
907
+ // …but NOT checked-out ones (documented in @zvndev/powdb-client: "callers
908
+ // that still hold one when close() is called are responsible for closing
909
+ // it themselves"). Destroy any stragglers so end() never leaves a live
910
+ // socket keeping the process alive until the server's idle timeout.
911
+ for (const client of this.checkedOut) {
912
+ this.pool.destroy(client);
913
+ }
914
+ this.checkedOut.clear();
810
915
  }
811
916
  }
812
917
  /** Normalize the embedded addon's loosely-typed result into a {@link PowdbResult}. */
@@ -938,11 +1043,14 @@ export class PowdbEmbeddedPool {
938
1043
  * transaction is holding.
939
1044
  */
940
1045
  async run(powql, params, holdRef) {
1046
+ if (this.closed) {
1047
+ throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1048
+ }
941
1049
  const ctl = txControl(powql);
942
1050
  if (ctl === 'begin') {
943
- // Gate BEFORE hitting the engine re-entrant begins throw fast,
944
- // independent concurrent ones wait their FIFO turn. (acquire()'s
945
- // re-entrancy check + context mark run synchronously right here.)
1051
+ // Gate BEFORE hitting the engine: a begin from inside an active
1052
+ // transaction callback throws re-entrant E017 fast; independent
1053
+ // concurrent ones wait their FIFO turn.
946
1054
  holdRef.hold = await this.txGate.acquire();
947
1055
  }
948
1056
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
@@ -988,11 +1096,31 @@ export class PowdbEmbeddedPool {
988
1096
  const { text: powql, params } = normalizeQueryArgs(text, values);
989
1097
  return this.run(powql, params, holdRef);
990
1098
  },
1099
+ // Scope the gate's re-entrancy marker to the user callback's async
1100
+ // subtree (see PowdbPool.connect(), identical contract).
1101
+ wrapTransactionCallback: (fn) => {
1102
+ const ctx = holdRef.hold?.ctx;
1103
+ return ctx ? powdbTxStorage.run(ctx, fn) : fn();
1104
+ },
991
1105
  release: () => {
992
1106
  // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
993
- // without an explicit commit/rollback must not wedge the queue.
994
- holdRef.hold?.finish();
995
- holdRef.hold = null;
1107
+ // without an explicit commit/rollback must not wedge the queue — and
1108
+ // on the ONE shared embedded handle its open engine transaction must
1109
+ // actually be rolled back before the gate moves on, or the next
1110
+ // transaction's work interleaves into it. run() owns the
1111
+ // finish/null-out in its commit/rollback finally; the .finally here
1112
+ // is the fallback when run() itself rejects.
1113
+ const h = holdRef.hold;
1114
+ if (!h)
1115
+ return;
1116
+ void this.run('rollback', [], holdRef)
1117
+ .catch(() => {
1118
+ /* best-effort */
1119
+ })
1120
+ .finally(() => {
1121
+ h.finish();
1122
+ holdRef.hold = null;
1123
+ });
996
1124
  },
997
1125
  };
998
1126
  }
@@ -1000,8 +1128,11 @@ export class PowdbEmbeddedPool {
1000
1128
  if (this.closed)
1001
1129
  return;
1002
1130
  // The addon exposes no explicit close — drop the reference and let GC /
1003
- // the engine's checkpoint flush. Caveat: durability is checkpoint-bound, so
1004
- // hold the process open long enough for the final WAL flush in short scripts.
1131
+ // the engine's checkpoint flush. Marking the pool closed makes later
1132
+ // queries fail with a typed ConnectionError instead of silently running
1133
+ // against a handle the caller believes is gone. Caveat: durability is
1134
+ // checkpoint-bound, so hold the process open long enough for the final
1135
+ // WAL flush in short scripts.
1005
1136
  this.closed = true;
1006
1137
  }
1007
1138
  }
@@ -1106,7 +1237,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1106
1237
  let owns = false;
1107
1238
  const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
1108
1239
  if (typeof target === 'string') {
1109
- const mod = await loadPowdb();
1240
+ const mod = options.powdbClientModule ?? (await loadPowdb());
1110
1241
  const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
1111
1242
  await assertNetworkedVersion(clientPool);
1112
1243
  pool = new PowdbPool(clientPool, undefined, poolOptions);
@@ -1124,7 +1255,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1124
1255
  pool = new PowdbPool(target, undefined, poolOptions);
1125
1256
  }
1126
1257
  else {
1127
- const mod = await loadPowdb();
1258
+ const mod = options.powdbClientModule ?? (await loadPowdb());
1128
1259
  const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
1129
1260
  await assertNetworkedVersion(clientPool);
1130
1261
  pool = new PowdbPool(clientPool, undefined, poolOptions);
@@ -1142,7 +1273,23 @@ export async function turbinePowDB(target, schema, options = {}) {
1142
1273
  warnOnUnlimited: options.warnOnUnlimited,
1143
1274
  queryInterfaceFactory,
1144
1275
  }, schema);
1145
- if (!owns) {
1276
+ if (owns) {
1277
+ // Turbine built this pool / embedded handle, so disconnect()/end() must
1278
+ // close it. client.ts sees TurbineConfig.pool as EXTERNAL (ownsPool =
1279
+ // false) and skips pool.end(); before this patch an owned networked
1280
+ // client leaked its live socket(s) on disconnect(), holding the process
1281
+ // open until powdb-server's idle timeout (~300s) closed them. Consistent
1282
+ // with turbineMssql's owned-pool patch.
1283
+ const baseDisconnect = client.disconnect.bind(client);
1284
+ const close = async () => {
1285
+ await baseDisconnect();
1286
+ await pool.end();
1287
+ };
1288
+ const patch = client;
1289
+ patch.disconnect = close;
1290
+ patch.end = close;
1291
+ }
1292
+ else {
1146
1293
  // Injected pool — the caller owns its lifecycle.
1147
1294
  client.disconnect = async () => { };
1148
1295
  }