turbine-orm 0.29.0 → 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 +33 -3
  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 +424 -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 +6 -2
  22. package/dist/client.js +33 -3
  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 +87 -25
  34. package/dist/powdb.js +419 -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
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — defineSchema() → SchemaMetadata bridge
4
+ *
5
+ * Converts a code-first {@link SchemaDef} (the output of `defineSchema()`)
6
+ * into the runtime {@link SchemaMetadata} shape that the query builder,
7
+ * `TurbineClient`, and the non-SQL engines consume — without touching a
8
+ * live database.
9
+ *
10
+ * Why this exists: the historical converter path (`introspect()` +
11
+ * `generate()`) requires a running SQL database, but code-first engines
12
+ * (PowDB, in-memory SQLite bootstraps, tests) only have the `SchemaDef`.
13
+ * `schemaDefToMetadata()` is the pure-function equivalent: its output
14
+ * matches what `turbine generate` would emit into `metadata.ts` for the
15
+ * same schema, minus the pieces only a live catalog can know (real index
16
+ * names, constraint names, view flags).
17
+ *
18
+ * Parity notes (ground truth = introspect.ts + generate.ts):
19
+ * - Relations are derived from `references:` exactly like introspection
20
+ * derives them from foreign keys: a `belongsTo` on the child table and
21
+ * a `hasMany` on the parent, with the same disambiguation rules when
22
+ * multiple FKs point at the same target.
23
+ * - Pure junction tables (2-column composite PK that IS the two
24
+ * single-column FKs to two distinct tables, no payload columns) get
25
+ * the same conservative auto-`manyToMany` treatment as introspection.
26
+ * - Explicit `manyToMany` declarations on the SchemaDef are merged via
27
+ * {@link applyManyToManyRelations} (additive, never clobbering).
28
+ * - `indexes` is always `[]` — SchemaDef cannot express indexes, and an
29
+ * empty list keeps `schemaHasIndexInfo()` false so the index advisor
30
+ * and the dev-mode missing-index warning stay silent instead of
31
+ * producing blanket false positives.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * import { defineSchema, schemaDefToMetadata } from 'turbine-orm';
36
+ *
37
+ * const def = defineSchema({
38
+ * users: { id: { type: 'serial', primaryKey: true }, name: { type: 'text', notNull: true } },
39
+ * posts: { id: { type: 'serial', primaryKey: true },
40
+ * userId: { type: 'integer', notNull: true, references: 'users.id' } },
41
+ * });
42
+ * const metadata = schemaDefToMetadata(def);
43
+ * // → usable anywhere SchemaMetadata is expected (e.g. turbinePowDB, TurbineClient)
44
+ * ```
45
+ */
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.schemaDefToMetadata = schemaDefToMetadata;
48
+ const introspect_js_1 = require("./introspect.js");
49
+ const schema_js_1 = require("./schema.js");
50
+ const schema_builder_js_1 = require("./schema-builder.js");
51
+ // ---------------------------------------------------------------------------
52
+ // DDL type → Postgres udt_name (what introspection reads from the catalog)
53
+ // ---------------------------------------------------------------------------
54
+ const DDL_TO_UDT = {
55
+ SERIAL: 'int4',
56
+ BIGSERIAL: 'int8',
57
+ BIGINT: 'int8',
58
+ INTEGER: 'int4',
59
+ SMALLINT: 'int2',
60
+ TEXT: 'text',
61
+ VARCHAR: 'varchar',
62
+ BOOLEAN: 'bool',
63
+ TIMESTAMPTZ: 'timestamptz',
64
+ DATE: 'date',
65
+ JSONB: 'jsonb',
66
+ UUID: 'uuid',
67
+ REAL: 'float4',
68
+ 'DOUBLE PRECISION': 'float8',
69
+ NUMERIC: 'numeric',
70
+ BYTEA: 'bytea',
71
+ };
72
+ /** Resolve a ColumnConfig's Postgres base type name (udt_name form). */
73
+ function udtName(config) {
74
+ if (config.type === 'ENUM')
75
+ return config.enumName ?? 'text';
76
+ if (config.type === 'VECTOR')
77
+ return 'vector';
78
+ return DDL_TO_UDT[config.type];
79
+ }
80
+ /** Server-generated (sequence-backed) types — pg's `nextval(...)` default. */
81
+ function isSerialType(type) {
82
+ return type === 'SERIAL' || type === 'BIGSERIAL';
83
+ }
84
+ /**
85
+ * Resolve the raw column part of a `references: 'table.column'` target to a
86
+ * snake_case column name — accepting either the camelCase field name or the
87
+ * snake_case DDL name, mirroring how schema-sql.ts accepts both table forms.
88
+ */
89
+ function resolveColumnName(raw, target) {
90
+ if (target) {
91
+ const viaField = target.fieldToColumn.get(raw);
92
+ if (viaField !== undefined)
93
+ return viaField;
94
+ if (target.columnNames.has(raw))
95
+ return raw;
96
+ }
97
+ return (0, schema_js_1.camelToSnake)(raw);
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // The converter
101
+ // ---------------------------------------------------------------------------
102
+ /**
103
+ * Convert a code-first {@link SchemaDef} into runtime {@link SchemaMetadata}.
104
+ *
105
+ * Pure function — no database connection, no side effects, input untouched.
106
+ * The output is shaped identically to the `SCHEMA` constant `turbine generate`
107
+ * emits from introspection, so it can be handed to any consumer that expects
108
+ * introspected metadata: `new TurbineClient(config, metadata)`,
109
+ * `turbinePowDB(..., metadata)`, `QueryInterface`, the index advisor, etc.
110
+ *
111
+ * What maps:
112
+ * - Columns → full {@link ColumnMetadata} (snake_case name, camelCase field,
113
+ * pg type names, TS types, nullability, defaults, `isGenerated` for
114
+ * serial/bigserial, array + varchar length info, date-column tracking).
115
+ * - Column-level `primaryKey` and table-level composite `primaryKey`.
116
+ * - `unique: true` columns → single-column `uniqueColumns` entries.
117
+ * - `references:` FKs → `belongsTo` (child) + `hasMany` (parent) relations,
118
+ * including `onDelete`/`onUpdate` actions (the `'no action'` default is
119
+ * omitted, matching introspection).
120
+ * - Pure junction tables → auto-detected `manyToMany` relations (same
121
+ * conservative rules as introspection).
122
+ * - Explicit `manyToMany` declarations → merged additively.
123
+ * - Schema-level `enums`.
124
+ *
125
+ * What SchemaDef cannot express (and how it degrades):
126
+ * - Indexes → every table gets `indexes: []`, which keeps
127
+ * `schemaHasIndexInfo()` false so index-advisor consumers produce no
128
+ * false positives on code-first metadata.
129
+ * - Views → never marked (`isView` is introspection-only).
130
+ * - Composite foreign keys → `references:` is single-column by design.
131
+ */
132
+ function schemaDefToMetadata(def) {
133
+ // ----- Pass 1: resolve every table's snake_case names for FK lookups -----
134
+ // Lookup accepts the accessor key (camelCase), the DDL name (snake_case),
135
+ // and the explicit `accessor` field — same tolerance as schema-sql.ts.
136
+ const lookup = new Map();
137
+ for (const [key, tableDef] of Object.entries(def.tables)) {
138
+ const fieldToColumn = new Map();
139
+ const columnNames = new Set();
140
+ for (const field of Object.keys(tableDef.columns)) {
141
+ const snake = (0, schema_js_1.camelToSnake)(field);
142
+ fieldToColumn.set(field, snake);
143
+ columnNames.add(snake);
144
+ }
145
+ const resolved = { name: tableDef.name, fieldToColumn, columnNames };
146
+ lookup.set(key, resolved);
147
+ if (tableDef.name)
148
+ lookup.set(tableDef.name, resolved);
149
+ if (tableDef.accessor)
150
+ lookup.set(tableDef.accessor, resolved);
151
+ }
152
+ // ----- Pass 2: collect resolved single-column FKs -----
153
+ const foreignKeys = [];
154
+ for (const tableDef of Object.values(def.tables)) {
155
+ for (const [field, config] of Object.entries(tableDef.columns)) {
156
+ if (!config.referencesTarget)
157
+ continue;
158
+ const parts = config.referencesTarget.split('.');
159
+ if (parts.length !== 2)
160
+ continue;
161
+ const target = lookup.get(parts[0]);
162
+ // Reference to a table outside this SchemaDef — skip, exactly like
163
+ // introspection skips FKs whose target is excluded from the table set.
164
+ if (!target)
165
+ continue;
166
+ foreignKeys.push({
167
+ sourceTable: tableDef.name,
168
+ sourceColumn: (0, schema_js_1.camelToSnake)(field),
169
+ targetTable: target.name,
170
+ targetColumn: resolveColumnName(parts[1], target),
171
+ onDelete: config.onDelete ?? undefined,
172
+ onUpdate: config.onUpdate ?? undefined,
173
+ });
174
+ }
175
+ }
176
+ // ----- Pass 3: derive belongsTo / hasMany relations (SHARED with introspect)
177
+ //
178
+ // Delegates to the exact same builder introspection uses
179
+ // (`buildRelationsFromForeignKeys`), so the same logical schema yields
180
+ // IDENTICAL relation names whether it arrives via `defineSchema()` or a live
181
+ // catalog: legacy-first naming, per-column disambiguation for several FKs to
182
+ // the same target, and collision resolution against scalar column fields
183
+ // (json/jsonb `unknown`-typed shadows keep the historical name). The old
184
+ // local reimplementation had NO collision guard — `posts.user` (text) +
185
+ // `userId references users.id` produced a relation `user` that shadowed the
186
+ // scalar, and two FKs deriving the same name silently clobbered each other
187
+ // (N-4).
188
+ //
189
+ // Constraint names are synthesized in pg's default `<table>_<column>_fkey`
190
+ // form; they only feed the referential-action lookup and composite-FK
191
+ // naming (never hit here — `references:` is single-column by design).
192
+ const fkEntries = [];
193
+ const fkActions = new Map();
194
+ for (const fk of foreignKeys) {
195
+ const constraintName = `${fk.sourceTable}_${fk.sourceColumn}_fkey`;
196
+ fkEntries.push({
197
+ sourceTable: fk.sourceTable,
198
+ sourceColumns: [fk.sourceColumn],
199
+ targetTable: fk.targetTable,
200
+ targetColumns: [fk.targetColumn],
201
+ constraintName,
202
+ });
203
+ fkActions.set(constraintName, {
204
+ onDelete: fk.onDelete ?? 'no action',
205
+ onUpdate: fk.onUpdate ?? 'no action',
206
+ });
207
+ }
208
+ // Taken-name seeds: every table's camelCase column fields (the SchemaDef
209
+ // keys ARE the fields) + which of them are json/jsonb (`unknown`-typed).
210
+ const columnFieldsByTable = new Map();
211
+ const unknownTypedFieldsByTable = new Map();
212
+ for (const tableDef of Object.values(def.tables)) {
213
+ const fields = Object.keys(tableDef.columns);
214
+ columnFieldsByTable.set(tableDef.name, new Set(fields));
215
+ // ENUM columns map to concrete union types in the generated layer, so
216
+ // only genuine json/jsonb (`unknown`-typed) columns qualify as shadows.
217
+ unknownTypedFieldsByTable.set(tableDef.name, new Set(fields.filter((f) => {
218
+ const config = tableDef.columns[f];
219
+ if (config.type === 'ENUM')
220
+ return false;
221
+ const wire = config.isArray ? `_${udtName(config)}` : udtName(config);
222
+ return (0, introspect_js_1.isUnknownTsType)((0, schema_js_1.pgTypeToTs)(wire, false));
223
+ })));
224
+ }
225
+ const relationsByTable = (0, introspect_js_1.buildRelationsFromForeignKeys)(fkEntries, columnFieldsByTable, fkActions, unknownTypedFieldsByTable);
226
+ // ----- Pass 4: conservative junction auto-m2m (SHARED with introspect) ---
227
+ // Same shared detector + naming/collision rules as introspection: a table J
228
+ // is a PURE junction only when its PK is exactly the two single-column FKs
229
+ // to two DISTINCT tables and it has no payload columns.
230
+ const pkByTable = new Map();
231
+ const columnNamesByTable = new Map();
232
+ for (const tableDef of Object.values(def.tables)) {
233
+ const pkFields = tableDef.primaryKey && tableDef.primaryKey.length > 0
234
+ ? [...tableDef.primaryKey]
235
+ : Object.entries(tableDef.columns)
236
+ .filter(([, c]) => c.isPrimaryKey)
237
+ .map(([f]) => f);
238
+ pkByTable.set(tableDef.name, pkFields.map(schema_js_1.camelToSnake));
239
+ columnNamesByTable.set(tableDef.name, Object.keys(tableDef.columns).map(schema_js_1.camelToSnake));
240
+ }
241
+ (0, introspect_js_1.addAutoManyToManyRelations)(Object.values(def.tables).map((t) => t.name), fkEntries, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
242
+ // ----- Pass 5: assemble TableMetadata -----
243
+ const tables = {};
244
+ for (const tableDef of Object.values(def.tables)) {
245
+ const columns = [];
246
+ const columnMap = {};
247
+ const reverseColumnMap = {};
248
+ const dateColumns = new Set();
249
+ const dialectTypes = {};
250
+ const pgTypes = {};
251
+ const allColumns = [];
252
+ const primaryKey = [];
253
+ const uniqueColumns = [];
254
+ for (const [field, config] of Object.entries(tableDef.columns)) {
255
+ const name = (0, schema_js_1.camelToSnake)(field);
256
+ const base = udtName(config);
257
+ // Introspection reports array columns with pg's leading-underscore
258
+ // udt_name (`_text`), which pgTypeToTs also understands.
259
+ const wireType = config.isArray ? `_${base}` : base;
260
+ const serial = isSerialType(config.type);
261
+ // PK members and serials are NOT NULL in Postgres regardless of flags.
262
+ const nullable = !(config.isPrimaryKey || config.isNotNull || serial);
263
+ const col = {
264
+ name,
265
+ field,
266
+ dialectType: wireType,
267
+ pgType: wireType,
268
+ tsType: (0, schema_js_1.pgTypeToTs)(wireType, nullable),
269
+ nullable,
270
+ hasDefault: config.defaultValue != null || serial,
271
+ isArray: config.isArray,
272
+ arrayType: (0, schema_js_1.pgArrayType)(base),
273
+ pgArrayType: (0, schema_js_1.pgArrayType)(base),
274
+ ...(serial ? { isGenerated: true } : {}),
275
+ ...(config.maxLength != null ? { maxLength: config.maxLength } : {}),
276
+ };
277
+ columns.push(col);
278
+ columnMap[field] = name;
279
+ reverseColumnMap[name] = field;
280
+ allColumns.push(name);
281
+ dialectTypes[name] = wireType;
282
+ pgTypes[name] = wireType;
283
+ if ((0, schema_js_1.isDateType)(base))
284
+ dateColumns.add(name);
285
+ if (config.isPrimaryKey)
286
+ primaryKey.push(name);
287
+ if (config.isUnique)
288
+ uniqueColumns.push([name]);
289
+ }
290
+ // Table-level composite PK takes precedence (defineSchema already cleared
291
+ // the column-level flags for its members).
292
+ const pk = tableDef.primaryKey && tableDef.primaryKey.length > 0 ? tableDef.primaryKey.map(schema_js_1.camelToSnake) : primaryKey;
293
+ tables[tableDef.name] = {
294
+ name: tableDef.name,
295
+ columns,
296
+ columnMap,
297
+ reverseColumnMap,
298
+ dateColumns,
299
+ dialectTypes,
300
+ pgTypes,
301
+ allColumns,
302
+ primaryKey: pk,
303
+ uniqueColumns,
304
+ relations: relationsByTable.get(tableDef.name) ?? {},
305
+ // SchemaDef cannot express indexes. An empty list keeps
306
+ // schemaHasIndexInfo() false → no index-advisor false positives.
307
+ indexes: [],
308
+ };
309
+ }
310
+ const enums = {};
311
+ for (const [name, labels] of Object.entries(def.enums ?? {})) {
312
+ enums[name] = [...labels];
313
+ }
314
+ // ----- Pass 6: merge explicit manyToMany declarations (additive) ---------
315
+ return (0, schema_builder_js_1.applyManyToManyRelations)({ tables, enums }, def);
316
+ }
@@ -56,6 +56,7 @@ const node_module_1 = require("node:module");
56
56
  const client_js_1 = require("./client.js");
57
57
  const dialect_js_1 = require("./dialect.js");
58
58
  const errors_js_1 = require("./errors.js");
59
+ const introspect_js_1 = require("./introspect.js");
59
60
  const schema_js_1 = require("./schema.js");
60
61
  let cachedDatabaseSync;
61
62
  /**
@@ -645,95 +646,13 @@ function introspectSqliteDatabase(db, options = {}) {
645
646
  indexesByTable.set(tableName, idxMeta);
646
647
  uniqueByTable.set(tableName, uniques);
647
648
  }
648
- // ----- Build relations from foreign keys (belongsTo + hasMany) -----
649
- const fkCounts = new Map();
650
- for (const fk of foreignKeys) {
651
- const key = `${fk.sourceTable}->${fk.targetTable}`;
652
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
653
- }
654
- const relationsByTable = new Map();
655
- for (const fk of foreignKeys) {
656
- const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
657
- const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
658
- const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
659
- const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
660
- ? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
661
- : (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
662
- if (!relationsByTable.has(fk.sourceTable))
663
- relationsByTable.set(fk.sourceTable, {});
664
- relationsByTable.get(fk.sourceTable)[belongsToName] = {
665
- type: 'belongsTo',
666
- name: belongsToName,
667
- from: fk.sourceTable,
668
- to: fk.targetTable,
669
- foreignKey,
670
- referenceKey,
671
- };
672
- const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
673
- ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
674
- : (0, schema_js_1.snakeToCamel)(fk.sourceTable);
675
- if (!relationsByTable.has(fk.targetTable))
676
- relationsByTable.set(fk.targetTable, {});
677
- relationsByTable.get(fk.targetTable)[hasManyName] = {
678
- type: 'hasMany',
679
- name: hasManyName,
680
- from: fk.targetTable,
681
- to: fk.sourceTable,
682
- foreignKey,
683
- referenceKey,
684
- };
685
- }
686
- // ----- Conservative many-to-many auto-detection (additive) -----
687
- // A table J is a pure junction iff: PK is exactly two columns, exactly two
688
- // single-column FKs whose source columns ARE the PK, two distinct target
689
- // tables, and no payload columns. Mirrors the Postgres introspector.
690
- for (const tableName of tableNames) {
691
- const pk = pkByTable.get(tableName) ?? [];
692
- if (pk.length !== 2)
693
- continue;
694
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
695
- if (tableFks.length !== 2)
696
- continue;
697
- if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
698
- continue;
699
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
700
- const pkSet = new Set(pk);
701
- if (!fkCols.every((c) => pkSet.has(c)))
702
- continue;
703
- if (new Set(fkCols).size !== 2)
704
- continue;
705
- const [fkA, fkB] = tableFks;
706
- if (fkA.targetTable === fkB.targetTable)
707
- continue;
708
- const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
709
- if (jCols.length !== 2)
710
- continue;
711
- const addM2M = (self, other) => {
712
- const sourceTbl = self.targetTable;
713
- const targetTbl = other.targetTable;
714
- const relName = (0, schema_js_1.snakeToCamel)(targetTbl);
715
- if (!relationsByTable.has(sourceTbl))
716
- relationsByTable.set(sourceTbl, {});
717
- const existing = relationsByTable.get(sourceTbl);
718
- if (existing[relName])
719
- return;
720
- existing[relName] = {
721
- type: 'manyToMany',
722
- name: relName,
723
- from: sourceTbl,
724
- to: targetTbl,
725
- referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
726
- foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
727
- through: {
728
- table: tableName,
729
- sourceKey: self.sourceColumns[0],
730
- targetKey: other.sourceColumns[0],
731
- },
732
- };
733
- };
734
- addM2M(fkA, fkB);
735
- addM2M(fkB, fkA);
736
- }
649
+ // ----- Build relations from foreign keys (belongsTo + hasMany + m2m) -----
650
+ // Delegated to the SHARED introspection pipeline (introspect.ts) so SQLite
651
+ // derives IDENTICAL relation names to the Postgres introspector for the
652
+ // same logical schema — legacy-first naming, per-column disambiguation,
653
+ // collision resolution against scalar column fields, and the conservative
654
+ // pure-junction manyToMany auto-detection included.
655
+ const relationsByTable = (0, introspect_js_1.deriveEngineRelations)(tableNames, foreignKeys, pkByTable, columnsByTable);
737
656
  // ----- Assemble TableMetadata -----
738
657
  const tables = {};
739
658
  for (const tableName of tableNames) {
@@ -44,6 +44,8 @@ export interface CliArgs {
44
44
  fix?: boolean;
45
45
  zod?: boolean;
46
46
  includeViews?: boolean;
47
+ /** Omit the `Generated at:` header line for reproducible (diff-stable) output. */
48
+ noTimestamp?: boolean;
47
49
  port?: number;
48
50
  host?: string;
49
51
  noOpen?: boolean;
package/dist/cli/index.js CHANGED
@@ -102,6 +102,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
102
102
  case '--include-views':
103
103
  result.includeViews = true;
104
104
  break;
105
+ case '--no-timestamp':
106
+ result.noTimestamp = true;
107
+ break;
105
108
  case '--allow-destructive':
106
109
  result.allowDestructive = true;
107
110
  break;
@@ -518,6 +521,7 @@ async function cmdGenerate(args, config) {
518
521
  outDir: config.out,
519
522
  connectionString: url,
520
523
  zod: args.zod,
524
+ noTimestamp: args.noTimestamp,
521
525
  });
522
526
  genSpinner.succeed(`Generated ${bold(String(result.files.length))} files in ${elapsed(startTime)}`);
523
527
  // List files
@@ -1464,6 +1468,7 @@ function showGenerateHelp() {
1464
1468
  console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
1465
1469
  console.log(` ${cyan('--zod')} Also emit ${cyan('zod.ts')} validation schemas ${dim('(needs the zod dep)')}`);
1466
1470
  console.log(` ${cyan('--include-views')} Include views + materialized views as read-only entities`);
1471
+ console.log(` ${cyan('--no-timestamp')} Omit the ${dim('Generated at:')} header line ${dim('(reproducible, diff-stable output)')}`);
1467
1472
  console.log(` ${cyan('--allow-empty')} Generate even when introspection matches 0 tables`);
1468
1473
  newline();
1469
1474
  }
package/dist/cli/mcp.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Readable, Writable } from 'node:stream';
2
+ import { type ColumnMetadata, type RelationDef } from '../schema.js';
2
3
  export interface McpServerOptions {
3
4
  url: string;
4
5
  schema: string;
@@ -14,4 +15,21 @@ export interface McpServerHandle {
14
15
  dispose(): Promise<void>;
15
16
  }
16
17
  export declare function startMcpServer(options: McpServerOptions, transport?: McpTransport): McpServerHandle;
18
+ interface ForeignKeyRow {
19
+ source_table: string;
20
+ source_column: string;
21
+ target_table: string;
22
+ target_column: string;
23
+ constraint_name: string;
24
+ }
25
+ /**
26
+ * Group raw FK rows into constraint-level entries and delegate relation
27
+ * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
28
+ * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
29
+ * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
30
+ * generate` derived DIFFERENT relation names from the same database (N-3).
31
+ * Exported for the parity unit test.
32
+ */
33
+ export declare function buildRelations(tableNames: string[], columnsByTable: Map<string, ColumnMetadata[]>, pkByTable: Map<string, string[]>, rows: ForeignKeyRow[], enums?: Record<string, string[]>): Map<string, Record<string, RelationDef>>;
17
34
  export declare function runMcpServer(options: McpServerOptions): Promise<void>;
35
+ export {};
package/dist/cli/mcp.js CHANGED
@@ -3,8 +3,9 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import pg from 'pg';
5
5
  import { findMissingRelationIndexes } from '../index-advisor.js';
6
+ import { addAutoManyToManyRelations, buildRelationsFromForeignKeys, isUnknownTsType, } from '../introspect.js';
6
7
  import { QueryInterface, quoteIdent } from '../query/index.js';
7
- import { isDateType, pgArrayType, pgTypeToTs, singularize, snakeToCamel, } from '../schema.js';
8
+ import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
8
9
  import { listMigrationFiles } from './migrate.js';
9
10
  /**
10
11
  * Walk up from the running script to find turbine-orm's own package.json.
@@ -550,7 +551,7 @@ async function loadSchemaMetadata(client, options) {
550
551
  labels.push(row.enumlabel);
551
552
  enums[row.typname] = labels;
552
553
  }
553
- const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows);
554
+ const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, enums);
554
555
  const tables = {};
555
556
  for (const tableName of tableNames) {
556
557
  const columns = columnsByTable.get(tableName) ?? [];
@@ -589,7 +590,15 @@ async function loadSchemaMetadata(client, options) {
589
590
  }
590
591
  return { tables, enums };
591
592
  }
592
- function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
593
+ /**
594
+ * Group raw FK rows into constraint-level entries and delegate relation
595
+ * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
596
+ * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
597
+ * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
598
+ * generate` derived DIFFERENT relation names from the same database (N-3).
599
+ * Exported for the parity unit test.
600
+ */
601
+ export function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
593
602
  const tableSet = new Set(tableNames);
594
603
  const groups = new Map();
595
604
  for (const row of rows) {
@@ -607,95 +616,18 @@ function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
607
616
  groups.set(row.constraint_name, group);
608
617
  }
609
618
  const foreignKeys = [...groups.values()];
610
- const fkCounts = new Map();
611
- for (const fk of foreignKeys) {
612
- const key = `${fk.sourceTable}→${fk.targetTable}`;
613
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
614
- }
615
- const relations = new Map();
616
- for (const fk of foreignKeys) {
617
- const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
618
- const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
619
- const foreignKey = oneOrMany(fk.sourceColumns);
620
- const referenceKey = oneOrMany(fk.targetColumns);
621
- const belongsToName = needsDisambiguation
622
- ? fk.sourceColumns.length === 1
623
- ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
624
- : snakeToCamel(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
625
- : singularize(snakeToCamel(fk.targetTable));
626
- const hasManyName = needsDisambiguation
627
- ? fk.sourceColumns.length === 1
628
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
629
- : snakeToCamel(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
630
- : snakeToCamel(fk.sourceTable);
631
- const sourceRels = relations.get(fk.sourceTable) ?? {};
632
- sourceRels[belongsToName] = {
633
- type: 'belongsTo',
634
- name: belongsToName,
635
- from: fk.sourceTable,
636
- to: fk.targetTable,
637
- foreignKey,
638
- referenceKey,
639
- };
640
- relations.set(fk.sourceTable, sourceRels);
641
- const targetRels = relations.get(fk.targetTable) ?? {};
642
- targetRels[hasManyName] = {
643
- type: 'hasMany',
644
- name: hasManyName,
645
- from: fk.targetTable,
646
- to: fk.sourceTable,
647
- foreignKey,
648
- referenceKey,
649
- };
650
- relations.set(fk.targetTable, targetRels);
651
- }
652
- addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations);
619
+ const columnFieldsByTable = new Map();
620
+ const unknownTypedFieldsByTable = new Map();
621
+ for (const [tbl, cols] of columnsByTable) {
622
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
623
+ // Enum-typed columns also report tsType 'unknown', but the generated type
624
+ // layer gives them a concrete union — only json/jsonb qualify as shadows.
625
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
626
+ }
627
+ const relations = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
628
+ addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relations, columnFieldsByTable, unknownTypedFieldsByTable);
653
629
  return relations;
654
630
  }
655
- function addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations) {
656
- for (const tableName of tableNames) {
657
- const pk = pkByTable.get(tableName) ?? [];
658
- if (pk.length !== 2)
659
- continue;
660
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
661
- if (tableFks.length !== 2 || tableFks.some((fk) => fk.sourceColumns.length !== 1))
662
- continue;
663
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
664
- const pkSet = new Set(pk);
665
- if (!fkCols.every((column) => pkSet.has(column)) || new Set(fkCols).size !== 2)
666
- continue;
667
- const [fkA, fkB] = tableFks;
668
- if (fkA.targetTable === fkB.targetTable)
669
- continue;
670
- const junctionColumns = (columnsByTable.get(tableName) ?? []).map((column) => column.name);
671
- if (junctionColumns.length !== 2)
672
- continue;
673
- addManyToManyDirection(relations, tableName, fkA, fkB);
674
- addManyToManyDirection(relations, tableName, fkB, fkA);
675
- }
676
- }
677
- function addManyToManyDirection(relations, junctionTable, self, other) {
678
- const sourceTable = self.targetTable;
679
- const targetTable = other.targetTable;
680
- const relName = snakeToCamel(targetTable);
681
- const tableRelations = relations.get(sourceTable) ?? {};
682
- if (tableRelations[relName])
683
- return;
684
- tableRelations[relName] = {
685
- type: 'manyToMany',
686
- name: relName,
687
- from: sourceTable,
688
- to: targetTable,
689
- referenceKey: oneOrMany(self.targetColumns),
690
- foreignKey: oneOrMany(self.targetColumns),
691
- through: {
692
- table: junctionTable,
693
- sourceKey: self.sourceColumns[0],
694
- targetKey: other.sourceColumns[0],
695
- },
696
- };
697
- relations.set(sourceTable, tableRelations);
698
- }
699
631
  async function estimateRows(client, schema) {
700
632
  const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
701
633
  FROM pg_class c
@@ -723,9 +655,6 @@ function extractIndexColumns(indexdef) {
723
655
  .replace(/ (ASC|DESC)$/i, '')
724
656
  .replace(/^"|"$/g, ''));
725
657
  }
726
- function oneOrMany(columns) {
727
- return columns.length === 1 ? columns[0] : columns;
728
- }
729
658
  function optionalLimit(value) {
730
659
  if (value === undefined)
731
660
  return 50;
package/dist/client.d.ts CHANGED
@@ -149,8 +149,12 @@ export interface TurbineConfig {
149
149
  logging?: boolean;
150
150
  /** Default LIMIT applied to findMany() when no limit is specified (opt-in, default: undefined) */
151
151
  defaultLimit?: number;
152
- /** Log a warning when findMany() is called without a limit (default: false) */
153
- warnOnUnlimited?: boolean;
152
+ /**
153
+ * Log a warning when findMany() is called without a limit (default: false).
154
+ * Pass a per-table map (`{ users: false }`) to override the default for
155
+ * specific tables; per-call `warnOnUnlimited` on findMany args wins over both.
156
+ */
157
+ warnOnUnlimited?: boolean | Record<string, boolean>;
154
158
  /**
155
159
  * Interpret Postgres `timestamp` (without time zone) values as UTC — both
156
160
  * at the driver level (OID 1114 type parser, registered only when Turbine