turbine-orm 0.40.1 → 0.41.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 (63) hide show
  1. package/README.md +22 -4
  2. package/dist/cjs/cli/config.js +3 -0
  3. package/dist/cjs/cli/index.js +179 -0
  4. package/dist/cjs/cli/prisma-report.js +216 -0
  5. package/dist/cjs/cli/prisma-resolve.js +335 -0
  6. package/dist/cjs/cli/prisma-schema.js +484 -0
  7. package/dist/cjs/client.js +1 -0
  8. package/dist/cjs/generate.js +279 -22
  9. package/dist/cjs/index.js +3 -2
  10. package/dist/cjs/introspect.js +203 -26
  11. package/dist/cjs/mssql.js +9 -10
  12. package/dist/cjs/mysql.js +3 -9
  13. package/dist/cjs/powdb-introspect.js +5 -10
  14. package/dist/cjs/powql.js +13 -0
  15. package/dist/cjs/prisma-compat.js +1147 -0
  16. package/dist/cjs/query/aggregates.js +67 -7
  17. package/dist/cjs/query/builder.js +388 -17
  18. package/dist/cjs/query/compound-unique.js +0 -0
  19. package/dist/cjs/query/relations.js +7 -5
  20. package/dist/cjs/query/warn-registry.js +98 -0
  21. package/dist/cjs/query/writes.js +13 -5
  22. package/dist/cjs/schema.js +47 -0
  23. package/dist/cjs/sqlite.js +4 -9
  24. package/dist/cli/config.d.ts +26 -0
  25. package/dist/cli/config.js +3 -0
  26. package/dist/cli/index.d.ts +11 -0
  27. package/dist/cli/index.js +180 -1
  28. package/dist/cli/prisma-report.d.ts +19 -0
  29. package/dist/cli/prisma-report.js +211 -0
  30. package/dist/cli/prisma-resolve.d.ts +87 -0
  31. package/dist/cli/prisma-resolve.js +330 -0
  32. package/dist/cli/prisma-schema.d.ts +116 -0
  33. package/dist/cli/prisma-schema.js +479 -0
  34. package/dist/cli/ui.d.ts +1 -1
  35. package/dist/client.d.ts +18 -2
  36. package/dist/client.js +1 -0
  37. package/dist/generate.d.ts +80 -1
  38. package/dist/generate.js +277 -25
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.js +1 -1
  41. package/dist/introspect.d.ts +92 -2
  42. package/dist/introspect.js +198 -26
  43. package/dist/mssql.js +10 -11
  44. package/dist/mysql.js +4 -10
  45. package/dist/powdb-introspect.js +5 -10
  46. package/dist/powql.js +13 -0
  47. package/dist/prisma-compat.d.ts +281 -0
  48. package/dist/prisma-compat.js +1143 -0
  49. package/dist/query/aggregates.js +67 -7
  50. package/dist/query/builder.d.ts +77 -4
  51. package/dist/query/builder.js +390 -19
  52. package/dist/query/compound-unique.d.ts +49 -0
  53. package/dist/query/compound-unique.js +0 -0
  54. package/dist/query/deferred.d.ts +18 -0
  55. package/dist/query/relations.js +7 -5
  56. package/dist/query/types.d.ts +70 -9
  57. package/dist/query/warn-registry.d.ts +57 -0
  58. package/dist/query/warn-registry.js +92 -0
  59. package/dist/query/writes.js +13 -5
  60. package/dist/schema.d.ts +75 -0
  61. package/dist/schema.js +46 -0
  62. package/dist/sqlite.js +5 -10
  63. package/package.json +6 -1
@@ -8,13 +8,54 @@
8
8
  * This is the foundation of `npx turbine generate`.
9
9
  */
10
10
  import { type Dialect } from './dialect.js';
11
- import { type ColumnMetadata, type ReferentialAction, type RelationDef, type SchemaMetadata } from './schema.js';
11
+ import { type ColumnMetadata, type IndexMetadata, type ReferentialAction, type RelationDef, type SchemaMetadata } from './schema.js';
12
12
  /**
13
13
  * Map a `pg_constraint.confdeltype` / `confupdtype` character to a
14
14
  * {@link ReferentialAction}. Postgres encodes: `a` = NO ACTION, `r` = RESTRICT,
15
15
  * `c` = CASCADE, `n` = SET NULL, `d` = SET DEFAULT.
16
16
  */
17
17
  export declare function pgConfActionToReferential(ch: string): ReferentialAction;
18
+ /**
19
+ * Migration-bookkeeping tables that introspection drops by default: Turbine's
20
+ * own `_turbine_migrations` / `_turbine_metrics` and Prisma's
21
+ * `_prisma_migrations`. These are almost never meant to be surfaced as typed
22
+ * accessors, and a fresh migrate-from-Prisma introspection would otherwise emit
23
+ * a `PrismaMigrations` entity plus stray FK-derived relations on neighbours.
24
+ *
25
+ * A table named here is dropped UNLESS it is explicitly listed in
26
+ * `options.include` (`include` is the escape hatch, no separate flag), and
27
+ * naming a default-excluded table restores its old generated output byte for
28
+ * byte. The list is deliberately tight (exactly these three); leading-
29
+ * underscore tables are legitimate user tables and are never blanket-excluded.
30
+ */
31
+ export declare const DEFAULT_EXCLUDED_TABLES: readonly ["_turbine_migrations", "_prisma_migrations", "_turbine_metrics"];
32
+ /** The include / exclude filters shared by every introspector's table selection. */
33
+ export interface TableFilterOptions {
34
+ /** Tables to include (empty/undefined = all). Applied first. */
35
+ include?: string[];
36
+ /** Tables the user asked to exclude. Applied after include. */
37
+ exclude?: string[];
38
+ }
39
+ /**
40
+ * The single authority for turning a raw list of candidate table names into the
41
+ * introspected set, shared by the Postgres catalog reader and every engine
42
+ * introspector (SQLite / MySQL / MSSQL / PowDB) so all surfaces agree.
43
+ *
44
+ * Order of operations:
45
+ * 1. `include` filter: when non-empty, keep only the named tables.
46
+ * 2. user `exclude`: drop anything the caller listed.
47
+ * 3. {@link DEFAULT_EXCLUDED_TABLES}: drop migration bookkeeping tables,
48
+ * EXCEPT any that the caller explicitly named in `include` (the escape
49
+ * hatch that restores the pre-0.41 output for those tables).
50
+ */
51
+ export declare function applyTableFilters(names: string[], options?: TableFilterOptions): string[];
52
+ /**
53
+ * The subset of {@link DEFAULT_EXCLUDED_TABLES} that were present in `names` but
54
+ * dropped by {@link applyTableFilters} (i.e. not re-added via `include`). Pure
55
+ * helper so the CLI can report "skipped internal table X" without re-deriving
56
+ * the filtering rule.
57
+ */
58
+ export declare function defaultExcludedTablesPresent(names: string[], options?: TableFilterOptions): string[];
18
59
  export interface IntrospectOptions {
19
60
  /** Postgres connection string */
20
61
  connectionString: string;
@@ -31,6 +72,23 @@ export interface IntrospectOptions {
31
72
  * the generated `findUnique`-family accessor types.
32
73
  */
33
74
  includeViews?: boolean;
75
+ /**
76
+ * Opt OUT of the unique-FK → `hasOne` flip (F2). By default (`false`)
77
+ * introspection emits a to-one (`hasOne`) relation on the parent side when a
78
+ * child's foreign-key column set is EXACTLY covered by a UNIQUE constraint or
79
+ * a non-partial, non-expression UNIQUE index, matching Prisma one-to-one
80
+ * introspection. Set to `true` to keep the pre-0.41 behavior where every such
81
+ * relation was emitted as `hasMany` (a to-many array). See
82
+ * {@link detectUniqueForeignKeySets}.
83
+ */
84
+ legacyToManyUniques?: boolean;
85
+ /**
86
+ * Called with any {@link DEFAULT_EXCLUDED_TABLES} that were present in the
87
+ * database but dropped from this run (F12), so the CLI can print a
88
+ * "skipped internal table X (add it to include to keep it)" note. Not invoked
89
+ * when the set is empty. Postgres path only for now.
90
+ */
91
+ onDefaultTableExclusion?: (tables: string[]) => void;
34
92
  /**
35
93
  * Dialect whose {@link Dialect.introspector} drives the catalog reads.
36
94
  * Defaults to {@link postgresDialect}. Engines plug their own introspector
@@ -81,6 +139,30 @@ export declare function relationNameFromColumn(column: string): string;
81
139
  * payload), so the legacy-first naming keeps it instead of renaming.
82
140
  */
83
141
  export declare function isUnknownTsType(tsType: string): boolean;
142
+ /**
143
+ * Parse the column list of a PLAIN unique index from its `pg_indexes.indexdef`,
144
+ * returning `null` for anything that does NOT guarantee at-most-one child row:
145
+ *
146
+ * - a PARTIAL index (has a `WHERE` clause): only unique within the predicate;
147
+ * - an EXPRESSION index (`lower(email)`, `(a || b)`): the uniqueness is on the
148
+ * expression, not the raw FK column set.
149
+ *
150
+ * Anchors on the `USING <method> (` clause the same way
151
+ * {@link describeIndexDefMismatch} does, so a partial index's `WHERE (...)`
152
+ * parentheses are never mistaken for the column list. Every column token must be
153
+ * a bare or double-quoted identifier; anything else (a function call, an
154
+ * operator expression) fails the check and yields `null`.
155
+ */
156
+ export declare function parsePlainUniqueIndexColumns(indexdef: string): string[] | null;
157
+ /**
158
+ * Assemble, per table, every column set that EXACTLY guarantees at-most-one row:
159
+ * the primary key, every UNIQUE constraint, and every PLAIN (non-partial,
160
+ * non-expression) UNIQUE index. Consumed by
161
+ * {@link buildRelationsFromForeignKeys} to flip a child relation whose FK column
162
+ * set matches one of these sets from `hasMany` to `hasOne` (F2, Prisma
163
+ * one-to-one parity).
164
+ */
165
+ export declare function detectUniqueForeignKeySets(pkByTable: Map<string, string[]>, uniqueByTable: Map<string, string[][]>, indexesByTable: Map<string, IndexMetadata[]>): Map<string, string[][]>;
84
166
  /**
85
167
  * Build the belongsTo/hasMany relation maps for every table from its foreign
86
168
  * keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
@@ -111,11 +193,19 @@ export declare function isUnknownTsType(tsType: string): boolean;
111
193
  * guarantee relations never shadow concrete-typed scalar columns.
112
194
  * @param unknownTypedFieldsByTable subset of the column fields whose tsType is
113
195
  * `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
196
+ * @param uniqueSetsByTable when provided (F2), the child-table column sets that
197
+ * guarantee at-most-one row (PK + unique constraints + plain unique indexes,
198
+ * from {@link detectUniqueForeignKeySets}). A reverse relation whose FK column
199
+ * set EXACTLY matches one of the child's unique sets is emitted as `hasOne`
200
+ * (to-one) instead of `hasMany`, and named with the SINGULAR of the child
201
+ * table (falling back to the legacy plural name on collision). Omit it (the
202
+ * engine introspectors and `defineSchema` path do) to keep every reverse
203
+ * relation `hasMany`.
114
204
  */
115
205
  export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEntry[], columnFieldsByTable: Map<string, Set<string>>, fkActions?: Map<string, {
116
206
  onDelete: ReferentialAction;
117
207
  onUpdate: ReferentialAction;
118
- }>, unknownTypedFieldsByTable?: Map<string, Set<string>>): Map<string, Record<string, RelationDef>>;
208
+ }>, unknownTypedFieldsByTable?: Map<string, Set<string>>, uniqueSetsByTable?: Map<string, string[][]>): Map<string, Record<string, RelationDef>>;
119
209
  /**
120
210
  * Conservative auto-`manyToMany` detection over pure junction tables, shared
121
211
  * by the Postgres introspector, the engine introspectors (SQLite / MySQL /
@@ -176,6 +176,61 @@ const SQL_ENUMS = `
176
176
  ORDER BY t.typname, e.enumsortorder
177
177
  `;
178
178
  // ---------------------------------------------------------------------------
179
+ // Default table exclusions (F12)
180
+ // ---------------------------------------------------------------------------
181
+ /**
182
+ * Migration-bookkeeping tables that introspection drops by default: Turbine's
183
+ * own `_turbine_migrations` / `_turbine_metrics` and Prisma's
184
+ * `_prisma_migrations`. These are almost never meant to be surfaced as typed
185
+ * accessors, and a fresh migrate-from-Prisma introspection would otherwise emit
186
+ * a `PrismaMigrations` entity plus stray FK-derived relations on neighbours.
187
+ *
188
+ * A table named here is dropped UNLESS it is explicitly listed in
189
+ * `options.include` (`include` is the escape hatch, no separate flag), and
190
+ * naming a default-excluded table restores its old generated output byte for
191
+ * byte. The list is deliberately tight (exactly these three); leading-
192
+ * underscore tables are legitimate user tables and are never blanket-excluded.
193
+ */
194
+ export const DEFAULT_EXCLUDED_TABLES = ['_turbine_migrations', '_prisma_migrations', '_turbine_metrics'];
195
+ /**
196
+ * The single authority for turning a raw list of candidate table names into the
197
+ * introspected set, shared by the Postgres catalog reader and every engine
198
+ * introspector (SQLite / MySQL / MSSQL / PowDB) so all surfaces agree.
199
+ *
200
+ * Order of operations:
201
+ * 1. `include` filter: when non-empty, keep only the named tables.
202
+ * 2. user `exclude`: drop anything the caller listed.
203
+ * 3. {@link DEFAULT_EXCLUDED_TABLES}: drop migration bookkeeping tables,
204
+ * EXCEPT any that the caller explicitly named in `include` (the escape
205
+ * hatch that restores the pre-0.41 output for those tables).
206
+ */
207
+ export function applyTableFilters(names, options = {}) {
208
+ let result = names;
209
+ const includeSet = options.include?.length ? new Set(options.include) : null;
210
+ if (includeSet) {
211
+ result = result.filter((t) => includeSet.has(t));
212
+ }
213
+ if (options.exclude?.length) {
214
+ const excludeSet = new Set(options.exclude);
215
+ result = result.filter((t) => !excludeSet.has(t));
216
+ }
217
+ // Default exclusions never override an explicit include.
218
+ const defaults = new Set(DEFAULT_EXCLUDED_TABLES);
219
+ result = result.filter((t) => !defaults.has(t) || (includeSet?.has(t) ?? false));
220
+ return result;
221
+ }
222
+ /**
223
+ * The subset of {@link DEFAULT_EXCLUDED_TABLES} that were present in `names` but
224
+ * dropped by {@link applyTableFilters} (i.e. not re-added via `include`). Pure
225
+ * helper so the CLI can report "skipped internal table X" without re-deriving
226
+ * the filtering rule.
227
+ */
228
+ export function defaultExcludedTablesPresent(names, options = {}) {
229
+ const includeSet = options.include?.length ? new Set(options.include) : null;
230
+ const present = new Set(names);
231
+ return DEFAULT_EXCLUDED_TABLES.filter((t) => present.has(t) && !(includeSet?.has(t) ?? false));
232
+ }
233
+ // ---------------------------------------------------------------------------
179
234
  // Main introspection function
180
235
  // ---------------------------------------------------------------------------
181
236
  /**
@@ -243,16 +298,18 @@ export async function introspectPostgresCatalog(options) {
243
298
  onUpdate: pgConfActionToReferential(row.confupdtype),
244
299
  });
245
300
  }
246
- // Filter tables by include/exclude. Views/matviews join the base tables as
247
- // candidates so include/exclude apply uniformly.
248
- let tableNames = [...tablesResult.rows.map((r) => r.table_name), ...viewNameSet];
249
- if (options.include?.length) {
250
- const includeSet = new Set(options.include);
251
- tableNames = tableNames.filter((t) => includeSet.has(t));
252
- }
253
- if (options.exclude?.length) {
254
- const excludeSet = new Set(options.exclude);
255
- tableNames = tableNames.filter((t) => !excludeSet.has(t));
301
+ // Filter tables by include/exclude + default bookkeeping-table exclusions
302
+ // (F12). Views/matviews join the base tables as candidates so the filters
303
+ // apply uniformly.
304
+ const candidateTables = [
305
+ ...tablesResult.rows.map((r) => r.table_name),
306
+ ...viewNameSet,
307
+ ];
308
+ const tableNames = applyTableFilters(candidateTables, options);
309
+ if (options.onDefaultTableExclusion) {
310
+ const skipped = defaultExcludedTablesPresent(candidateTables, options);
311
+ if (skipped.length > 0)
312
+ options.onDefaultTableExclusion(skipped);
256
313
  }
257
314
  const tableSet = new Set(tableNames);
258
315
  // ----- Group columns by table -----
@@ -407,7 +464,13 @@ export async function introspectPostgresCatalog(options) {
407
464
  // main, so only genuine json/jsonb columns qualify as historical shadows.
408
465
  unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
409
466
  }
410
- const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable);
467
+ // F2: unless the caller opts out, detect child FK column sets that a unique
468
+ // constraint / plain unique index exactly covers, so the reverse relation is
469
+ // emitted as a one-to-one (`hasOne`) instead of `hasMany`.
470
+ const uniqueSetsByTable = options.legacyToManyUniques
471
+ ? undefined
472
+ : detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
473
+ const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
411
474
  // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
412
475
  //
413
476
  // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
@@ -537,6 +600,92 @@ function upperFirst(s) {
537
600
  export function isUnknownTsType(tsType) {
538
601
  return tsType === 'unknown' || tsType === 'unknown | null';
539
602
  }
603
+ // ---------------------------------------------------------------------------
604
+ // Unique-foreign-key detection for one-to-one relations (F2)
605
+ // ---------------------------------------------------------------------------
606
+ /** True when two column lists cover the same set (order-insensitive, no dupes). */
607
+ function columnSetsEqual(a, b) {
608
+ if (a.length !== b.length)
609
+ return false;
610
+ const bs = new Set(b);
611
+ return a.every((c) => bs.has(c));
612
+ }
613
+ /**
614
+ * Parse the column list of a PLAIN unique index from its `pg_indexes.indexdef`,
615
+ * returning `null` for anything that does NOT guarantee at-most-one child row:
616
+ *
617
+ * - a PARTIAL index (has a `WHERE` clause): only unique within the predicate;
618
+ * - an EXPRESSION index (`lower(email)`, `(a || b)`): the uniqueness is on the
619
+ * expression, not the raw FK column set.
620
+ *
621
+ * Anchors on the `USING <method> (` clause the same way
622
+ * {@link describeIndexDefMismatch} does, so a partial index's `WHERE (...)`
623
+ * parentheses are never mistaken for the column list. Every column token must be
624
+ * a bare or double-quoted identifier; anything else (a function call, an
625
+ * operator expression) fails the check and yields `null`.
626
+ */
627
+ export function parsePlainUniqueIndexColumns(indexdef) {
628
+ // Partial index: uniqueness is scoped to the WHERE predicate.
629
+ if (/\bWHERE\b/i.test(indexdef))
630
+ return null;
631
+ const paren = indexdef.match(/USING\s+\w+\s*\(([^)]*)\)/i);
632
+ if (!paren)
633
+ return null;
634
+ const tokens = paren[1].split(',').map((c) => c
635
+ .trim()
636
+ .replace(/\s+(ASC|DESC|NULLS\s+(FIRST|LAST))\b/gi, '')
637
+ .trim());
638
+ const columns = [];
639
+ for (const token of tokens) {
640
+ if (token.length === 0)
641
+ return null;
642
+ if (/^"(?:[^"]|"")*"$/.test(token)) {
643
+ // Quoted identifier: unquote and unescape doubled quotes.
644
+ columns.push(token.slice(1, -1).replace(/""/g, '"'));
645
+ }
646
+ else if (/^[A-Za-z_][A-Za-z0-9_$]*$/.test(token)) {
647
+ columns.push(token);
648
+ }
649
+ else {
650
+ // Expression column (function call, operator, cast, and the like): never a plain FK.
651
+ return null;
652
+ }
653
+ }
654
+ return columns.length > 0 ? columns : null;
655
+ }
656
+ /**
657
+ * Assemble, per table, every column set that EXACTLY guarantees at-most-one row:
658
+ * the primary key, every UNIQUE constraint, and every PLAIN (non-partial,
659
+ * non-expression) UNIQUE index. Consumed by
660
+ * {@link buildRelationsFromForeignKeys} to flip a child relation whose FK column
661
+ * set matches one of these sets from `hasMany` to `hasOne` (F2, Prisma
662
+ * one-to-one parity).
663
+ */
664
+ export function detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable) {
665
+ const result = new Map();
666
+ const add = (table, cols) => {
667
+ if (cols.length === 0)
668
+ return;
669
+ if (!result.has(table))
670
+ result.set(table, []);
671
+ result.get(table).push(cols);
672
+ };
673
+ for (const [table, pk] of pkByTable)
674
+ add(table, pk);
675
+ for (const [table, sets] of uniqueByTable)
676
+ for (const cols of sets)
677
+ add(table, cols);
678
+ for (const [table, indexes] of indexesByTable) {
679
+ for (const idx of indexes) {
680
+ if (!idx.unique)
681
+ continue;
682
+ const cols = parsePlainUniqueIndexColumns(idx.definition);
683
+ if (cols)
684
+ add(table, cols);
685
+ }
686
+ }
687
+ return result;
688
+ }
540
689
  /**
541
690
  * Resolve a derived relation name against the names already taken on the
542
691
  * table (scalar column fields + previously assigned relations). On collision,
@@ -583,8 +732,16 @@ function resolveRelationNameCollision(candidate, taken, table, source) {
583
732
  * guarantee relations never shadow concrete-typed scalar columns.
584
733
  * @param unknownTypedFieldsByTable subset of the column fields whose tsType is
585
734
  * `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
735
+ * @param uniqueSetsByTable when provided (F2), the child-table column sets that
736
+ * guarantee at-most-one row (PK + unique constraints + plain unique indexes,
737
+ * from {@link detectUniqueForeignKeySets}). A reverse relation whose FK column
738
+ * set EXACTLY matches one of the child's unique sets is emitted as `hasOne`
739
+ * (to-one) instead of `hasMany`, and named with the SINGULAR of the child
740
+ * table (falling back to the legacy plural name on collision). Omit it (the
741
+ * engine introspectors and `defineSchema` path do) to keep every reverse
742
+ * relation `hasMany`.
586
743
  */
587
- export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable) {
744
+ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable, uniqueSetsByTable) {
588
745
  // Count FKs per (source, target) pair for disambiguation.
589
746
  const fkCounts = new Map();
590
747
  for (const fk of foreignKeys) {
@@ -673,26 +830,41 @@ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable,
673
830
  referenceKey,
674
831
  ...actionFields,
675
832
  };
676
- // --- hasMany on the target (parent) table ---
677
- // e.g. posts.user_id → users.id creates users.posts (hasMany)
678
- const legacyHasMany = needsDisambiguation
833
+ // --- reverse relation on the target (parent) table ---
834
+ // e.g. posts.user_id → users.id creates users.posts (hasMany), UNLESS the
835
+ // child's FK column set is exactly covered by a unique constraint / plain
836
+ // unique index (F2), then it is a one-to-one, emitted as `hasOne` and
837
+ // named with the SINGULAR of the child table.
838
+ const isUniqueFk = (uniqueSetsByTable?.get(fk.sourceTable) ?? []).some((set) => columnSetsEqual(set, fk.sourceColumns));
839
+ const disambSuffix = needsDisambiguation
840
+ ? singleColumn
841
+ ? `By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
842
+ : `By${upperFirst(snakeToCamel(constraintBase))}`
843
+ : '';
844
+ const legacyReverse = needsDisambiguation
679
845
  ? singleColumn
680
846
  ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
681
847
  : snakeToCamel(`${fk.sourceTable}_by_${constraintBase}`)
682
848
  : snakeToCamel(fk.sourceTable);
683
- const modernHasMany = needsDisambiguation
684
- ? singleColumn
685
- ? `${snakeToCamel(fk.sourceTable)}By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
686
- : `${snakeToCamel(fk.sourceTable)}By${upperFirst(snakeToCamel(constraintBase))}`
687
- : null;
688
- const hasManyName = resolveName(legacyHasMany, modernHasMany, fk.targetTable, `FK ${fk.constraintName}`);
689
- takenFor(fk.targetTable).add(hasManyName);
690
- assignedFor(fk.targetTable).add(hasManyName);
849
+ const modernReverse = needsDisambiguation ? `${snakeToCamel(fk.sourceTable)}${disambSuffix}` : null;
850
+ let reverseName;
851
+ const reverseType = isUniqueFk ? 'hasOne' : 'hasMany';
852
+ if (isUniqueFk) {
853
+ // Prefer the singular child-table name; fall back to the legacy plural
854
+ // (which stays byte-stable for any app that was on the pre-flip shape).
855
+ const singularReverse = `${singularize(snakeToCamel(fk.sourceTable))}${disambSuffix}`;
856
+ reverseName = resolveName(singularReverse, legacyReverse, fk.targetTable, `FK ${fk.constraintName}`);
857
+ }
858
+ else {
859
+ reverseName = resolveName(legacyReverse, modernReverse, fk.targetTable, `FK ${fk.constraintName}`);
860
+ }
861
+ takenFor(fk.targetTable).add(reverseName);
862
+ assignedFor(fk.targetTable).add(reverseName);
691
863
  if (!relationsByTable.has(fk.targetTable))
692
864
  relationsByTable.set(fk.targetTable, {});
693
- relationsByTable.get(fk.targetTable)[hasManyName] = {
694
- type: 'hasMany',
695
- name: hasManyName,
865
+ relationsByTable.get(fk.targetTable)[reverseName] = {
866
+ type: reverseType,
867
+ name: reverseName,
696
868
  from: fk.targetTable,
697
869
  to: fk.sourceTable,
698
870
  foreignKey,
package/dist/mssql.js CHANGED
@@ -92,7 +92,7 @@
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 { deriveEngineRelations } from './introspect.js';
95
+ import { applyTableFilters, deriveEngineRelations } from './introspect.js';
96
96
  import importOptionalPeer from './optional-peer-import.cjs';
97
97
  import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
98
98
  // ---------------------------------------------------------------------------
@@ -549,7 +549,12 @@ export const mssqlDialect = {
549
549
  .map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
550
550
  .join(', ');
551
551
  const out = mssqlOutput(input.returning, 'INSERTED');
552
- // skipDuplicates has no single-statement equivalent here; ignored (documented).
552
+ // SQL Server has no single-statement skip-duplicates form (no ON CONFLICT /
553
+ // INSERT IGNORE), so silently dropping the flag would let duplicate rows
554
+ // through against the caller's intent. Refuse loudly instead.
555
+ if (input.skipDuplicates) {
556
+ throw new UnsupportedFeatureError('createMany({ skipDuplicates: true })', 'mssql', 'SQL Server has no ON CONFLICT DO NOTHING equivalent, pre-filter conflicting rows or use a MERGE.');
557
+ }
553
558
  return {
554
559
  sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
555
560
  params: input.rowValues.flat(),
@@ -923,15 +928,9 @@ function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, colum
923
928
  */
924
929
  export async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
925
930
  // ----- Tables -----
926
- let tableNames = (await exec("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
927
- if (options.include?.length) {
928
- const inc = new Set(options.include);
929
- tableNames = tableNames.filter((t) => inc.has(t));
930
- }
931
- if (options.exclude?.length) {
932
- const exc = new Set(options.exclude);
933
- tableNames = tableNames.filter((t) => !exc.has(t));
934
- }
931
+ const candidateTables = (await exec("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
932
+ // include / exclude + default bookkeeping-table exclusions (F12).
933
+ const tableNames = applyTableFilters(candidateTables, options);
935
934
  const tableSet = new Set(tableNames);
936
935
  // ----- Identity columns (mark hasDefault) -----
937
936
  const identityRows = await exec(`SELECT t.name AS TABLE_NAME, c.name AS COLUMN_NAME
package/dist/mysql.js CHANGED
@@ -65,7 +65,7 @@
65
65
  import { TurbineClient } from './client.js';
66
66
  import { postgresDialect, } from './dialect.js';
67
67
  import { ConnectionError, UnsupportedFeatureError } from './errors.js';
68
- import { deriveEngineRelations } from './introspect.js';
68
+ import { applyTableFilters, deriveEngineRelations } from './introspect.js';
69
69
  import importOptionalPeer from './optional-peer-import.cjs';
70
70
  import { isDateType, snakeToCamel, } from './schema.js';
71
71
  // ---------------------------------------------------------------------------
@@ -585,15 +585,9 @@ function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, colum
585
585
  */
586
586
  export async function introspectMysqlWith(exec, schema, options = {}) {
587
587
  // ----- Tables -----
588
- let tableNames = (await exec("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = :p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
589
- if (options.include?.length) {
590
- const inc = new Set(options.include);
591
- tableNames = tableNames.filter((t) => inc.has(t));
592
- }
593
- if (options.exclude?.length) {
594
- const exc = new Set(options.exclude);
595
- tableNames = tableNames.filter((t) => !exc.has(t));
596
- }
588
+ const candidateTables = (await exec("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = :p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
589
+ // include / exclude + default bookkeeping-table exclusions (F12).
590
+ const tableNames = applyTableFilters(candidateTables, options);
597
591
  const tableSet = new Set(tableNames);
598
592
  // ----- Columns -----
599
593
  const columnRows = (await exec(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY,
@@ -54,6 +54,7 @@
54
54
  * funnel to construct a PowDB client instead of a `pg` client for `powdb://`.
55
55
  */
56
56
  import { ValidationError } from './errors.js';
57
+ import { applyTableFilters } from './introspect.js';
57
58
  import { quotePowqlIdent, requireCapability } from './powdb.js';
58
59
  import { snakeToCamel } from './schema.js';
59
60
  /** Coerce a wire cell to string (legacy wire cells are strings; native cells may be typed). */
@@ -107,24 +108,18 @@ export async function introspectPowdbDatabase(exec, options = {}) {
107
108
  }
108
109
  // ----- Types (one row per table, columns `name`, `columns`) -----
109
110
  const schemaRows = (await exec('schema')).rows;
110
- let tableNames = schemaRows.map((r) => asString(r.name)).filter((n) => n.length > 0);
111
+ const candidateTables = schemaRows.map((r) => asString(r.name)).filter((n) => n.length > 0);
111
112
  // A mis-shaped `exec` (e.g. the raw client's positional `string[][]` rows
112
113
  // passed straight through) yields rows whose `name` cell is `undefined`, so
113
114
  // every table filters out and the schema comes back silently empty. Refuse
114
115
  // that instead of losing data: real rows must carry a `name`.
115
- if (schemaRows.length > 0 && tableNames.length === 0) {
116
+ if (schemaRows.length > 0 && candidateTables.length === 0) {
116
117
  throw new ValidationError(`[turbine] PowDB introspection: the \`schema\` statement returned ${schemaRows.length} row(s) but none carried a ` +
117
118
  '`name` cell. The `exec` you supplied likely returns POSITIONAL rows (string[][]) rather than records keyed by ' +
118
119
  'column name; zip `columns` with each row (see introspectPowdbDatabase docs).');
119
120
  }
120
- if (options.include?.length) {
121
- const inc = new Set(options.include);
122
- tableNames = tableNames.filter((t) => inc.has(t));
123
- }
124
- if (options.exclude?.length) {
125
- const exc = new Set(options.exclude);
126
- tableNames = tableNames.filter((t) => !exc.has(t));
127
- }
121
+ // include / exclude + default bookkeeping-table exclusions (F12).
122
+ const tableNames = applyTableFilters(candidateTables, options);
128
123
  const tables = {};
129
124
  for (const tableName of tableNames) {
130
125
  // `describe` needs the table name in bare-identifier position → quote it so
package/dist/powql.js CHANGED
@@ -38,6 +38,7 @@ import { randomUUID } from 'node:crypto';
38
38
  import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
40
  import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
+ import { expandCompoundUniqueWhere } from './query/compound-unique.js';
41
42
  import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
42
43
  import { escapeLike } from './query/utils.js';
43
44
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
@@ -1007,6 +1008,13 @@ export class PowqlInterface {
1007
1008
  .filter((line) => line.length > 0);
1008
1009
  }
1009
1010
  async findUnique(args) {
1011
+ // Prisma compound-unique selector → column conjunction (engine parity with
1012
+ // the SQL findUnique family; pure metadata, so this is a one-line adoption).
1013
+ if (args.where) {
1014
+ const expanded = expandCompoundUniqueWhere(this.meta, args.where);
1015
+ if (expanded !== args.where)
1016
+ args = { ...args, where: expanded };
1017
+ }
1010
1018
  return this.withMiddleware('findUnique', args, async () => {
1011
1019
  const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1012
1020
  if (!rows.length)
@@ -1736,6 +1744,11 @@ export class PowqlInterface {
1736
1744
  }
1737
1745
  async createMany(args) {
1738
1746
  return this.withMiddleware('createMany', args, async () => {
1747
+ if (args.skipDuplicates) {
1748
+ // PowQL's `insert … returning` has no conflict clause, so there is no
1749
+ // faithful skip-duplicates form; refuse rather than silently insert.
1750
+ throw new UnsupportedFeatureError('createMany({ skipDuplicates: true })', 'powdb', 'PowQL insert has no ON CONFLICT DO NOTHING equivalent, filter duplicates before inserting.');
1751
+ }
1739
1752
  const inputs = args.data.map((d) => this.applyPkDefault(d));
1740
1753
  if (!inputs.length)
1741
1754
  return [];