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
@@ -89,10 +89,9 @@ const schema_js_1 = require("../schema.js");
89
89
  const batched_loader_js_1 = require("./batched-loader.js");
90
90
  const filters_js_1 = require("./filters.js");
91
91
  const utils_js_1 = require("./utils.js");
92
+ const warn_registry_js_1 = require("./warn-registry.js");
92
93
  const whereMod = __importStar(require("./where.js"));
93
94
  const writesMod = __importStar(require("./writes.js"));
94
- /** Relations already warned about missing FK indexes (once per process, dev only). */
95
- const unindexedRelationWarned = new Set();
96
95
  /**
97
96
  * Resolve select/omit options into a list of snake_case column names.
98
97
  * Returns null if neither is provided (meaning all columns).
@@ -1397,10 +1396,13 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
1397
1396
  // instead of letting the slowness look like an ORM problem.
1398
1397
  if (process.env.NODE_ENV !== 'production') {
1399
1398
  const warnKey = `${relDef.from}.${relDef.name}`;
1400
- if (!unindexedRelationWarned.has(warnKey)) {
1399
+ // Compute the (potentially costly) probe only for a key not yet warned, and
1400
+ // claim the key through the process-wide registry ONLY when we actually warn,
1401
+ // so a dual-package / HMR-reevaluated second module copy cannot re-warn the
1402
+ // same relation, and indexed relations never consume the dedupe cap.
1403
+ if (!(0, warn_registry_js_1.hasWarnedOnce)(warn_registry_js_1.WARN_NS.unindexedRelation, warnKey)) {
1401
1404
  const miss = (0, index_advisor_js_1.missingIndexForRelation)(qi.schema, relDef);
1402
- if (miss) {
1403
- unindexedRelationWarned.add(warnKey);
1405
+ if (miss && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unindexedRelation, warnKey)) {
1404
1406
  console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
1405
1407
  `"${miss.table}"(${miss.columns.join(', ')}) which has no covering index — ` +
1406
1408
  `each parent row scans the full table. Fix: ${miss.createSql}; ` +
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm, process-wide once-per-key dev-warning dedupe registry.
4
+ *
5
+ * Several dev-only diagnostics (the missing-FK-index warning in relations.ts,
6
+ * the `relationLoadStrategy: 'auto'` engagement note, the deep-`with` warning)
7
+ * must fire AT MOST ONCE per distinct key for the life of the process. A
8
+ * module-level `Set` almost does this, but it is defeated by the two field
9
+ * realities this package actually ships into:
10
+ *
11
+ * 1. **Dual-package loading.** Turbine ships ESM (`dist/`) AND CJS
12
+ * (`dist/cjs/`). A mixed `require`/`import` graph (a compat layer, a tool
13
+ * that loads both) instantiates the module twice, giving two independent
14
+ * `Set`s that each warn once, a double warning.
15
+ * 2. **Bundler / HMR re-evaluation.** Under Next.js dev the module is
16
+ * re-evaluated per recompile, resetting a module-level `Set` and making the
17
+ * warning appear to fire every time.
18
+ *
19
+ * Hanging the registry off `globalThis` under a `Symbol.for(...)` key gives every
20
+ * module copy in the realm ONE shared registry (cross-copy identity without
21
+ * polluting enumerable globals), and `globalThis` survives webpack recompiles
22
+ * because the realm persists, which is exactly what fixes the every-recompile
23
+ * firing in dev servers. Per-process firing (worker threads, separate processes)
24
+ * is acceptable and stays.
25
+ *
26
+ * Bounded: each namespace stops recording AND stops warning once it reaches
27
+ * {@link WARN_ONCE_CAP} distinct keys. A schema with 500+ distinct unindexed
28
+ * relations has long since gotten the message, and the cap prevents unbounded
29
+ * growth if metadata objects are churned dynamically. (Clearing on overflow
30
+ * would be wrong, it would re-warn.)
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.WARN_NS = exports.WARN_ONCE_CAP = void 0;
34
+ exports.shouldWarnOnce = shouldWarnOnce;
35
+ exports.hasWarnedOnce = hasWarnedOnce;
36
+ exports.resetWarnOnce = resetWarnOnce;
37
+ const REGISTRY_KEY = Symbol.for('turbine.warnOnce.registry');
38
+ /** Per-namespace cap on distinct recorded keys (see module doc). */
39
+ exports.WARN_ONCE_CAP = 500;
40
+ function registry() {
41
+ const g = globalThis;
42
+ let reg = g[REGISTRY_KEY];
43
+ if (!reg) {
44
+ reg = Object.create(null);
45
+ g[REGISTRY_KEY] = reg;
46
+ }
47
+ return reg;
48
+ }
49
+ function namespaceSet(ns) {
50
+ const reg = registry();
51
+ let set = reg[ns];
52
+ if (!set) {
53
+ set = new Set();
54
+ reg[ns] = set;
55
+ }
56
+ return set;
57
+ }
58
+ /**
59
+ * Record `(ns, key)` and report whether THIS call is the first to see it
60
+ * process-wide. Returns `true` exactly once per distinct key (the caller should
61
+ * emit its warning then), `false` on every subsequent call for that key, and
62
+ * `false` once the namespace has recorded {@link WARN_ONCE_CAP} distinct keys
63
+ * (bounded growth; the warning simply stops rather than re-firing).
64
+ */
65
+ function shouldWarnOnce(ns, key) {
66
+ const set = namespaceSet(ns);
67
+ if (set.has(key))
68
+ return false;
69
+ if (set.size >= exports.WARN_ONCE_CAP)
70
+ return false;
71
+ set.add(key);
72
+ return true;
73
+ }
74
+ /** True when `(ns, key)` has already been recorded (no mutation). */
75
+ function hasWarnedOnce(ns, key) {
76
+ return namespaceSet(ns).has(key);
77
+ }
78
+ /**
79
+ * @internal Test-only: clear one namespace, or the whole registry when `ns` is
80
+ * omitted. Lets a single test process verify that a warning fires once and then
81
+ * re-verify after a reset without spawning a new process.
82
+ */
83
+ function resetWarnOnce(ns) {
84
+ if (ns === undefined) {
85
+ globalThis[REGISTRY_KEY] = undefined;
86
+ return;
87
+ }
88
+ registry()[ns] = undefined;
89
+ }
90
+ /** Namespace constants so callers never typo a bare string. */
91
+ exports.WARN_NS = {
92
+ /** Missing-FK-index runtime warning (relations.ts `buildRelationSubquery`). */
93
+ unindexedRelation: 'unindexedRelation',
94
+ /** `relationLoadStrategy: 'auto'` batched-fallback engagement note. */
95
+ autoStrategy: 'autoStrategy',
96
+ /** Deep-`with` (depth > 5) advisory (builder.ts `findMany`). */
97
+ deepWith: 'deepWith',
98
+ };
@@ -66,6 +66,7 @@ exports.fingerprintSet = fingerprintSet;
66
66
  exports.collectSetParams = collectSetParams;
67
67
  const errors_js_1 = require("../errors.js");
68
68
  const schema_js_1 = require("../schema.js");
69
+ const compound_unique_js_1 = require("./compound-unique.js");
69
70
  const filters_js_1 = require("./filters.js");
70
71
  const whereMod = __importStar(require("./where.js"));
71
72
  /**
@@ -187,7 +188,10 @@ function buildUpdate(qi, args) {
187
188
  qi.currentSkip = args.skipGlobalFilters;
188
189
  const dataObj = args.data;
189
190
  assertNoGeneratedColumns(qi, dataObj, 'update');
190
- const userWhere = args.where;
191
+ // Prisma compound-unique selector (e.g. `{ orgId_userId: { orgId, userId } }`)
192
+ // → the column conjunction, before the empty-`where` guard so the expanded
193
+ // members count as a real predicate.
194
+ const userWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
191
195
  const lock = args.optimisticLock;
192
196
  // The empty-`where` guard checks the USER predicate only — a global filter
193
197
  // must never turn an unguarded mass update into an allowed one.
@@ -293,9 +297,11 @@ function buildUpdate(qi, args) {
293
297
  function buildDelete(qi, args) {
294
298
  assertWritable(qi, 'delete');
295
299
  qi.currentSkip = args.skipGlobalFilters;
300
+ // Prisma compound-unique selector → the column conjunction (before the guard).
301
+ const userWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
296
302
  // Guard the USER predicate (a global filter must not satisfy the guard).
297
- whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
298
- const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
303
+ whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', args.allowFullTableScan);
304
+ const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
299
305
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
300
306
  const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
301
307
  const params = [];
@@ -346,6 +352,8 @@ function buildUpsert(qi, args) {
346
352
  assertNoGeneratedColumns(qi, args.create, 'upsert');
347
353
  assertNoGeneratedColumns(qi, args.update, 'upsert');
348
354
  qi.currentSkip = args.skipGlobalFilters;
355
+ // Prisma compound-unique selector on the conflict target → its member columns.
356
+ const upsertWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
349
357
  // Build the INSERT part from create data
350
358
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
351
359
  const columns = createEntries.map(([k]) => qi.toSqlColumn(k));
@@ -353,7 +361,7 @@ function buildUpsert(qi, args) {
353
361
  // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
354
362
  const placeholders = createEntries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
355
363
  // The conflict target comes from `where` keys — must be unique/PK columns
356
- const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
364
+ const conflictKeys = Object.keys(upsertWhere).filter((k) => upsertWhere[k] !== undefined);
357
365
  const conflictColumns = conflictKeys.map((k) => qi.toSqlColumn(k));
358
366
  // Build the UPDATE SET part
359
367
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
@@ -404,7 +412,7 @@ function buildUpsert(qi, args) {
404
412
  reselect: qi.dialect.resultStrategy === 'reselect'
405
413
  ? async (exec) => {
406
414
  await exec(sql, params);
407
- const sel = buildReselectByWhere(qi, (whereMod.mergeGlobalFilter(qi, args.where) ?? {}));
415
+ const sel = buildReselectByWhere(qi, (whereMod.mergeGlobalFilter(qi, upsertWhere) ?? {}));
408
416
  return exec(sel.sql, sel.params);
409
417
  }
410
418
  : undefined,
@@ -14,6 +14,7 @@ exports.snakeToCamel = snakeToCamel;
14
14
  exports.camelToSnake = camelToSnake;
15
15
  exports.snakeToPascal = snakeToPascal;
16
16
  exports.singularize = singularize;
17
+ exports.withDbFieldNames = withDbFieldNames;
17
18
  // ---------------------------------------------------------------------------
18
19
  // Helpers for composite key handling
19
20
  // ---------------------------------------------------------------------------
@@ -156,3 +157,49 @@ function singularize(s) {
156
157
  return s.slice(0, -1);
157
158
  return s;
158
159
  }
160
+ // ---------------------------------------------------------------------------
161
+ // keepColumnNames transform (F4)
162
+ // ---------------------------------------------------------------------------
163
+ /**
164
+ * Return a copy of `schema` in which every column's TypeScript **field** name is
165
+ * the raw database column name (snake_case) instead of the camelCased default,
166
+ * i.e. `user_id` stays `user_id` rather than becoming `userId`.
167
+ *
168
+ * This is a PURE, generate-time transform with ZERO runtime changes: it only
169
+ * rewrites `column.field` and rebuilds each table's `columnMap` /
170
+ * `reverseColumnMap` as IDENTITY maps (`user_id → user_id`). Because every
171
+ * runtime surface resolves column names through those maps (`toColumn`,
172
+ * `parseRow`, relation `json_build_object` keys, batched-loader stitching,
173
+ * positional decoding, aggregate/groupBy naming), the generated client returns
174
+ * rows keyed by DB column names and accepts DB column names in
175
+ * `where`/`orderBy`/`select` with no code path aware of the difference.
176
+ *
177
+ * Deliberately left untouched (the flag is literally about column names):
178
+ * - `name`, `allColumns`, `dateColumns`, `dialectTypes`, `pgTypes`,
179
+ * `primaryKey`, `uniqueColumns`, `indexes`, `checks`, `isView` (all already
180
+ * keyed by snake_case column names);
181
+ * - `relations` (relation PROPERTY names are synthetic introspection names
182
+ * with no DB column equivalent, and `foreignKey`/`referenceKey`/`through`
183
+ * already hold DB column names);
184
+ * - entity type names, table accessors, and `enums`.
185
+ *
186
+ * Every non-PII field of each {@link ColumnMetadata} (`pii`, `pgType`,
187
+ * `dialectType`, `nullable`, …) is preserved. Exported from the package root so
188
+ * runtime-introspection and serverless users can apply the same identity mapping
189
+ * to a schema they build at runtime, e.g. `turbineHttp(pool,
190
+ * withDbFieldNames(schema))`.
191
+ */
192
+ function withDbFieldNames(schema) {
193
+ const tables = {};
194
+ for (const [tableKey, table] of Object.entries(schema.tables)) {
195
+ const columns = table.columns.map((col) => ({ ...col, field: col.name }));
196
+ const columnMap = {};
197
+ const reverseColumnMap = {};
198
+ for (const col of columns) {
199
+ columnMap[col.name] = col.name;
200
+ reverseColumnMap[col.name] = col.name;
201
+ }
202
+ tables[tableKey] = { ...table, columns, columnMap, reverseColumnMap };
203
+ }
204
+ return { ...schema, tables };
205
+ }
@@ -558,15 +558,10 @@ function pragma(db, sql) {
558
558
  */
559
559
  function introspectSqliteDatabase(db, options = {}) {
560
560
  // ----- Tables (skip SQLite internal + the migration tracking table) -----
561
- let tableNames = pragma(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map((r) => r.name);
562
- if (options.include?.length) {
563
- const inc = new Set(options.include);
564
- tableNames = tableNames.filter((t) => inc.has(t));
565
- }
566
- if (options.exclude?.length) {
567
- const exc = new Set(options.exclude);
568
- tableNames = tableNames.filter((t) => !exc.has(t));
569
- }
561
+ const candidateTables = pragma(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map((r) => r.name);
562
+ // include / exclude + default bookkeeping-table exclusions (F12), shared with
563
+ // every other introspector via applyTableFilters.
564
+ const tableNames = (0, introspect_js_1.applyTableFilters)(candidateTables, options);
570
565
  const tableSet = new Set(tableNames);
571
566
  const columnsByTable = new Map();
572
567
  const pkByTable = new Map();
@@ -15,6 +15,23 @@ export interface TurbineCliConfig {
15
15
  include?: string[];
16
16
  /** Tables to exclude */
17
17
  exclude?: string[];
18
+ /**
19
+ * Extension for the generated `index.ts` sibling imports (F3):
20
+ * `'js'` (`./types.js`), `'none'` (`./types`), or `'auto'` (default:
21
+ * tsconfig-detected, falling back to `'js'`).
22
+ */
23
+ importExtension?: 'js' | 'none' | 'auto';
24
+ /**
25
+ * Keep raw database column names as generated field names (snake_case)
26
+ * instead of camelCase (F4). Opt-in; default `false`.
27
+ */
28
+ keepColumnNames?: boolean;
29
+ /**
30
+ * Opt OUT of the unique-FK → one-to-one (`hasOne`) introspection flip (F2),
31
+ * emitting the pre-0.41 `hasMany` shape for unique-covered child relations.
32
+ * Default `false` (detection on).
33
+ */
34
+ legacyToManyUniques?: boolean;
18
35
  /** Directory for migration files (default: ./turbine/migrations) */
19
36
  migrationsDir?: string;
20
37
  /** Path to seed file. Defaults are resolved from seed.ts, seed.js, then seed.sql. */
@@ -120,6 +137,12 @@ export interface ResolvedConfig {
120
137
  migrationsDir: string;
121
138
  seedFile?: string;
122
139
  schemaFile: string;
140
+ /** Resolved generator import-extension mode (F3). */
141
+ importExtension: 'js' | 'none' | 'auto';
142
+ /** Resolved keep-column-names generator flag (F4). */
143
+ keepColumnNames: boolean;
144
+ /** Resolved opt-out of the unique-FK → hasOne introspection flip (F2). */
145
+ legacyToManyUniques: boolean;
123
146
  }
124
147
  export interface CliOverrides {
125
148
  url?: string;
@@ -127,6 +150,9 @@ export interface CliOverrides {
127
150
  schema?: string;
128
151
  include?: string[];
129
152
  exclude?: string[];
153
+ importExtension?: 'js' | 'none' | 'auto';
154
+ keepColumnNames?: boolean;
155
+ legacyToManyUniques?: boolean;
130
156
  }
131
157
  /**
132
158
  * Merge config file values with CLI overrides and env vars.
@@ -141,6 +141,9 @@ export function resolveConfig(fileConfig, overrides) {
141
141
  migrationsDir: fileConfig.migrationsDir ?? './turbine/migrations',
142
142
  seedFile: fileConfig.seed ?? fileConfig.seedFile,
143
143
  schemaFile: fileConfig.schemaFile ?? './turbine/schema.ts',
144
+ importExtension: overrides.importExtension ?? fileConfig.importExtension ?? 'auto',
145
+ keepColumnNames: overrides.keepColumnNames ?? fileConfig.keepColumnNames ?? false,
146
+ legacyToManyUniques: overrides.legacyToManyUniques ?? fileConfig.legacyToManyUniques ?? false,
144
147
  };
145
148
  }
146
149
  /**
@@ -5,6 +5,7 @@
5
5
  * Commands:
6
6
  * turbine init — Initialize a Turbine project
7
7
  * turbine generate | pull — Introspect database and generate TypeScript types
8
+ * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
8
9
  * turbine push - Apply schema-builder definitions to database (destructive ops gated)
9
10
  * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
10
11
  * turbine migrate up — Apply pending migrations
@@ -60,6 +61,12 @@ export interface CliArgs {
60
61
  includeViews?: boolean;
61
62
  /** Omit the `Generated at:` header line for reproducible (diff-stable) output. */
62
63
  noTimestamp?: boolean;
64
+ /** `generate --import-ext <js|none|auto>`: sibling-import extension mode (F3). */
65
+ importExtension?: 'js' | 'none' | 'auto';
66
+ /** `generate --keep-column-names`: keep raw DB column names as field names (F4). */
67
+ keepColumnNames?: boolean;
68
+ /** `generate --legacy-to-many-uniques`: opt out of the unique-FK → hasOne flip (F2). */
69
+ legacyToManyUniques?: boolean;
63
70
  port?: number;
64
71
  host?: string;
65
72
  noOpen?: boolean;
@@ -71,6 +78,10 @@ export interface CliArgs {
71
78
  showPii?: boolean;
72
79
  /** Launch Studio with a seeded in-memory sample database (`studio --demo`). */
73
80
  demo?: boolean;
81
+ /** `migrate-from-prisma --allow-partial`: exit 0 even when some items are UNRESOLVED. */
82
+ allowPartial?: boolean;
83
+ /** `migrate-from-prisma --no-db`: parse-only, skip database resolution. */
84
+ noDb?: boolean;
74
85
  }
75
86
  export declare function parseArgs(argv?: string[]): CliArgs;
76
87
  /** Where a resolved `DATABASE_URL` came from, after the `.env` load. */
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Commands:
6
6
  * turbine init — Initialize a Turbine project
7
7
  * turbine generate | pull — Introspect database and generate TypeScript types
8
+ * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
8
9
  * turbine push - Apply schema-builder definitions to database (destructive ops gated)
9
10
  * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
10
11
  * turbine migrate up — Apply pending migrations
@@ -27,7 +28,7 @@ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realp
27
28
  import { tmpdir } from 'node:os';
28
29
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
29
30
  import { pathToFileURL } from 'node:url';
30
- import { generate } from '../generate.js';
31
+ import { generate, generatePrismaMap } from '../generate.js';
31
32
  import { findMissingRelationIndexes } from '../index-advisor.js';
32
33
  import { introspect } from '../introspect.js';
33
34
  import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
@@ -37,6 +38,9 @@ import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from
37
38
  import { runMcpServer } from './mcp.js';
38
39
  import { buildDiffMigrationBody, collectUpDestructive, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
39
40
  import { startObserve } from './observe.js';
41
+ import { formatPrismaReport, summaryLines } from './prisma-report.js';
42
+ import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.js';
43
+ import { PrismaParseError, parsePrismaSchema } from './prisma-schema.js';
40
44
  import { startStudio } from './studio.js';
41
45
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
42
46
  export function parseArgs(argv = process.argv.slice(2)) {
@@ -126,6 +130,21 @@ export function parseArgs(argv = process.argv.slice(2)) {
126
130
  case '--no-timestamp':
127
131
  result.noTimestamp = true;
128
132
  break;
133
+ case '--import-ext':
134
+ case '--import-extension':
135
+ if (next !== 'js' && next !== 'none' && next !== 'auto') {
136
+ console.error(`--import-ext requires one of: js, none, auto (got ${next ?? '(nothing)'})`);
137
+ process.exit(1);
138
+ }
139
+ result.importExtension = next;
140
+ i++;
141
+ break;
142
+ case '--keep-column-names':
143
+ result.keepColumnNames = true;
144
+ break;
145
+ case '--legacy-to-many-uniques':
146
+ result.legacyToManyUniques = true;
147
+ break;
129
148
  case '--allow-destructive':
130
149
  result.allowDestructive = true;
131
150
  break;
@@ -172,6 +191,12 @@ export function parseArgs(argv = process.argv.slice(2)) {
172
191
  case '--demo':
173
192
  result.demo = true;
174
193
  break;
194
+ case '--allow-partial':
195
+ result.allowPartial = true;
196
+ break;
197
+ case '--no-db':
198
+ result.noDb = true;
199
+ break;
175
200
  default:
176
201
  if (!arg.startsWith('-')) {
177
202
  result.positional.push(arg);
@@ -887,17 +912,25 @@ async function cmdGenerate(args, config) {
887
912
  newline();
888
913
  // Introspect
889
914
  const spinner = new Spinner('Introspecting database schema').start();
915
+ const skippedInternalTables = [];
890
916
  const schema = await introspect({
891
917
  connectionString: url,
892
918
  schema: config.schema,
893
919
  include: config.include.length ? config.include : undefined,
894
920
  exclude: config.exclude.length ? config.exclude : undefined,
895
921
  includeViews: args.includeViews,
922
+ legacyToManyUniques: config.legacyToManyUniques,
923
+ onDefaultTableExclusion: (tables) => skippedInternalTables.push(...tables),
896
924
  });
897
925
  const tableNames = Object.keys(schema.tables);
898
926
  const totalColumns = Object.values(schema.tables).reduce((sum, t) => sum + t.columns.length, 0);
899
927
  const totalRelations = Object.values(schema.tables).reduce((sum, t) => sum + Object.keys(t.relations).length, 0);
900
928
  spinner.succeed(`Found ${bold(String(tableNames.length))} tables, ${bold(String(totalColumns))} columns, ${bold(String(totalRelations))} relations`);
929
+ // F12: make the default bookkeeping-table exclusions discoverable rather than
930
+ // silent. `include` re-adds any of them byte-for-byte.
931
+ for (const t of skippedInternalTables) {
932
+ console.log(` ${dim(`${symbols.teeEnd} skipped internal table ${t} (add it to include to keep it)`)}`);
933
+ }
901
934
  // Guard: zero tables means the generated client would be empty. That is almost
902
935
  // always a misconfiguration (wrong `schema`, an include/exclude that filtered
903
936
  // everything, or a database with no tables yet) rather than intent. Fail loudly
@@ -937,6 +970,8 @@ async function cmdGenerate(args, config) {
937
970
  connectionString: url,
938
971
  zod: args.zod,
939
972
  noTimestamp: args.noTimestamp,
973
+ importExtension: config.importExtension,
974
+ keepColumnNames: config.keepColumnNames,
940
975
  });
941
976
  genSpinner.succeed(`Generated ${bold(String(result.files.length))} files in ${elapsed(startTime)}`);
942
977
  // List files
@@ -955,6 +990,112 @@ async function cmdGenerate(args, config) {
955
990
  newline();
956
991
  }
957
992
  // ---------------------------------------------------------------------------
993
+ // migrate-from-prisma
994
+ // ---------------------------------------------------------------------------
995
+ /**
996
+ * `turbine migrate-from-prisma --schema prisma/schema.prisma` parses a Prisma
997
+ * schema, resolve its models/fields/relations/compound-uniques against the live
998
+ * database (unless `--no-db`), and emit (a) a Markdown resolution report and
999
+ * (b) a typed `prisma-map.ts` name map next to the generated client.
1000
+ *
1001
+ * NOTE: within THIS command `--schema` names the Prisma schema FILE (not the
1002
+ * Postgres namespace, which the rest of the CLI's `--schema` means). The
1003
+ * Postgres namespace is `public` here; multi-schema (`@@schema`) is unsupported
1004
+ * in v1 and listed as a parser note in the report.
1005
+ */
1006
+ async function cmdMigrateFromPrisma(args, config) {
1007
+ banner();
1008
+ // `--schema` is the Prisma schema file path in this command.
1009
+ const prismaPath = resolve(args.schema ?? 'prisma/schema.prisma');
1010
+ if (!existsSync(prismaPath)) {
1011
+ error(`Prisma schema file not found: ${cyan(prismaPath)}`);
1012
+ newline();
1013
+ console.log(` ${dim('Point at it with')} ${cyan('--schema <path/to/schema.prisma>')} ${dim('(default: prisma/schema.prisma).')}`);
1014
+ newline();
1015
+ process.exit(1);
1016
+ }
1017
+ // Parse (fatal only on a construct we must understand).
1018
+ const source = readFileSync(prismaPath, 'utf-8');
1019
+ let ast;
1020
+ try {
1021
+ ast = parsePrismaSchema(source);
1022
+ }
1023
+ catch (err) {
1024
+ if (err instanceof PrismaParseError) {
1025
+ newline();
1026
+ error(`Could not parse ${cyan(prismaPath)}`);
1027
+ console.log(` ${red(err.message)}`);
1028
+ newline();
1029
+ process.exit(1);
1030
+ }
1031
+ throw err;
1032
+ }
1033
+ label('Prisma schema', prismaPath);
1034
+ label('Models', String(ast.models.length));
1035
+ label('Enums', String(ast.enums.length));
1036
+ // Resolve against the live database, unless --no-db (parse-only).
1037
+ let schemaMeta = null;
1038
+ if (args.noDb) {
1039
+ info('Parse-only mode (--no-db): names will not be resolved.');
1040
+ }
1041
+ else {
1042
+ const url = requireUrl(config);
1043
+ label('Database', redactUrl(url));
1044
+ const spinner = new Spinner('Introspecting database schema').start();
1045
+ schemaMeta = await introspect({
1046
+ connectionString: url,
1047
+ // The Postgres NAMESPACE is fixed to `public` here (`--schema` names the
1048
+ // Prisma file, not the namespace).
1049
+ schema: 'public',
1050
+ // Prisma `view` models resolve against introspected views.
1051
+ includeViews: true,
1052
+ // Inherit the shared bookkeeping-table exclusions (Turbine + Prisma).
1053
+ exclude: [...new Set([...config.exclude, ...DEFAULT_EXCLUDED_TABLES])],
1054
+ });
1055
+ spinner.succeed(`Introspected ${bold(String(Object.keys(schemaMeta.tables).length))} tables`);
1056
+ }
1057
+ newline();
1058
+ const result = resolvePrismaSchema(ast, schemaMeta);
1059
+ // Console summary.
1060
+ header('Resolution');
1061
+ for (const line of summaryLines(result)) {
1062
+ const marker = line.includes('[UNRESOLVED]') ? red(symbols.cross) : green(symbols.check);
1063
+ console.log(` ${marker} ${line}`);
1064
+ }
1065
+ newline();
1066
+ // Write outputs into the generate outDir.
1067
+ const outDir = resolve(config.out);
1068
+ const rel = relative(process.cwd(), outDir);
1069
+ if (rel.startsWith('..') || resolve(rel) !== outDir) {
1070
+ error(`Output directory must be within the project root. Got: ${config.out}`);
1071
+ newline();
1072
+ process.exit(1);
1073
+ }
1074
+ mkdirSync(outDir, { recursive: true });
1075
+ const reportPath = join(outDir, 'prisma-migration-report.md');
1076
+ writeFileSync(reportPath, formatPrismaReport(result, { schemaPath: prismaPath, noTimestamp: args.noTimestamp }), 'utf-8');
1077
+ console.log(` ${dim(symbols.teeEnd)} ${cyan(reportPath)} ${dim('(report)')}`);
1078
+ if (!args.noDb) {
1079
+ const mapPath = join(outDir, 'prisma-map.ts');
1080
+ writeFileSync(mapPath, generatePrismaMap(result.map, { noTimestamp: args.noTimestamp }), 'utf-8');
1081
+ console.log(` ${dim(symbols.teeEnd)} ${cyan(mapPath)} ${dim('(typed name map)')}`);
1082
+ }
1083
+ newline();
1084
+ // Exit non-zero when anything is UNRESOLVED, unless --allow-partial.
1085
+ if (result.hasUnresolved && !args.allowPartial) {
1086
+ warn('Some items could not be resolved (see the report). Re-run with --allow-partial to accept a partial map.');
1087
+ newline();
1088
+ process.exit(1);
1089
+ }
1090
+ if (args.noDb) {
1091
+ info(`Parse-only report written. Re-run without ${cyan('--no-db')} against your database to resolve names.`);
1092
+ }
1093
+ else {
1094
+ success('Prisma name map generated.');
1095
+ }
1096
+ newline();
1097
+ }
1098
+ // ---------------------------------------------------------------------------
958
1099
  // Command: push
959
1100
  // ---------------------------------------------------------------------------
960
1101
  async function cmdPush(args, config) {
@@ -2121,6 +2262,7 @@ function showSubcommandHelp(command) {
2121
2262
  init: showInitHelp,
2122
2263
  generate: showGenerateHelp,
2123
2264
  pull: showGenerateHelp,
2265
+ 'migrate-from-prisma': showMigrateFromPrismaHelp,
2124
2266
  push: showPushHelp,
2125
2267
  migrate: showMigrateHelp,
2126
2268
  migration: showMigrateHelp,
@@ -2182,9 +2324,39 @@ function showGenerateHelp() {
2182
2324
  console.log(` ${cyan('--zod')} Also emit ${cyan('zod.ts')} validation schemas ${dim('(needs the zod dep)')}`);
2183
2325
  console.log(` ${cyan('--include-views')} Include views + materialized views as read-only entities`);
2184
2326
  console.log(` ${cyan('--no-timestamp')} Omit the ${dim('Generated at:')} header line ${dim('(reproducible, diff-stable output)')}`);
2327
+ console.log(` ${cyan('--import-ext')} ${dim('<mode>')} Sibling-import extension: ${cyan('js')} / ${cyan('none')} / ${cyan('auto')} ${dim('(default: auto)')}`);
2328
+ console.log(` ${cyan('--keep-column-names')} Keep raw DB column names as field names ${dim('(snake_case, not camelCase)')}`);
2329
+ console.log(` ${cyan('--legacy-to-many-uniques')} Emit ${cyan('hasMany')} for unique-FK children ${dim('(pre-0.41 shape; default flips to hasOne)')}`);
2185
2330
  console.log(` ${cyan('--allow-empty')} Generate even when introspection matches 0 tables`);
2186
2331
  newline();
2187
2332
  }
2333
+ function showMigrateFromPrismaHelp() {
2334
+ banner();
2335
+ console.log(` ${bold('turbine migrate-from-prisma')} - Map a Prisma schema onto a Turbine client`);
2336
+ newline();
2337
+ console.log(` ${bold('Usage:')}`);
2338
+ console.log(` npx turbine migrate-from-prisma ${dim('--schema prisma/schema.prisma [options]')}`);
2339
+ newline();
2340
+ console.log(` Parses your ${cyan('schema.prisma')}, resolves models/fields/relations/compound`);
2341
+ console.log(` uniques against the live database, and writes into the output directory:`);
2342
+ console.log(` ${dim('•')} ${cyan('prisma-migration-report.md')} - per-model resolution + unresolved items`);
2343
+ console.log(` ${dim('•')} ${cyan('prisma-map.ts')} - typed PRISMA_MAP name map`);
2344
+ newline();
2345
+ console.log(` ${dim('Note:')} here ${cyan('--schema')} names the Prisma FILE (not the Postgres namespace).`);
2346
+ newline();
2347
+ console.log(` ${bold('Options:')}`);
2348
+ console.log(` ${cyan('--schema')} ${dim('<file>')} Path to schema.prisma ${dim('(default: prisma/schema.prisma)')}`);
2349
+ console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string ${dim('(unless --no-db)')}`);
2350
+ console.log(` ${cyan('--out, -o')} ${dim('<dir>')} Output directory ${dim('(default: ./generated/turbine)')}`);
2351
+ console.log(` ${cyan('--no-db')} Parse-only: write the report without resolving names`);
2352
+ console.log(` ${cyan('--allow-partial')} Exit 0 even when some items are UNRESOLVED`);
2353
+ console.log(` ${cyan('--no-timestamp')} Omit the ${dim('Generated:')} lines ${dim('(reproducible output)')}`);
2354
+ newline();
2355
+ console.log(` ${bold('Examples:')}`);
2356
+ console.log(` ${dim('$')} DATABASE_URL=postgres://... npx turbine migrate-from-prisma --schema prisma/schema.prisma`);
2357
+ console.log(` ${dim('$')} npx turbine migrate-from-prisma --schema prisma/schema.prisma --no-db`);
2358
+ newline();
2359
+ }
2188
2360
  function showPushHelp() {
2189
2361
  banner();
2190
2362
  console.log(` ${bold('turbine push')} — Apply schema-builder definitions to database`);
@@ -2297,6 +2469,7 @@ function showHelp() {
2297
2469
  console.log(` ${bold('Commands:')}`);
2298
2470
  console.log(` ${cyan('init')} Initialize a Turbine project`);
2299
2471
  console.log(` ${cyan('generate')} ${dim('| pull')} Introspect database ${symbols.arrow} generate types`);
2472
+ console.log(` ${cyan('migrate-from-prisma')} Map a schema.prisma onto Turbine ${dim('(report + typed name map)')}`);
2300
2473
  console.log(` ${cyan('push')} Apply schema definitions to database`);
2301
2474
  console.log(` ${cyan('migrate')} ${dim('<sub>')} SQL migration management`);
2302
2475
  console.log(` ${dim('create <name>')} Create a new migration file`);
@@ -2457,6 +2630,9 @@ async function main() {
2457
2630
  schema: args.schema,
2458
2631
  include: args.include,
2459
2632
  exclude: args.exclude,
2633
+ importExtension: args.importExtension,
2634
+ keepColumnNames: args.keepColumnNames,
2635
+ legacyToManyUniques: args.legacyToManyUniques,
2460
2636
  };
2461
2637
  const config = resolveConfig(fileConfig, overrides);
2462
2638
  // Warn (don't change precedence) when an .env-sourced DATABASE_URL is silently
@@ -2483,6 +2659,9 @@ async function main() {
2483
2659
  case 'pull':
2484
2660
  await cmdGenerate(args, config);
2485
2661
  break;
2662
+ case 'migrate-from-prisma':
2663
+ await cmdMigrateFromPrisma(args, config);
2664
+ break;
2486
2665
  case 'push':
2487
2666
  await cmdPush(args, config);
2488
2667
  break;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Render a {@link ResolutionResult} into the `prisma-migration-report.md`
3
+ * artifact and a short console summary. Pure leaf - string in, string out.
4
+ */
5
+ import type { ResolutionResult } from './prisma-resolve.js';
6
+ export interface ReportOptions {
7
+ /** Path the Prisma schema was read from (for the report header). */
8
+ schemaPath?: string;
9
+ /** Omit the volatile `Generated: <ISO>` line for reproducible output. */
10
+ noTimestamp?: boolean;
11
+ }
12
+ /**
13
+ * Build the full Markdown migration report.
14
+ */
15
+ export declare function formatPrismaReport(result: ResolutionResult, options?: ReportOptions): string;
16
+ /** Flat list of unresolved item descriptions across the whole result. */
17
+ export declare function collectUnresolved(result: ResolutionResult): string[];
18
+ /** A one-line-per-model console summary for the CLI. */
19
+ export declare function summaryLines(result: ResolutionResult): string[];