turbine-orm 0.29.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.
Files changed (48) 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 +47 -6
  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 +42 -136
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +579 -89
  12. package/dist/cjs/powql.js +56 -26
  13. package/dist/cjs/query/builder.js +601 -86
  14. package/dist/cjs/query/filters.js +80 -2
  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 +19 -2
  22. package/dist/client.js +47 -6
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +2 -1
  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 +40 -104
  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 +118 -23
  34. package/dist/powdb.js +574 -88
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +58 -28
  37. package/dist/query/builder.d.ts +145 -8
  38. package/dist/query/builder.js +602 -87
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +46 -1
  41. package/dist/query/filters.js +76 -1
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/types.d.ts +85 -11
  44. package/dist/schema-metadata.d.ts +77 -0
  45. package/dist/schema-metadata.js +313 -0
  46. package/dist/schema.d.ts +10 -0
  47. package/dist/sqlite.js +9 -90
  48. package/package.json +3 -3
package/dist/mssql.js CHANGED
@@ -92,7 +92,9 @@
92
92
  import { TurbineClient } from './client.js';
93
93
  import { postgresDialect, } from './dialect.js';
94
94
  import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
95
- import { camelToSnake, isDateType, normalizeKeyColumns, singularize, snakeToCamel, } from './schema.js';
95
+ import { deriveEngineRelations } from './introspect.js';
96
+ import importOptionalPeer from './optional-peer-import.cjs';
97
+ import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
96
98
  // ---------------------------------------------------------------------------
97
99
  // SQL Server / connection limits
98
100
  // ---------------------------------------------------------------------------
@@ -743,11 +745,32 @@ function buildForJsonSubquery(dialect, ctx) {
743
745
  if (hasOrder) {
744
746
  const orderBy = orderEntries
745
747
  .map(([k, dir]) => {
746
- 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);
747
769
  if (!targetMeta.allColumns.includes(col)) {
748
- 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)'}.`);
749
772
  }
750
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
773
+ const safeDir = String(rawDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
751
774
  return `${a}.${q(col)} ${safeDir}`;
752
775
  })
753
776
  .join(', ');
@@ -853,103 +876,15 @@ function buildForJsonManyToMany(dialect, ctx, h) {
853
876
  }
854
877
  const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
855
878
  /**
856
- * Derive belongsTo + hasMany relations (and conservatively-detected manyToMany
857
- * junctions) from a flat foreign-key list. Mirrors the PostgreSQL / MySQL / SQLite
858
- * introspectors so the produced {@link SchemaMetadata} has an identical relation
859
- * shape across engines.
879
+ * Derive relations from the FK list via the SHARED introspection pipeline
880
+ * (`deriveEngineRelations` → `buildRelationsFromForeignKeys` +
881
+ * `addAutoManyToManyRelations` in introspect.ts), so this engine derives
882
+ * IDENTICAL relation names to `turbine generate` against Postgres for the
883
+ * same logical schema — legacy-first naming, per-column disambiguation, and
884
+ * collision resolution against scalar column fields included.
860
885
  */
861
886
  function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
862
- const tableSet = new Set(tableNames);
863
- const fkCounts = new Map();
864
- for (const fk of foreignKeys) {
865
- const key = `${fk.sourceTable}->${fk.targetTable}`;
866
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
867
- }
868
- const relationsByTable = new Map();
869
- for (const fk of foreignKeys) {
870
- if (!tableSet.has(fk.targetTable))
871
- continue;
872
- const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
873
- const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
874
- const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
875
- const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
876
- ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
877
- : singularize(snakeToCamel(fk.targetTable));
878
- if (!relationsByTable.has(fk.sourceTable))
879
- relationsByTable.set(fk.sourceTable, {});
880
- relationsByTable.get(fk.sourceTable)[belongsToName] = {
881
- type: 'belongsTo',
882
- name: belongsToName,
883
- from: fk.sourceTable,
884
- to: fk.targetTable,
885
- foreignKey,
886
- referenceKey,
887
- };
888
- const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
889
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
890
- : snakeToCamel(fk.sourceTable);
891
- if (!relationsByTable.has(fk.targetTable))
892
- relationsByTable.set(fk.targetTable, {});
893
- relationsByTable.get(fk.targetTable)[hasManyName] = {
894
- type: 'hasMany',
895
- name: hasManyName,
896
- from: fk.targetTable,
897
- to: fk.sourceTable,
898
- foreignKey,
899
- referenceKey,
900
- };
901
- }
902
- // Conservative many-to-many auto-detection (additive): a table J is a pure
903
- // junction iff PK is exactly two columns, exactly two single-column FKs whose
904
- // source columns ARE the PK, two distinct target tables, and no payload columns.
905
- for (const tableName of tableNames) {
906
- const pk = pkByTable.get(tableName) ?? [];
907
- if (pk.length !== 2)
908
- continue;
909
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
910
- if (tableFks.length !== 2)
911
- continue;
912
- if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
913
- continue;
914
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
915
- const pkSet = new Set(pk);
916
- if (!fkCols.every((c) => pkSet.has(c)))
917
- continue;
918
- if (new Set(fkCols).size !== 2)
919
- continue;
920
- const [fkA, fkB] = tableFks;
921
- if (fkA.targetTable === fkB.targetTable)
922
- continue;
923
- const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
924
- if (jCols.length !== 2)
925
- continue;
926
- const addM2M = (self, other) => {
927
- const sourceTbl = self.targetTable;
928
- const targetTbl = other.targetTable;
929
- const relName = snakeToCamel(targetTbl);
930
- if (!relationsByTable.has(sourceTbl))
931
- relationsByTable.set(sourceTbl, {});
932
- const existing = relationsByTable.get(sourceTbl);
933
- if (existing[relName])
934
- return;
935
- existing[relName] = {
936
- type: 'manyToMany',
937
- name: relName,
938
- from: sourceTbl,
939
- to: targetTbl,
940
- referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
941
- foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
942
- through: {
943
- table: tableName,
944
- sourceKey: self.sourceColumns[0],
945
- targetKey: other.sourceColumns[0],
946
- },
947
- };
948
- };
949
- addM2M(fkA, fkB);
950
- addM2M(fkB, fkA);
951
- }
952
- return relationsByTable;
887
+ return deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable);
953
888
  }
954
889
  /**
955
890
  * Introspect a SQL Server database into the same {@link SchemaMetadata} shape the
@@ -1196,11 +1131,12 @@ async function loadMssql() {
1196
1131
  let mod;
1197
1132
  try {
1198
1133
  // `mssql` ships no bundled type declarations (it needs @types/mssql, which
1199
- // Turbine deliberately does not depend on) — the structural MssqlModule above
1200
- // is our typed surface. Import through a widened specifier so tsc treats the
1201
- // typeless module as `any` instead of erroring (TS7016).
1202
- const specifier = 'mssql';
1203
- mod = (await import(specifier));
1134
+ // Turbine deliberately does not depend on) — the structural MssqlModule
1135
+ // above is our typed surface; the helper returns `unknown` so no TS7016.
1136
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1137
+ // import() even if a future mssql major goes ESM-only (the CommonJS pass
1138
+ // transpiles a plain `import()` here into `require()`).
1139
+ mod = (await importOptionalPeer('mssql'));
1204
1140
  }
1205
1141
  catch (err) {
1206
1142
  throw new ConnectionError("[turbine] turbine-orm/mssql requires the optional peer dependency 'mssql'. Install it: npm i mssql. " +
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
@@ -24,6 +24,11 @@
24
24
  * impossible → it degrades to batched N+1 loaders (Phase B).
25
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.
27
32
  * - **The wire protocol pipelines** — `@zvndev/powdb-client` writes each
28
33
  * request frame immediately and matches replies FIFO, so multiple queries
29
34
  * may be in flight on one connection. {@link PowdbPool}'s checked-out
@@ -65,9 +70,11 @@ import type { ColumnMetadata, SchemaMetadata, TableMetadata } from './schema.js'
65
70
  * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
66
71
  * savepoint synchronously (before any DB call) and so fails fast with a
67
72
  * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
68
- * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
69
- * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
70
- * 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.
71
78
  * Isolation levels remain Phase B.
72
79
  */
73
80
  export declare const powdbDialect: Dialect;
@@ -113,6 +120,18 @@ interface PowdbClientPool {
113
120
  withClient<T>(fn: (c: PowdbClient) => Promise<T>): Promise<T>;
114
121
  close(): Promise<void>;
115
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
+ }
116
135
  /** Connection options for {@link turbinePowDB} — host/port, not a connection string. */
117
136
  export interface PowdbConnOptions {
118
137
  host: string;
@@ -161,6 +180,24 @@ export declare function powqlColumnType(col: ColumnMetadata): PowqlType;
161
180
  * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
162
181
  * synthesizing a client-side value for it.
163
182
  */
183
+ /**
184
+ * PowQL reserved words — the v0.10 lexer keyword table from POWQL.md's
185
+ * "Reserved Words and Quoting" section, including the v0.10 additions
186
+ * `schema` and `describe`. Keyword matching is case-sensitive in the lexer,
187
+ * so only the exact lowercase form collides.
188
+ */
189
+ export declare const POWQL_KEYWORDS: ReadonlySet<string>;
190
+ /**
191
+ * Backtick-quote an identifier when PowQL would otherwise lex it as a keyword
192
+ * (or when it contains characters outside the bare-identifier grammar).
193
+ * Applied only in bare-identifier positions — DDL type/field names, index DDL,
194
+ * and `insert`/`update`/`upsert` assignment targets. Dotted references
195
+ * (`.col` in filters/projections/ordering) bypass keyword lookup on every
196
+ * engine version and deliberately stay bare for ≤0.9 compatibility. Backticks
197
+ * parse on PowDB ≥ 0.10; on older engines these names were already parse
198
+ * errors when emitted bare, so quoting is strictly an improvement.
199
+ */
200
+ export declare function quotePowqlIdent(name: string): string;
164
201
  export declare function powqlSchemaDDL(schema: SchemaMetadata): string[];
165
202
  /**
166
203
  * Coerce a single PowDB wire string into the JS value its column type implies.
@@ -194,6 +231,27 @@ type QueryArg = string | {
194
231
  text: string;
195
232
  values?: unknown[];
196
233
  };
234
+ /**
235
+ * Default cap (ms) on how long a `begin` may wait in the FIFO queue for
236
+ * PowDB's single global write lock before failing with a typed
237
+ * {@link TimeoutError} (E002). Prevents silent starvation behind a wedged
238
+ * transaction. Override via `transactionQueueTimeoutMs`
239
+ * ({@link TurbinePowdbOptions} / {@link PowdbPoolOptions}); `0` or `Infinity`
240
+ * waits without limit.
241
+ */
242
+ export declare const DEFAULT_TX_QUEUE_TIMEOUT_MS = 30000;
243
+ /** Tuning options shared by {@link PowdbPool} and {@link PowdbEmbeddedPool}. */
244
+ export interface PowdbPoolOptions {
245
+ /**
246
+ * Max time (ms) a concurrent transaction's `begin` may wait in the FIFO
247
+ * queue for the single-writer lock before failing with a
248
+ * {@link TimeoutError}. Default {@link DEFAULT_TX_QUEUE_TIMEOUT_MS};
249
+ * `0` / `Infinity` = wait without limit. Note this is a separate surface
250
+ * from `$transaction`'s `timeout` option, which only covers the callback
251
+ * *after* the transaction has begun.
252
+ */
253
+ transactionQueueTimeoutMs?: number;
254
+ }
197
255
  /**
198
256
  * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
199
257
  * `text` is **PowQL**, not SQL — {@link PowqlInterface} generates it. Rows come
@@ -205,21 +263,31 @@ export declare class PowdbPool implements PgCompatPool {
205
263
  private readonly toParam;
206
264
  private closed;
207
265
  /**
208
- * Pool-level single-writer guard. PowDB holds one global write lock, so at
209
- * most one transaction may be open across the whole pool. A `begin` issued
210
- * while this is `true` is rejected (it would otherwise check out a second
211
- * connection and block on the lock forever — the networked re-entrant hang).
266
+ * Pool-level single-writer gate. PowDB holds one global write lock, so at
267
+ * most one transaction may be open across the whole pool. Concurrent
268
+ * `begin`s queue FIFO on the gate (instead of checking out a second
269
+ * connection and blocking on the lock forever — the networked hang);
270
+ * re-entrant `begin`s throw E017 (see {@link PowdbTxGate}).
212
271
  */
213
- private activeTransaction;
214
- constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam);
272
+ private readonly txGate;
273
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
274
+ private poolHold;
215
275
  /**
216
- * Enforce the single-writer model on a transaction-control statement. Throws
217
- * (before any query runs) if a `begin` arrives while a transaction is open;
218
- * otherwise flips the pool-level flag. Returns the control kind so the caller
219
- * can decide whether it even needs to hit the engine.
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.
220
281
  */
221
- private guardTxControl;
282
+ private readonly checkedOut;
283
+ constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam, options?: PowdbPoolOptions);
222
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;
223
291
  connect(): Promise<PgCompatPoolClient>;
224
292
  end(): Promise<void>;
225
293
  }
@@ -278,17 +346,27 @@ export declare class PowdbEmbeddedPool implements PgCompatPool {
278
346
  private readonly db;
279
347
  private closed;
280
348
  /**
281
- * Single-writer guard. The embedded engine is one handle with one global
349
+ * Single-writer gate. The embedded engine is one handle with one global
282
350
  * write lock — only one transaction may be open at a time. A re-entrant
283
- * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
284
- * would otherwise hit PowDB's raw "already in a transaction" parse error;
285
- * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
286
- * earlier still, by the savepoint override in {@link powdbDialect}.)
351
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one's
352
+ * callback) would otherwise hit PowDB's raw "already in a transaction"
353
+ * parse error; the gate surfaces a typed E017 instead, while INDEPENDENT
354
+ * concurrent transactions queue FIFO and run one at a time. (Nested
355
+ * `tx.$transaction` is caught earlier still, by the savepoint override in
356
+ * {@link powdbDialect}.)
357
+ */
358
+ private readonly txGate;
359
+ /** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
360
+ private readonly poolHoldRef;
361
+ constructor(db: EmbeddedDatabase, options?: PowdbPoolOptions);
362
+ /** Materialize `$N` params and hand the PowQL to the in-process engine. */
363
+ private exec;
364
+ /**
365
+ * Run one statement, gating transaction control. `holdRef` scopes the gate
366
+ * hold to whoever issued the `begin` (the pool itself or one checked-out
367
+ * client), so finishing a transaction can never release a slot a different
368
+ * transaction is holding.
287
369
  */
288
- private activeTransaction;
289
- constructor(db: EmbeddedDatabase);
290
- /** Enforce the single-writer model on a transaction-control statement. */
291
- private guardTxControl;
292
370
  private run;
293
371
  query(text: QueryArg, values?: unknown[]): Promise<any>;
294
372
  connect(): Promise<PgCompatPoolClient>;
@@ -299,6 +377,23 @@ export { PowqlInterface } from './powql.js';
299
377
  export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited'> {
300
378
  /** Max pooled connections (default 10). Networked transport only. */
301
379
  connectionLimit?: number;
380
+ /**
381
+ * Max time (ms) a concurrent `$transaction` waits in the FIFO queue for
382
+ * PowDB's single global write lock before failing with a typed
383
+ * `TimeoutError` (default {@link DEFAULT_TX_QUEUE_TIMEOUT_MS} = 30 000;
384
+ * `0` / `Infinity` = wait without limit). Independent concurrent
385
+ * transactions queue and run one at a time; only a re-entrant
386
+ * `db.$transaction` (opened inside an active transaction callback) throws
387
+ * E017 — queueing that shape would deadlock.
388
+ */
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;
302
397
  }
303
398
  /**
304
399
  * Selects the **embedded** transport — an in-process `@zvndev/powdb-embedded`