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.
- package/README.md +134 -40
- package/dist/cjs/cli/index.js +72 -3
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +33 -9
- package/dist/cjs/client.js +92 -8
- package/dist/cjs/errors.js +177 -4
- package/dist/cjs/generate.js +120 -9
- package/dist/cjs/index.js +7 -1
- package/dist/cjs/pipeline-submittable.js +403 -0
- package/dist/cjs/pipeline.js +90 -37
- package/dist/cjs/query.js +943 -137
- package/dist/cjs/schema-builder.js +57 -6
- package/dist/cjs/schema-sql.js +85 -19
- package/dist/cjs/serverless.js +8 -7
- package/dist/cli/index.js +72 -3
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +7 -1
- package/dist/cli/migrate.js +33 -9
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +47 -3
- package/dist/client.js +94 -10
- package/dist/errors.d.ts +132 -1
- package/dist/errors.js +171 -3
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +120 -10
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/pipeline-submittable.d.ts +94 -0
- package/dist/pipeline-submittable.js +397 -0
- package/dist/pipeline.d.ts +37 -9
- package/dist/pipeline.js +89 -37
- package/dist/query.d.ts +268 -17
- package/dist/query.js +941 -137
- package/dist/schema-builder.d.ts +36 -3
- package/dist/schema-builder.js +57 -6
- package/dist/schema-sql.js +85 -19
- package/dist/serverless.d.ts +8 -7
- package/dist/serverless.js +8 -7
- package/package.json +3 -3
|
@@ -86,23 +86,74 @@ function isTableDef(v) {
|
|
|
86
86
|
*/
|
|
87
87
|
function defineSchema(input) {
|
|
88
88
|
const tables = {};
|
|
89
|
-
for (const [
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/cjs/schema-sql.js
CHANGED
|
@@ -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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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)(
|
|
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 (!
|
|
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
|
|
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 });
|
package/dist/cjs/serverless.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 {
|
|
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
|
-
* },
|
|
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,
|
|
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 {
|
|
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,
|
|
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')}
|
|
490
|
-
console.log(` ${cyan('--step, -n')}
|
|
491
|
-
console.log(` ${cyan('--dry-run')}
|
|
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
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -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[];
|