turbine-orm 0.7.0 → 0.8.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.
@@ -86,23 +86,74 @@ function isTableDef(v) {
86
86
  */
87
87
  function defineSchema(input) {
88
88
  const tables = {};
89
- for (const [tableName, value] of Object.entries(input)) {
89
+ for (const [accessor, value] of Object.entries(input)) {
90
+ // The user-facing key is the camelCase JS accessor; the DDL-facing
91
+ // table name is its snake_case form (e.g. `postTags` → `post_tags`).
92
+ const dbName = camelToSnakeLocal(accessor);
90
93
  if (isTableDef(value)) {
91
94
  // Legacy format: defineSchema({ users: table({ ... }) })
92
- value.name = tableName;
93
- tables[tableName] = value;
95
+ // Stamp both the DDL name and the JS accessor.
96
+ value.name = dbName;
97
+ value.accessor = accessor;
98
+ tables[accessor] = value;
94
99
  }
95
100
  else {
96
101
  // Object format: defineSchema({ users: { id: { type: 'serial' }, ... } })
102
+ const raw = value;
97
103
  const columns = {};
98
- for (const [fieldName, def] of Object.entries(value)) {
104
+ let pk;
105
+ for (const [fieldName, def] of Object.entries(raw)) {
106
+ if (fieldName === 'primaryKey') {
107
+ // Top-level composite primary key declaration
108
+ if (def !== undefined) {
109
+ if (!Array.isArray(def)) {
110
+ throw new Error(`Table "${accessor}": top-level "primaryKey" must be an array of column names (string[])`);
111
+ }
112
+ pk = def;
113
+ }
114
+ continue;
115
+ }
116
+ // Anything else is a ColumnDef
99
117
  columns[fieldName] = resolveColumn(def);
100
118
  }
101
- tables[tableName] = { name: tableName, columns };
119
+ // Validate composite PK references real columns and clear column-level PKs
120
+ // for those columns so we don't double-emit `PRIMARY KEY` clauses.
121
+ // Composite PK members are implicitly NOT NULL — preserve that even
122
+ // when the user clears the column-level `primaryKey: true` flag.
123
+ if (pk && pk.length > 0) {
124
+ for (const colName of pk) {
125
+ if (!(colName in columns)) {
126
+ throw new Error(`Table "${accessor}": composite primaryKey references unknown column "${colName}". ` +
127
+ `Known columns: ${Object.keys(columns).join(', ') || '(none)'}`);
128
+ }
129
+ // A composite PK at the table level supersedes any column-level
130
+ // `primaryKey: true` flag — silently clear it so DDL emission
131
+ // produces a single, valid table-level PRIMARY KEY constraint.
132
+ // Force NOT NULL since PK columns can never be nullable.
133
+ const c = columns[colName];
134
+ if (c) {
135
+ c.isPrimaryKey = false;
136
+ c.isNotNull = true;
137
+ }
138
+ }
139
+ }
140
+ tables[accessor] = {
141
+ name: dbName,
142
+ accessor,
143
+ columns,
144
+ ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
145
+ };
102
146
  }
103
147
  }
104
148
  return { tables };
105
149
  }
150
+ /**
151
+ * Local copy of camelToSnake to avoid a circular import dependency at the
152
+ * top of the file. Mirrors the implementation in `./schema.ts`.
153
+ */
154
+ function camelToSnakeLocal(s) {
155
+ return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
156
+ }
106
157
  // ---------------------------------------------------------------------------
107
158
  // Legacy compat — ColumnBuilder still works for existing code
108
159
  // ---------------------------------------------------------------------------
@@ -229,7 +280,7 @@ function table(columns) {
229
280
  for (const [fieldName, builder] of Object.entries(columns)) {
230
281
  built[fieldName] = builder.build();
231
282
  }
232
- return { name: '', columns: built };
283
+ return { name: '', accessor: '', columns: built };
233
284
  }
234
285
  // ---------------------------------------------------------------------------
235
286
  // Helpers
@@ -29,10 +29,11 @@ function schemaToSQL(schema) {
29
29
  const statements = [];
30
30
  // Topologically sort tables by their foreign key references
31
31
  const sorted = topologicalSort(schema);
32
+ const resolveRef = makeRefResolver(schema);
32
33
  // Generate CREATE TABLE statements
33
34
  for (const tableName of sorted) {
34
35
  const table = schema.tables[tableName];
35
- statements.push(generateCreateTable(table));
36
+ statements.push(generateCreateTable(table, resolveRef));
36
37
  }
37
38
  // Generate CREATE INDEX for foreign key columns
38
39
  for (const tableName of sorted) {
@@ -42,12 +43,51 @@ function schemaToSQL(schema) {
42
43
  }
43
44
  return statements;
44
45
  }
46
+ /**
47
+ * Build a function that resolves a raw `references: 'foo.id'` target name
48
+ * to the snake_case DDL table name. Accepts both the JS-facing camelCase
49
+ * accessor name and the snake_case DDL name; passes through unknown names
50
+ * unchanged so existing call sites continue to work.
51
+ */
52
+ function makeRefResolver(schema) {
53
+ const lookup = buildTableLookup(schema);
54
+ return (rawName) => {
55
+ const key = lookup[rawName];
56
+ if (key) {
57
+ const def = schema.tables[key];
58
+ if (def?.name)
59
+ return def.name;
60
+ }
61
+ return rawName;
62
+ };
63
+ }
64
+ /**
65
+ * Build a lookup index from both DDL names (snake_case) and JS accessor
66
+ * names (camelCase) to table keys, so `references: 'post_tags.id'` and
67
+ * `references: 'postTags.id'` both resolve to the same TableDef.
68
+ */
69
+ function buildTableLookup(schema) {
70
+ const lookup = {};
71
+ for (const [key, def] of Object.entries(schema.tables)) {
72
+ lookup[key] = key;
73
+ if (def.name && def.name !== key)
74
+ lookup[def.name] = key;
75
+ if (def.accessor && def.accessor !== key)
76
+ lookup[def.accessor] = key;
77
+ }
78
+ return lookup;
79
+ }
45
80
  /**
46
81
  * Topologically sort tables so that referenced tables come before referencing ones.
82
+ * Returns the table keys (the same keys used in `schema.tables`). The keys are
83
+ * the JS-facing accessor names; consumers should still call `table.name` to get
84
+ * the snake_case DDL name when emitting SQL.
85
+ *
47
86
  * Falls back to input order for tables with no dependency ordering.
48
87
  */
49
88
  function topologicalSort(schema) {
50
89
  const tableNames = Object.keys(schema.tables);
90
+ const lookup = buildTableLookup(schema);
51
91
  const resolved = new Set();
52
92
  const result = [];
53
93
  const visiting = new Set();
@@ -64,9 +104,10 @@ function topologicalSort(schema) {
64
104
  // Visit all tables this table references
65
105
  for (const col of Object.values(table.columns)) {
66
106
  if (col.referencesTarget) {
67
- const refTable = col.referencesTarget.split('.')[0];
68
- if (refTable !== name && schema.tables[refTable]) {
69
- visit(refTable);
107
+ const refRaw = col.referencesTarget.split('.')[0];
108
+ const refKey = lookup[refRaw];
109
+ if (refKey && refKey !== name) {
110
+ visit(refKey);
70
111
  }
71
112
  }
72
113
  }
@@ -82,12 +123,28 @@ function topologicalSort(schema) {
82
123
  }
83
124
  /**
84
125
  * Generate a CREATE TABLE statement for a single table definition.
126
+ *
127
+ * If `table.primaryKey` is set (composite primary key), emits a table-level
128
+ * `PRIMARY KEY ("col1", "col2", ...)` constraint instead of column-level
129
+ * `PRIMARY KEY` clauses on each member column. The composite PK column
130
+ * names are camelCase JS field names; they are converted to snake_case
131
+ * here.
132
+ *
133
+ * `resolveRef` (when supplied) maps raw `references: 'foo.id'` table names
134
+ * to their snake_case DDL form, so users can write either camelCase JS
135
+ * accessor names or snake_case DDL names.
85
136
  */
86
- function generateCreateTable(table) {
137
+ function generateCreateTable(table, resolveRef) {
87
138
  const tableName = table.name;
88
139
  const columnDefs = [];
140
+ const compositePk = table.primaryKey && table.primaryKey.length > 0 ? table.primaryKey : null;
89
141
  for (const [fieldName, config] of Object.entries(table.columns)) {
90
- columnDefs.push(generateColumnDef(fieldName, config));
142
+ columnDefs.push(generateColumnDef(fieldName, config, resolveRef));
143
+ }
144
+ // Append a table-level PRIMARY KEY constraint when a composite PK is set.
145
+ if (compositePk) {
146
+ const cols = compositePk.map((c) => (0, query_js_1.quoteIdent)((0, schema_js_1.camelToSnake)(c))).join(', ');
147
+ columnDefs.push(`PRIMARY KEY (${cols})`);
91
148
  }
92
149
  const body = columnDefs.map((d) => ` ${d}`).join(',\n');
93
150
  return `CREATE TABLE ${(0, query_js_1.quoteIdent)(tableName)} (\n${body}\n);`;
@@ -95,7 +152,7 @@ function generateCreateTable(table) {
95
152
  /**
96
153
  * Generate a single column definition line (e.g. "id BIGSERIAL PRIMARY KEY").
97
154
  */
98
- function generateColumnDef(fieldName, config) {
155
+ function generateColumnDef(fieldName, config, resolveRef) {
99
156
  const snakeName = (0, schema_js_1.camelToSnake)(fieldName);
100
157
  const parts = [(0, query_js_1.quoteIdent)(snakeName)];
101
158
  // Type
@@ -129,11 +186,14 @@ function generateColumnDef(fieldName, config) {
129
186
  const sqlDefault = normalizeDefault(config.defaultValue);
130
187
  parts.push(`DEFAULT ${sqlDefault}`);
131
188
  }
132
- // REFERENCES
189
+ // REFERENCES — resolve the raw table name through the optional resolver so
190
+ // both camelCase accessor names and snake_case DDL names work.
133
191
  if (config.referencesTarget) {
134
192
  const refParts = config.referencesTarget.split('.');
135
193
  if (refParts.length === 2) {
136
- parts.push(`REFERENCES ${(0, query_js_1.quoteIdent)(refParts[0])}(${(0, query_js_1.quoteIdent)(refParts[1])})`);
194
+ const rawTable = refParts[0];
195
+ const refTable = resolveRef ? resolveRef(rawTable) : rawTable;
196
+ parts.push(`REFERENCES ${(0, query_js_1.quoteIdent)(refTable)}(${(0, query_js_1.quoteIdent)(refParts[1])})`);
137
197
  }
138
198
  }
139
199
  return parts.join(' ');
@@ -240,33 +300,39 @@ async function schemaDiff(schema, connectionString) {
240
300
  dbUniques[row.table_name] = {};
241
301
  dbUniques[row.table_name][row.column_name] = row.constraint_name;
242
302
  }
243
- const schemaTableNames = new Set(Object.keys(schema.tables));
303
+ // Build a set of DDL-facing snake_case table names that the schema defines.
304
+ const schemaDdlNames = new Set();
305
+ for (const def of Object.values(schema.tables))
306
+ schemaDdlNames.add(def.name);
244
307
  const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [] };
308
+ const resolveRef = makeRefResolver(schema);
245
309
  // Tables to create (in schema but not in DB)
246
310
  const sorted = topologicalSort(schema);
247
- for (const tableName of sorted) {
248
- if (!existingTables.has(tableName)) {
249
- const tableDef = schema.tables[tableName];
311
+ for (const tableKey of sorted) {
312
+ const tableDef = schema.tables[tableKey];
313
+ const ddlName = tableDef.name;
314
+ if (!existingTables.has(ddlName)) {
250
315
  result.create.push(tableDef);
251
- result.statements.push(generateCreateTable(tableDef));
316
+ result.statements.push(generateCreateTable(tableDef, resolveRef));
252
317
  const fkIndexes = generateForeignKeyIndexes(tableDef);
253
318
  result.statements.push(...fkIndexes);
254
319
  // Reverse: DROP TABLE (with indexes — they drop automatically)
255
- result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${(0, query_js_1.quoteIdent)(tableName)} CASCADE;`);
320
+ result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${(0, query_js_1.quoteIdent)(ddlName)} CASCADE;`);
256
321
  }
257
322
  }
258
323
  // Tables to drop (in DB but not in schema)
259
324
  for (const existingTable of existingTables) {
260
- if (!schemaTableNames.has(existingTable)) {
325
+ if (!schemaDdlNames.has(existingTable)) {
261
326
  result.drop.push(existingTable);
262
327
  // We don't auto-generate DROP statements for safety
263
328
  }
264
329
  }
265
330
  // Tables to alter (exist in both)
266
- for (const tableName of sorted) {
331
+ for (const tableKey of sorted) {
332
+ const tableDef = schema.tables[tableKey];
333
+ const tableName = tableDef.name;
267
334
  if (!existingTables.has(tableName))
268
335
  continue;
269
- const tableDef = schema.tables[tableName];
270
336
  const dbCols = dbColumns[tableName] ?? {};
271
337
  const tableUniques = dbUniques[tableName] ?? {};
272
338
  const alterDef = { table: tableName, columns: [] };
@@ -275,7 +341,7 @@ async function schemaDiff(schema, connectionString) {
275
341
  const dbCol = dbCols[snakeName];
276
342
  if (!dbCol) {
277
343
  // Column exists in schema but not in DB — ADD COLUMN
278
- const colDef = generateColumnDef(fieldName, config);
344
+ const colDef = generateColumnDef(fieldName, config, resolveRef);
279
345
  const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ADD COLUMN ${colDef};`;
280
346
  const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} DROP COLUMN ${(0, query_js_1.quoteIdent)(snakeName)};`;
281
347
  alterDef.columns.push({ column: snakeName, action: 'add', sql, reverseSql });
@@ -36,12 +36,12 @@
36
36
  * // app/api/users/route.ts
37
37
  * import { Pool } from '@neondatabase/serverless';
38
38
  * import { turbineHttp } from 'turbine-orm/serverless';
39
- * import { schema } from '@/generated/turbine/metadata';
39
+ * import { SCHEMA } from '../../generated/turbine/metadata';
40
40
  *
41
41
  * export const runtime = 'edge';
42
42
  *
43
43
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
44
- * const db = turbineHttp(pool, schema);
44
+ * const db = turbineHttp(pool, SCHEMA);
45
45
  *
46
46
  * export async function GET() {
47
47
  * const users = await db.table('users').findMany({ limit: 10 });
@@ -53,12 +53,12 @@
53
53
  *
54
54
  * ```ts
55
55
  * import { TurbineClient } from 'turbine-orm';
56
- * import { schema } from './generated/turbine/metadata.js';
56
+ * import { SCHEMA } from './generated/turbine/metadata.js';
57
57
  *
58
58
  * const db = new TurbineClient({
59
59
  * connectionString: process.env.SUPABASE_DB_URL,
60
60
  * ssl: { rejectUnauthorized: false },
61
- * }, schema);
61
+ * }, SCHEMA);
62
62
  * ```
63
63
  *
64
64
  * ## Example — Cloudflare Workers
@@ -67,11 +67,12 @@
67
67
  * // Use the Neon HTTP driver which works in Workers runtime
68
68
  * import { Pool } from '@neondatabase/serverless';
69
69
  * import { turbineHttp } from 'turbine-orm/serverless';
70
+ * import { SCHEMA } from './generated/turbine/metadata';
70
71
  *
71
72
  * export default {
72
73
  * async fetch(req: Request, env: Env) {
73
74
  * const pool = new Pool({ connectionString: env.DATABASE_URL });
74
- * const db = turbineHttp(pool, schema);
75
+ * const db = turbineHttp(pool, SCHEMA);
75
76
  * const users = await db.table('users').findMany({ limit: 10 });
76
77
  * return Response.json(users);
77
78
  * }
@@ -97,10 +98,10 @@ const client_js_1 = require("./client.js");
97
98
  * ```ts
98
99
  * import { Pool } from '@neondatabase/serverless';
99
100
  * import { turbineHttp } from 'turbine-orm/serverless';
100
- * import { schema } from './generated/turbine/metadata.js';
101
+ * import { SCHEMA } from './generated/turbine/metadata.js';
101
102
  *
102
103
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
103
- * const db = turbineHttp(pool, schema);
104
+ * const db = turbineHttp(pool, SCHEMA);
104
105
  *
105
106
  * const users = await db.table('users').findMany({ limit: 10 });
106
107
  * ```
package/dist/cli/index.js CHANGED
@@ -26,6 +26,7 @@ import { generate } from '../generate.js';
26
26
  import { introspect } from '../introspect.js';
27
27
  import { schemaDiff, schemaPush } from '../schema-sql.js';
28
28
  import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
29
+ import { needsTsLoader, registerTsLoader } from './loader.js';
29
30
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
30
31
  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';
31
32
  function parseArgs() {
@@ -78,6 +79,9 @@ function parseArgs() {
78
79
  case '--auto':
79
80
  result.auto = true;
80
81
  break;
82
+ case '--allow-drift':
83
+ result.allowDrift = true;
84
+ break;
81
85
  case '--force':
82
86
  case '-f':
83
87
  result.force = true;
@@ -100,6 +104,36 @@ function parseArgs() {
100
104
  return result;
101
105
  }
102
106
  // ---------------------------------------------------------------------------
107
+ // TypeScript loader — user-facing error helper
108
+ // ---------------------------------------------------------------------------
109
+ /**
110
+ * Print a friendly error explaining how to install tsx, then exit.
111
+ * Called when we know we need to load a `.ts` file but the loader isn't available.
112
+ */
113
+ function failMissingTsLoader(filePath, reason) {
114
+ newline();
115
+ error(`Cannot load TypeScript file: ${filePath}`);
116
+ newline();
117
+ if (reason === 'unsupported') {
118
+ console.log(` ${dim('Your Node.js version does not support')} ${cyan('module.register()')}.`);
119
+ console.log(` ${dim('Upgrade to Node.js')} ${cyan('20.6+')} ${dim('or use a')} ${cyan('.js')} ${dim('/')} ${cyan('.mjs')} ${dim('config file.')}`);
120
+ }
121
+ else {
122
+ console.log(` ${dim('Loading .ts config / schema files requires')} ${cyan('tsx')} ${dim('to be installed.')}`);
123
+ newline();
124
+ console.log(` ${dim('Install it as a dev dependency:')}`);
125
+ console.log(` ${cyan('npm install --save-dev tsx')}`);
126
+ console.log(` ${dim('or')}`);
127
+ console.log(` ${cyan('pnpm add -D tsx')}`);
128
+ console.log(` ${dim('or')}`);
129
+ console.log(` ${cyan('yarn add -D tsx')}`);
130
+ newline();
131
+ console.log(` ${dim('Alternatively, rename your file to')} ${cyan('.js')} ${dim('or')} ${cyan('.mjs')}.`);
132
+ }
133
+ newline();
134
+ process.exit(1);
135
+ }
136
+ // ---------------------------------------------------------------------------
103
137
  // Helpers
104
138
  // ---------------------------------------------------------------------------
105
139
  function requireUrl(config) {
@@ -122,6 +156,15 @@ async function loadSchemaFile(schemaFile) {
122
156
  console.log(` ${dim('Create one with:')} ${cyan('turbine init')}`);
123
157
  process.exit(1);
124
158
  }
159
+ // If this is a TypeScript file, ensure the tsx ESM loader is registered
160
+ // before we attempt the dynamic import. Without this, Node throws
161
+ // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
162
+ if (needsTsLoader(absPath)) {
163
+ const status = await registerTsLoader();
164
+ if (status === 'missing' || status === 'unsupported') {
165
+ failMissingTsLoader(schemaFile, status);
166
+ }
167
+ }
125
168
  try {
126
169
  const fileUrl = pathToFileURL(absPath).href;
127
170
  const mod = await import(fileUrl);
@@ -136,6 +179,11 @@ async function loadSchemaFile(schemaFile) {
136
179
  error(`Failed to load schema file: ${schemaFile}`);
137
180
  if (err instanceof Error) {
138
181
  console.log(` ${dim(err.message)}`);
182
+ // If the error is the classic ERR_UNKNOWN_FILE_EXTENSION, give a hint.
183
+ if (err.message.includes('ERR_UNKNOWN_FILE_EXTENSION') || err.message.includes('Unknown file extension')) {
184
+ newline();
185
+ console.log(` ${dim('Hint: install')} ${cyan('tsx')} ${dim('to load .ts files:')} ${cyan('npm install --save-dev tsx')}`);
186
+ }
139
187
  }
140
188
  process.exit(1);
141
189
  }
@@ -486,9 +534,10 @@ async function cmdMigrate(args, config) {
486
534
  console.log(` ${cyan('status')} Show migration status`);
487
535
  newline();
488
536
  console.log(` ${bold('Options:')}`);
489
- console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
490
- console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
491
- console.log(` ${cyan('--dry-run')} Show SQL without executing`);
537
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
538
+ console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
539
+ console.log(` ${cyan('--dry-run')} Show SQL without executing`);
540
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
492
541
  newline();
493
542
  console.log(` ${bold('Examples:')}`);
494
543
  console.log(` ${dim('npx turbine migrate create add_users_table')}`);
@@ -604,9 +653,18 @@ async function cmdMigrateUp(args, config) {
604
653
  newline();
605
654
  return;
606
655
  }
656
+ // Big, loud warning when bypassing drift detection — this is a deliberately
657
+ // dangerous operation and the user should see it on every invocation.
658
+ if (args.allowDrift) {
659
+ warn('--allow-drift is set — checksum validation is DISABLED for this run.');
660
+ console.log(` ${dim('Applied migrations may have been modified or deleted on disk.')}`);
661
+ console.log(` ${dim('Proceed only if you are intentionally rewriting migration history.')}`);
662
+ newline();
663
+ }
607
664
  const spinner = new Spinner('Applying migrations').start();
608
665
  const result = await migrateUp(url, config.migrationsDir, {
609
666
  step: args.step,
667
+ allowDrift: args.allowDrift,
610
668
  });
611
669
  if (result.applied.length === 0 && result.errors.length === 0) {
612
670
  spinner.succeed('All migrations are up to date');
@@ -935,6 +993,7 @@ function showMigrateHelp() {
935
993
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
936
994
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
937
995
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
996
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
938
997
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
939
998
  newline();
940
999
  console.log(` ${bold('Examples:')}`);
@@ -1043,6 +1102,16 @@ async function main() {
1043
1102
  showVersion();
1044
1103
  return;
1045
1104
  }
1105
+ // If the user has a TypeScript config file, register the tsx ESM loader
1106
+ // before we attempt to import it. Otherwise Node throws
1107
+ // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
1108
+ const configPath = findConfigFile();
1109
+ if (needsTsLoader(configPath)) {
1110
+ const status = await registerTsLoader();
1111
+ if (status === 'missing' || status === 'unsupported') {
1112
+ failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1113
+ }
1114
+ }
1046
1115
  // Load config file
1047
1116
  let fileConfig = {};
1048
1117
  try {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * turbine-orm CLI — TypeScript loader registration
3
+ *
4
+ * The CLI loads user-supplied config and schema files via dynamic `import()`.
5
+ * Plain Node has no built-in `.ts` loader, so importing `turbine.config.ts`
6
+ * blows up with `ERR_UNKNOWN_FILE_EXTENSION` unless we register a TypeScript
7
+ * loader first.
8
+ *
9
+ * Strategy:
10
+ * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
11
+ * probe whether `tsx/esm` is resolvable from the user's CWD.
12
+ * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
13
+ * 3. If no, surface an actionable error telling the user to install `tsx`.
14
+ *
15
+ * `tsx` is intentionally NOT a runtime dependency — many projects already
16
+ * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
17
+ */
18
+ /**
19
+ * Detect whether a config / schema file path needs the tsx ESM loader.
20
+ * Returns true for `.ts`, `.mts`, and `.cts` files; false for `.js`, `.mjs`,
21
+ * `.cjs`, `.json`, missing paths, or anything else.
22
+ */
23
+ export declare function needsTsLoader(filePath: string | null | undefined): boolean;
24
+ /**
25
+ * Probe whether `tsx/esm` is resolvable from the user's current working
26
+ * directory. Returns true if `tsx` is installed in the user's project.
27
+ *
28
+ * Accepts an injected `resolver` so unit tests don't need a real filesystem.
29
+ */
30
+ export declare function canResolveTsx(resolver?: (id: string) => string): boolean;
31
+ export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing';
32
+ /**
33
+ * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
34
+ * work. Safe to call multiple times — internal flag prevents double registration.
35
+ *
36
+ * Returns:
37
+ * - 'registered' loader was successfully registered this call
38
+ * - 'already' a loader was previously registered (idempotent)
39
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
40
+ * - 'missing' `tsx` is not installed in the user's project
41
+ */
42
+ export declare function registerTsLoader(): Promise<TsLoaderStatus>;
43
+ /** Reset the loader state — used by unit tests only. */
44
+ export declare function _resetTsLoaderStateForTests(): void;
45
+ //# sourceMappingURL=loader.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * turbine-orm CLI — TypeScript loader registration
3
+ *
4
+ * The CLI loads user-supplied config and schema files via dynamic `import()`.
5
+ * Plain Node has no built-in `.ts` loader, so importing `turbine.config.ts`
6
+ * blows up with `ERR_UNKNOWN_FILE_EXTENSION` unless we register a TypeScript
7
+ * loader first.
8
+ *
9
+ * Strategy:
10
+ * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
11
+ * probe whether `tsx/esm` is resolvable from the user's CWD.
12
+ * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
13
+ * 3. If no, surface an actionable error telling the user to install `tsx`.
14
+ *
15
+ * `tsx` is intentionally NOT a runtime dependency — many projects already
16
+ * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
17
+ */
18
+ import { createRequire } from 'node:module';
19
+ import { pathToFileURL } from 'node:url';
20
+ /**
21
+ * Detect whether a config / schema file path needs the tsx ESM loader.
22
+ * Returns true for `.ts`, `.mts`, and `.cts` files; false for `.js`, `.mjs`,
23
+ * `.cjs`, `.json`, missing paths, or anything else.
24
+ */
25
+ export function needsTsLoader(filePath) {
26
+ if (!filePath)
27
+ return false;
28
+ return /\.(ts|mts|cts)$/i.test(filePath);
29
+ }
30
+ /**
31
+ * Probe whether `tsx/esm` is resolvable from the user's current working
32
+ * directory. Returns true if `tsx` is installed in the user's project.
33
+ *
34
+ * Accepts an injected `resolver` so unit tests don't need a real filesystem.
35
+ */
36
+ export function canResolveTsx(resolver) {
37
+ try {
38
+ if (resolver) {
39
+ resolver('tsx/esm');
40
+ return true;
41
+ }
42
+ // Probe relative to the user's CWD, not Turbine's install location.
43
+ // This way we honour whatever `tsx` version the user has pinned.
44
+ const userRequire = createRequire(`${process.cwd()}/`);
45
+ userRequire.resolve('tsx/esm');
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ let tsLoaderState = null;
53
+ /**
54
+ * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
55
+ * work. Safe to call multiple times — internal flag prevents double registration.
56
+ *
57
+ * Returns:
58
+ * - 'registered' loader was successfully registered this call
59
+ * - 'already' a loader was previously registered (idempotent)
60
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
61
+ * - 'missing' `tsx` is not installed in the user's project
62
+ */
63
+ export async function registerTsLoader() {
64
+ if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
65
+ return 'already';
66
+ }
67
+ if (!canResolveTsx()) {
68
+ tsLoaderState = 'missing';
69
+ return 'missing';
70
+ }
71
+ try {
72
+ const mod = await import('node:module');
73
+ const register = mod.register;
74
+ if (typeof register !== 'function') {
75
+ tsLoaderState = 'unsupported';
76
+ return 'unsupported';
77
+ }
78
+ register('tsx/esm', pathToFileURL(`${process.cwd()}/`));
79
+ tsLoaderState = 'registered';
80
+ return 'registered';
81
+ }
82
+ catch {
83
+ tsLoaderState = 'missing';
84
+ return 'missing';
85
+ }
86
+ }
87
+ /** Reset the loader state — used by unit tests only. */
88
+ export function _resetTsLoaderStateForTests() {
89
+ tsLoaderState = null;
90
+ }
91
+ //# sourceMappingURL=loader.js.map
@@ -85,11 +85,17 @@ export declare function createMigration(migrationsDir: string, name: string, aut
85
85
  * Features:
86
86
  * - Idempotent: running twice is safe (already-applied migrations are skipped)
87
87
  * - Advisory lock: prevents concurrent migration runs
88
- * - Checksum validation: detects modified migration files
88
+ * - Checksum validation: detects modified migration files (BLOCKING — use
89
+ * `allowDrift: true` to bypass when intentionally rewriting history)
89
90
  * - Each migration runs in its own transaction
91
+ *
92
+ * Throws `MigrationError` if any applied migration has been modified or deleted
93
+ * on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
94
+ * this check (the CLI exposes this as `--allow-drift`).
90
95
  */
91
96
  export declare function migrateUp(connectionString: string, migrationsDir: string, options?: {
92
97
  step?: number;
98
+ allowDrift?: boolean /** @deprecated use allowDrift */;
93
99
  force?: boolean;
94
100
  }): Promise<{
95
101
  applied: MigrationFile[];