turbine-orm 0.28.3 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli/index.js +5 -0
  3. package/dist/cjs/cli/mcp.js +22 -92
  4. package/dist/cjs/client.js +69 -5
  5. package/dist/cjs/generate.js +71 -25
  6. package/dist/cjs/index.js +4 -1
  7. package/dist/cjs/introspect.js +350 -120
  8. package/dist/cjs/mssql.js +18 -133
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +440 -81
  12. package/dist/cjs/powql.js +49 -25
  13. package/dist/cjs/query/builder.js +290 -23
  14. package/dist/cjs/query/filters.js +32 -1
  15. package/dist/cjs/schema-metadata.js +316 -0
  16. package/dist/cjs/sqlite.js +8 -89
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +5 -0
  19. package/dist/cli/mcp.d.ts +18 -0
  20. package/dist/cli/mcp.js +22 -93
  21. package/dist/client.d.ts +44 -6
  22. package/dist/client.js +69 -5
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +2 -0
  27. package/dist/introspect.d.ts +94 -1
  28. package/dist/introspect.js +345 -120
  29. package/dist/mssql.js +16 -101
  30. package/dist/mysql.js +14 -97
  31. package/dist/optional-peer-import.cjs +89 -0
  32. package/dist/optional-peer-import.d.cts +53 -0
  33. package/dist/powdb.d.ts +94 -26
  34. package/dist/powdb.js +435 -80
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +51 -27
  37. package/dist/query/builder.d.ts +60 -3
  38. package/dist/query/builder.js +291 -24
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +18 -0
  41. package/dist/query/filters.js +30 -0
  42. package/dist/query/types.d.ts +19 -0
  43. package/dist/schema-metadata.d.ts +77 -0
  44. package/dist/schema-metadata.js +313 -0
  45. package/dist/schema.d.ts +10 -0
  46. package/dist/sqlite.js +9 -90
  47. package/package.json +3 -3
package/dist/mysql.js CHANGED
@@ -65,7 +65,9 @@
65
65
  import { TurbineClient } from './client.js';
66
66
  import { postgresDialect, } from './dialect.js';
67
67
  import { ConnectionError, UnsupportedFeatureError } from './errors.js';
68
- import { isDateType, singularize, snakeToCamel, } from './schema.js';
68
+ import { deriveEngineRelations } from './introspect.js';
69
+ import importOptionalPeer from './optional-peer-import.cjs';
70
+ import { isDateType, snakeToCamel, } from './schema.js';
69
71
  // ---------------------------------------------------------------------------
70
72
  // Value coercion (params in)
71
73
  // ---------------------------------------------------------------------------
@@ -555,103 +557,15 @@ export const mysqlDialect = {
555
557
  };
556
558
  const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
557
559
  /**
558
- * Derive belongsTo + hasMany relations (and conservatively-detected manyToMany
559
- * junctions) from a flat foreign-key list. Mirrors the PostgreSQL / SQLite
560
- * introspectors so the produced {@link SchemaMetadata} has an identical relation
561
- * shape across engines.
560
+ * Derive relations from the FK list via the SHARED introspection pipeline
561
+ * (`deriveEngineRelations` `buildRelationsFromForeignKeys` +
562
+ * `addAutoManyToManyRelations` in introspect.ts), so this engine derives
563
+ * IDENTICAL relation names to `turbine generate` against Postgres for the
564
+ * same logical schema — legacy-first naming, per-column disambiguation, and
565
+ * collision resolution against scalar column fields included.
562
566
  */
563
567
  function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
564
- const tableSet = new Set(tableNames);
565
- const fkCounts = new Map();
566
- for (const fk of foreignKeys) {
567
- const key = `${fk.sourceTable}->${fk.targetTable}`;
568
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
569
- }
570
- const relationsByTable = new Map();
571
- for (const fk of foreignKeys) {
572
- if (!tableSet.has(fk.targetTable))
573
- continue;
574
- const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
575
- const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
576
- const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
577
- const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
578
- ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
579
- : singularize(snakeToCamel(fk.targetTable));
580
- if (!relationsByTable.has(fk.sourceTable))
581
- relationsByTable.set(fk.sourceTable, {});
582
- relationsByTable.get(fk.sourceTable)[belongsToName] = {
583
- type: 'belongsTo',
584
- name: belongsToName,
585
- from: fk.sourceTable,
586
- to: fk.targetTable,
587
- foreignKey,
588
- referenceKey,
589
- };
590
- const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
591
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
592
- : snakeToCamel(fk.sourceTable);
593
- if (!relationsByTable.has(fk.targetTable))
594
- relationsByTable.set(fk.targetTable, {});
595
- relationsByTable.get(fk.targetTable)[hasManyName] = {
596
- type: 'hasMany',
597
- name: hasManyName,
598
- from: fk.targetTable,
599
- to: fk.sourceTable,
600
- foreignKey,
601
- referenceKey,
602
- };
603
- }
604
- // Conservative many-to-many auto-detection (additive): a table J is a pure
605
- // junction iff PK is exactly two columns, exactly two single-column FKs whose
606
- // source columns ARE the PK, two distinct target tables, and no payload columns.
607
- for (const tableName of tableNames) {
608
- const pk = pkByTable.get(tableName) ?? [];
609
- if (pk.length !== 2)
610
- continue;
611
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
612
- if (tableFks.length !== 2)
613
- continue;
614
- if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
615
- continue;
616
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
617
- const pkSet = new Set(pk);
618
- if (!fkCols.every((c) => pkSet.has(c)))
619
- continue;
620
- if (new Set(fkCols).size !== 2)
621
- continue;
622
- const [fkA, fkB] = tableFks;
623
- if (fkA.targetTable === fkB.targetTable)
624
- continue;
625
- const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
626
- if (jCols.length !== 2)
627
- continue;
628
- const addM2M = (self, other) => {
629
- const sourceTbl = self.targetTable;
630
- const targetTbl = other.targetTable;
631
- const relName = snakeToCamel(targetTbl);
632
- if (!relationsByTable.has(sourceTbl))
633
- relationsByTable.set(sourceTbl, {});
634
- const existing = relationsByTable.get(sourceTbl);
635
- if (existing[relName])
636
- return;
637
- existing[relName] = {
638
- type: 'manyToMany',
639
- name: relName,
640
- from: sourceTbl,
641
- to: targetTbl,
642
- referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
643
- foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
644
- through: {
645
- table: tableName,
646
- sourceKey: self.sourceColumns[0],
647
- targetKey: other.sourceColumns[0],
648
- },
649
- };
650
- };
651
- addM2M(fkA, fkB);
652
- addM2M(fkB, fkA);
653
- }
654
- return relationsByTable;
568
+ return deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable);
655
569
  }
656
570
  /**
657
571
  * Introspect a MySQL database into the same {@link SchemaMetadata} shape the
@@ -898,7 +812,10 @@ function parseMysqlConfig(connectionString) {
898
812
  async function loadCreatePool() {
899
813
  let mod;
900
814
  try {
901
- mod = (await import('mysql2/promise'));
815
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
816
+ // import() even if a future mysql2 major goes ESM-only (the CommonJS pass
817
+ // transpiles a plain `import()` here into `require()`).
818
+ mod = (await importOptionalPeer('mysql2/promise'));
902
819
  }
903
820
  catch (err) {
904
821
  throw new ConnectionError("[turbine] turbine-orm/mysql requires the optional peer dependency 'mysql2'. Install it: npm i mysql2. " +
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /**
3
+ * True dynamic `import()` for the optional peer dependencies (`mysql2`,
4
+ * `mssql`, `@zvndev/powdb-client`, `@zvndev/powdb-embedded`) — safe in BOTH
5
+ * build outputs, including for peers that are ESM-only.
6
+ *
7
+ * THE PROBLEM THIS FILE SOLVES (the `@zvndev/powdb-client` ≥ 0.9 CJS break):
8
+ * the engine subpaths load their optional peers with dynamic `import()` so the
9
+ * peers stay out of the static graph. The ESM build (`tsconfig.json`, module
10
+ * NodeNext) emits that `import()` verbatim. The CJS build (`tsconfig.cjs.json`,
11
+ * module CommonJS) however TRANSPILES `import()` into
12
+ * `Promise.resolve().then(() => require(...))` — and `require()` of an
13
+ * ESM-only package (no `require` export condition, e.g. powdb-client ≥ 0.9)
14
+ * throws `ERR_PACKAGE_PATH_NOT_EXPORTED`, breaking every CJS consumer.
15
+ *
16
+ * TypeScript offers no way to preserve `import()` under `module: CommonJS`,
17
+ * and the CJS pass cannot switch to `module: NodeNext` (the root package.json
18
+ * says `"type": "module"`, so NodeNext would classify every `.ts` source as
19
+ * ESM and emit ESM into dist/cjs). A `.cts` file is the escape hatch: it is
20
+ * CommonJS-format by extension regardless of package `type`, so under the ESM
21
+ * pass (NodeNext) it compiles to `dist/optional-peer-import.cjs` — a CommonJS
22
+ * file whose `import()` SURVIVES transpilation (NodeNext preserves dynamic
23
+ * import in CJS files precisely because it is the only way CJS can load ESM).
24
+ *
25
+ * That gives the published package two copies of this module:
26
+ * - `dist/optional-peer-import.cjs` (ESM pass, NodeNext) — real `import()`
27
+ * - `dist/cjs/optional-peer-import.cjs` (CJS pass, CommonJS) — lowered to `require()`
28
+ *
29
+ * The lowered copy works fine for CJS-loadable peers (`mysql2`, `mssql`, older
30
+ * powdb peers). When it hits an ESM-only peer, the `require()` fails with a
31
+ * recognizable code and this function falls back to delegating the load to the
32
+ * sibling NodeNext copy one directory up (`../optional-peer-import.cjs`) —
33
+ * which is a plain CommonJS file (loadable by `require()` on every supported
34
+ * Node) whose real `import()` then loads the ESM peer. The ESM-pass copy has
35
+ * no such sibling; its lazy `require` throws and the original error surfaces,
36
+ * so the fallback can never recurse.
37
+ *
38
+ * Keep this module dependency-free and side-effect-free: it must be loadable
39
+ * from both module systems on every supported Node (≥ 20) without pulling in
40
+ * anything else.
41
+ */
42
+ /**
43
+ * Does this error mean "the module exists but cannot be loaded via
44
+ * `require()` because it is ESM-only"? These are the only failures worth
45
+ * retrying through a real `import()`; anything else (not installed, throw on
46
+ * init, …) is rethrown untouched so callers keep the original diagnostics.
47
+ */
48
+ function isEsmOnlyLoadError(err) {
49
+ const code = err?.code;
50
+ return (code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || // exports map has no `require` condition
51
+ code === 'ERR_REQUIRE_ESM' || // require() of an ES module (pre-require(esm) Node)
52
+ code === 'ERR_REQUIRE_ASYNC_MODULE' // require(esm) of a module with top-level await
53
+ );
54
+ }
55
+ /**
56
+ * Dynamically import an optional peer dependency. In the ESM build this is a
57
+ * plain `import()`. In the CJS build the first attempt is a transpiled
58
+ * `require()`; if the peer turns out to be ESM-only, the load is retried
59
+ * through the ESM-build sibling copy of this file, whose `import()` survived
60
+ * transpilation (see the module doc comment).
61
+ *
62
+ * @param specifier bare package specifier (e.g. `'@zvndev/powdb-client'`).
63
+ * @param allowEsmFallback internal recursion guard — the delegated call passes
64
+ * `false` so a failure in the sibling copy can never bounce back.
65
+ */
66
+ async function importOptionalPeer(specifier, allowEsmFallback = true) {
67
+ try {
68
+ return await import(specifier);
69
+ }
70
+ catch (err) {
71
+ if (!allowEsmFallback || !isEsmOnlyLoadError(err))
72
+ throw err;
73
+ let esmCapableCopy;
74
+ try {
75
+ // Only resolvable from dist/cjs/, where it lands on the NodeNext-built
76
+ // dist/optional-peer-import.cjs. From anywhere else (the ESM copy
77
+ // itself, or running the TypeScript source directly) the file does not
78
+ // exist and the original error is rethrown below.
79
+ esmCapableCopy = require('../optional-peer-import.cjs');
80
+ }
81
+ catch {
82
+ esmCapableCopy = undefined;
83
+ }
84
+ if (typeof esmCapableCopy !== 'function')
85
+ throw err;
86
+ return esmCapableCopy(specifier, false);
87
+ }
88
+ }
89
+ module.exports = importOptionalPeer;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * True dynamic `import()` for the optional peer dependencies (`mysql2`,
3
+ * `mssql`, `@zvndev/powdb-client`, `@zvndev/powdb-embedded`) — safe in BOTH
4
+ * build outputs, including for peers that are ESM-only.
5
+ *
6
+ * THE PROBLEM THIS FILE SOLVES (the `@zvndev/powdb-client` ≥ 0.9 CJS break):
7
+ * the engine subpaths load their optional peers with dynamic `import()` so the
8
+ * peers stay out of the static graph. The ESM build (`tsconfig.json`, module
9
+ * NodeNext) emits that `import()` verbatim. The CJS build (`tsconfig.cjs.json`,
10
+ * module CommonJS) however TRANSPILES `import()` into
11
+ * `Promise.resolve().then(() => require(...))` — and `require()` of an
12
+ * ESM-only package (no `require` export condition, e.g. powdb-client ≥ 0.9)
13
+ * throws `ERR_PACKAGE_PATH_NOT_EXPORTED`, breaking every CJS consumer.
14
+ *
15
+ * TypeScript offers no way to preserve `import()` under `module: CommonJS`,
16
+ * and the CJS pass cannot switch to `module: NodeNext` (the root package.json
17
+ * says `"type": "module"`, so NodeNext would classify every `.ts` source as
18
+ * ESM and emit ESM into dist/cjs). A `.cts` file is the escape hatch: it is
19
+ * CommonJS-format by extension regardless of package `type`, so under the ESM
20
+ * pass (NodeNext) it compiles to `dist/optional-peer-import.cjs` — a CommonJS
21
+ * file whose `import()` SURVIVES transpilation (NodeNext preserves dynamic
22
+ * import in CJS files precisely because it is the only way CJS can load ESM).
23
+ *
24
+ * That gives the published package two copies of this module:
25
+ * - `dist/optional-peer-import.cjs` (ESM pass, NodeNext) — real `import()`
26
+ * - `dist/cjs/optional-peer-import.cjs` (CJS pass, CommonJS) — lowered to `require()`
27
+ *
28
+ * The lowered copy works fine for CJS-loadable peers (`mysql2`, `mssql`, older
29
+ * powdb peers). When it hits an ESM-only peer, the `require()` fails with a
30
+ * recognizable code and this function falls back to delegating the load to the
31
+ * sibling NodeNext copy one directory up (`../optional-peer-import.cjs`) —
32
+ * which is a plain CommonJS file (loadable by `require()` on every supported
33
+ * Node) whose real `import()` then loads the ESM peer. The ESM-pass copy has
34
+ * no such sibling; its lazy `require` throws and the original error surfaces,
35
+ * so the fallback can never recurse.
36
+ *
37
+ * Keep this module dependency-free and side-effect-free: it must be loadable
38
+ * from both module systems on every supported Node (≥ 20) without pulling in
39
+ * anything else.
40
+ */
41
+ /**
42
+ * Dynamically import an optional peer dependency. In the ESM build this is a
43
+ * plain `import()`. In the CJS build the first attempt is a transpiled
44
+ * `require()`; if the peer turns out to be ESM-only, the load is retried
45
+ * through the ESM-build sibling copy of this file, whose `import()` survived
46
+ * transpilation (see the module doc comment).
47
+ *
48
+ * @param specifier bare package specifier (e.g. `'@zvndev/powdb-client'`).
49
+ * @param allowEsmFallback internal recursion guard — the delegated call passes
50
+ * `false` so a failure in the sibling copy can never bounce back.
51
+ */
52
+ declare function importOptionalPeer(specifier: string, allowEsmFallback?: boolean): Promise<unknown>;
53
+ export = importOptionalPeer;
package/dist/powdb.d.ts CHANGED
@@ -22,8 +22,19 @@
22
22
  * `string` PKs hold UUID strings.
23
23
  * - **No JSON aggregation / link navigation** — single-query nested `with` is
24
24
  * impossible → it degrades to batched N+1 loaders (Phase B).
25
- * - **Single global write lock; no savepoints/isolation/pipelining** — nested
25
+ * - **Single global write lock; no savepoints/isolation** — nested
26
26
  * transactions / isolation / vector / LISTEN-NOTIFY / RLS throw.
27
+ * Independent concurrent `db.$transaction` calls do NOT throw: they queue
28
+ * FIFO on a pool-level gate and run one at a time (see {@link PowdbTxGate}).
29
+ * Only a *re-entrant* transaction — a `db.$transaction` opened from inside
30
+ * an active transaction callback's async context, which queueing would
31
+ * deadlock — fails fast with E017.
32
+ * - **The wire protocol pipelines** — `@zvndev/powdb-client` writes each
33
+ * request frame immediately and matches replies FIFO, so multiple queries
34
+ * may be in flight on one connection. {@link PowdbPool}'s checked-out
35
+ * clients advertise `supportsPipelining`, which lets the batch
36
+ * `$transaction([...])` overload dispatch all statements in one write
37
+ * burst (~1 round trip) instead of one round trip per statement.
27
38
  *
28
39
  * `@zvndev/powdb-client` is an **optional peer dependency** loaded by dynamic
29
40
  * import; `npm i turbine-orm` still pulls only `pg`.
@@ -59,9 +70,11 @@ import type { ColumnMetadata, SchemaMetadata, TableMetadata } from './schema.js'
59
70
  * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
60
71
  * savepoint synchronously (before any DB call) and so fails fast with a
61
72
  * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
62
- * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
63
- * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
64
- * an already-open one before it can deadlock on the write lock.
73
+ * The pool-level transaction gate (see {@link PowdbTxGate}) handles the
74
+ * other shapes: a fresh top-level `db.$transaction` opened inside an
75
+ * already-open one throws E017 before it can deadlock on the write lock,
76
+ * while INDEPENDENT concurrent `db.$transaction` calls queue FIFO and run
77
+ * one at a time instead of failing.
65
78
  * Isolation levels remain Phase B.
66
79
  */
67
80
  export declare const powdbDialect: Dialect;
@@ -155,6 +168,24 @@ export declare function powqlColumnType(col: ColumnMetadata): PowqlType;
155
168
  * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
156
169
  * synthesizing a client-side value for it.
157
170
  */
171
+ /**
172
+ * PowQL reserved words — the v0.10 lexer keyword table from POWQL.md's
173
+ * "Reserved Words and Quoting" section, including the v0.10 additions
174
+ * `schema` and `describe`. Keyword matching is case-sensitive in the lexer,
175
+ * so only the exact lowercase form collides.
176
+ */
177
+ export declare const POWQL_KEYWORDS: ReadonlySet<string>;
178
+ /**
179
+ * Backtick-quote an identifier when PowQL would otherwise lex it as a keyword
180
+ * (or when it contains characters outside the bare-identifier grammar).
181
+ * Applied only in bare-identifier positions — DDL type/field names, index DDL,
182
+ * and `insert`/`update`/`upsert` assignment targets. Dotted references
183
+ * (`.col` in filters/projections/ordering) bypass keyword lookup on every
184
+ * engine version and deliberately stay bare for ≤0.9 compatibility. Backticks
185
+ * parse on PowDB ≥ 0.10; on older engines these names were already parse
186
+ * errors when emitted bare, so quoting is strictly an improvement.
187
+ */
188
+ export declare function quotePowqlIdent(name: string): string;
158
189
  export declare function powqlSchemaDDL(schema: SchemaMetadata): string[];
159
190
  /**
160
191
  * Coerce a single PowDB wire string into the JS value its column type implies.
@@ -188,6 +219,27 @@ type QueryArg = string | {
188
219
  text: string;
189
220
  values?: unknown[];
190
221
  };
222
+ /**
223
+ * Default cap (ms) on how long a `begin` may wait in the FIFO queue for
224
+ * PowDB's single global write lock before failing with a typed
225
+ * {@link TimeoutError} (E002). Prevents silent starvation behind a wedged
226
+ * transaction. Override via `transactionQueueTimeoutMs`
227
+ * ({@link TurbinePowdbOptions} / {@link PowdbPoolOptions}); `0` or `Infinity`
228
+ * waits without limit.
229
+ */
230
+ export declare const DEFAULT_TX_QUEUE_TIMEOUT_MS = 30000;
231
+ /** Tuning options shared by {@link PowdbPool} and {@link PowdbEmbeddedPool}. */
232
+ export interface PowdbPoolOptions {
233
+ /**
234
+ * Max time (ms) a concurrent transaction's `begin` may wait in the FIFO
235
+ * queue for the single-writer lock before failing with a
236
+ * {@link TimeoutError}. Default {@link DEFAULT_TX_QUEUE_TIMEOUT_MS};
237
+ * `0` / `Infinity` = wait without limit. Note this is a separate surface
238
+ * from `$transaction`'s `timeout` option, which only covers the callback
239
+ * *after* the transaction has begun.
240
+ */
241
+ transactionQueueTimeoutMs?: number;
242
+ }
191
243
  /**
192
244
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
193
245
  * `text` is **PowQL**, not SQL — {@link PowqlInterface} generates it. Rows come
@@ -199,20 +251,16 @@ export declare class PowdbPool implements PgCompatPool {
199
251
  private readonly toParam;
200
252
  private closed;
201
253
  /**
202
- * Pool-level single-writer guard. PowDB holds one global write lock, so at
203
- * most one transaction may be open across the whole pool. A `begin` issued
204
- * while this is `true` is rejected (it would otherwise check out a second
205
- * connection and block on the lock forever — the networked re-entrant hang).
206
- */
207
- private activeTransaction;
208
- constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam);
209
- /**
210
- * Enforce the single-writer model on a transaction-control statement. Throws
211
- * (before any query runs) if a `begin` arrives while a transaction is open;
212
- * otherwise flips the pool-level flag. Returns the control kind so the caller
213
- * can decide whether it even needs to hit the engine.
254
+ * Pool-level single-writer gate. PowDB holds one global write lock, so at
255
+ * most one transaction may be open across the whole pool. Concurrent
256
+ * `begin`s queue FIFO on the gate (instead of checking out a second
257
+ * connection and blocking on the lock forever — the networked hang);
258
+ * re-entrant `begin`s throw E017 (see {@link PowdbTxGate}).
214
259
  */
215
- private guardTxControl;
260
+ private readonly txGate;
261
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
262
+ private poolHold;
263
+ constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam, options?: PowdbPoolOptions);
216
264
  query(text: QueryArg, values?: unknown[]): Promise<any>;
217
265
  connect(): Promise<PgCompatPoolClient>;
218
266
  end(): Promise<void>;
@@ -272,17 +320,27 @@ export declare class PowdbEmbeddedPool implements PgCompatPool {
272
320
  private readonly db;
273
321
  private closed;
274
322
  /**
275
- * Single-writer guard. The embedded engine is one handle with one global
323
+ * Single-writer gate. The embedded engine is one handle with one global
276
324
  * write lock — only one transaction may be open at a time. A re-entrant
277
- * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
278
- * would otherwise hit PowDB's raw "already in a transaction" parse error;
279
- * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
280
- * earlier still, by the savepoint override in {@link powdbDialect}.)
325
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one's
326
+ * callback) would otherwise hit PowDB's raw "already in a transaction"
327
+ * parse error; the gate surfaces a typed E017 instead, while INDEPENDENT
328
+ * concurrent transactions queue FIFO and run one at a time. (Nested
329
+ * `tx.$transaction` is caught earlier still, by the savepoint override in
330
+ * {@link powdbDialect}.)
331
+ */
332
+ private readonly txGate;
333
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
334
+ private readonly poolHoldRef;
335
+ constructor(db: EmbeddedDatabase, options?: PowdbPoolOptions);
336
+ /** Materialize `$N` params and hand the PowQL to the in-process engine. */
337
+ private exec;
338
+ /**
339
+ * Run one statement, gating transaction control. `holdRef` scopes the gate
340
+ * hold to whoever issued the `begin` (the pool itself or one checked-out
341
+ * client), so finishing a transaction can never release a slot a different
342
+ * transaction is holding.
281
343
  */
282
- private activeTransaction;
283
- constructor(db: EmbeddedDatabase);
284
- /** Enforce the single-writer model on a transaction-control statement. */
285
- private guardTxControl;
286
344
  private run;
287
345
  query(text: QueryArg, values?: unknown[]): Promise<any>;
288
346
  connect(): Promise<PgCompatPoolClient>;
@@ -293,6 +351,16 @@ export { PowqlInterface } from './powql.js';
293
351
  export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited'> {
294
352
  /** Max pooled connections (default 10). Networked transport only. */
295
353
  connectionLimit?: number;
354
+ /**
355
+ * Max time (ms) a concurrent `$transaction` waits in the FIFO queue for
356
+ * PowDB's single global write lock before failing with a typed
357
+ * `TimeoutError` (default {@link DEFAULT_TX_QUEUE_TIMEOUT_MS} = 30 000;
358
+ * `0` / `Infinity` = wait without limit). Independent concurrent
359
+ * transactions queue and run one at a time; only a re-entrant
360
+ * `db.$transaction` (opened inside an active transaction callback) throws
361
+ * E017 — queueing that shape would deadlock.
362
+ */
363
+ transactionQueueTimeoutMs?: number;
296
364
  }
297
365
  /**
298
366
  * Selects the **embedded** transport — an in-process `@zvndev/powdb-embedded`