turbine-orm 0.5.0 → 0.7.1
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 +292 -26
- package/dist/cjs/cli/config.js +5 -15
- package/dist/cjs/cli/index.js +311 -43
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +96 -47
- package/dist/cjs/cli/ui.js +5 -9
- package/dist/cjs/client.js +158 -49
- package/dist/cjs/errors.js +424 -0
- package/dist/cjs/generate.js +145 -14
- package/dist/cjs/index.js +43 -20
- package/dist/cjs/introspect.js +3 -5
- package/dist/cjs/pipeline.js +9 -2
- package/dist/cjs/query.js +544 -115
- package/dist/cjs/schema-builder.js +150 -30
- package/dist/cjs/schema-sql.js +241 -37
- package/dist/cjs/schema.js +5 -2
- package/dist/cjs/serverless.js +88 -176
- package/dist/cli/config.js +6 -16
- package/dist/cli/index.js +316 -48
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +13 -2
- package/dist/cli/migrate.js +97 -48
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +5 -9
- package/dist/client.d.ts +92 -4
- package/dist/client.js +158 -49
- package/dist/errors.d.ts +225 -0
- package/dist/errors.js +405 -0
- package/dist/generate.d.ts +7 -1
- package/dist/generate.js +148 -18
- package/dist/index.d.ts +11 -9
- package/dist/index.js +16 -12
- package/dist/introspect.d.ts +1 -1
- package/dist/introspect.js +4 -6
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +9 -2
- package/dist/query.d.ts +374 -38
- package/dist/query.js +545 -116
- package/dist/schema-builder.d.ts +38 -5
- package/dist/schema-builder.js +150 -31
- package/dist/schema-sql.d.ts +7 -3
- package/dist/schema-sql.js +241 -37
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +5 -2
- package/dist/serverless.d.ts +92 -139
- package/dist/serverless.js +87 -173
- package/package.json +33 -16
- package/dist/types.d.ts +0 -93
- package/dist/types.js +0 -126
package/dist/schema-sql.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* turbine-orm — Schema SQL Generator
|
|
3
3
|
*
|
|
4
4
|
* Converts a SchemaDef (from defineSchema) into executable DDL statements.
|
|
5
5
|
* Also provides diff and push commands for syncing schema to a live database.
|
|
6
6
|
*/
|
|
7
7
|
import pg from 'pg';
|
|
8
|
-
import { camelToSnake } from './schema.js';
|
|
9
8
|
import { quoteIdent } from './query.js';
|
|
9
|
+
import { camelToSnake } from './schema.js';
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
// SQL Generation — SchemaDef → CREATE TABLE statements
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
@@ -20,10 +20,11 @@ export function schemaToSQL(schema) {
|
|
|
20
20
|
const statements = [];
|
|
21
21
|
// Topologically sort tables by their foreign key references
|
|
22
22
|
const sorted = topologicalSort(schema);
|
|
23
|
+
const resolveRef = makeRefResolver(schema);
|
|
23
24
|
// Generate CREATE TABLE statements
|
|
24
25
|
for (const tableName of sorted) {
|
|
25
26
|
const table = schema.tables[tableName];
|
|
26
|
-
statements.push(generateCreateTable(table));
|
|
27
|
+
statements.push(generateCreateTable(table, resolveRef));
|
|
27
28
|
}
|
|
28
29
|
// Generate CREATE INDEX for foreign key columns
|
|
29
30
|
for (const tableName of sorted) {
|
|
@@ -33,12 +34,51 @@ export function schemaToSQL(schema) {
|
|
|
33
34
|
}
|
|
34
35
|
return statements;
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Build a function that resolves a raw `references: 'foo.id'` target name
|
|
39
|
+
* to the snake_case DDL table name. Accepts both the JS-facing camelCase
|
|
40
|
+
* accessor name and the snake_case DDL name; passes through unknown names
|
|
41
|
+
* unchanged so existing call sites continue to work.
|
|
42
|
+
*/
|
|
43
|
+
function makeRefResolver(schema) {
|
|
44
|
+
const lookup = buildTableLookup(schema);
|
|
45
|
+
return (rawName) => {
|
|
46
|
+
const key = lookup[rawName];
|
|
47
|
+
if (key) {
|
|
48
|
+
const def = schema.tables[key];
|
|
49
|
+
if (def?.name)
|
|
50
|
+
return def.name;
|
|
51
|
+
}
|
|
52
|
+
return rawName;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build a lookup index from both DDL names (snake_case) and JS accessor
|
|
57
|
+
* names (camelCase) to table keys, so `references: 'post_tags.id'` and
|
|
58
|
+
* `references: 'postTags.id'` both resolve to the same TableDef.
|
|
59
|
+
*/
|
|
60
|
+
function buildTableLookup(schema) {
|
|
61
|
+
const lookup = {};
|
|
62
|
+
for (const [key, def] of Object.entries(schema.tables)) {
|
|
63
|
+
lookup[key] = key;
|
|
64
|
+
if (def.name && def.name !== key)
|
|
65
|
+
lookup[def.name] = key;
|
|
66
|
+
if (def.accessor && def.accessor !== key)
|
|
67
|
+
lookup[def.accessor] = key;
|
|
68
|
+
}
|
|
69
|
+
return lookup;
|
|
70
|
+
}
|
|
36
71
|
/**
|
|
37
72
|
* Topologically sort tables so that referenced tables come before referencing ones.
|
|
73
|
+
* Returns the table keys (the same keys used in `schema.tables`). The keys are
|
|
74
|
+
* the JS-facing accessor names; consumers should still call `table.name` to get
|
|
75
|
+
* the snake_case DDL name when emitting SQL.
|
|
76
|
+
*
|
|
38
77
|
* Falls back to input order for tables with no dependency ordering.
|
|
39
78
|
*/
|
|
40
79
|
function topologicalSort(schema) {
|
|
41
80
|
const tableNames = Object.keys(schema.tables);
|
|
81
|
+
const lookup = buildTableLookup(schema);
|
|
42
82
|
const resolved = new Set();
|
|
43
83
|
const result = [];
|
|
44
84
|
const visiting = new Set();
|
|
@@ -55,9 +95,10 @@ function topologicalSort(schema) {
|
|
|
55
95
|
// Visit all tables this table references
|
|
56
96
|
for (const col of Object.values(table.columns)) {
|
|
57
97
|
if (col.referencesTarget) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
98
|
+
const refRaw = col.referencesTarget.split('.')[0];
|
|
99
|
+
const refKey = lookup[refRaw];
|
|
100
|
+
if (refKey && refKey !== name) {
|
|
101
|
+
visit(refKey);
|
|
61
102
|
}
|
|
62
103
|
}
|
|
63
104
|
}
|
|
@@ -73,12 +114,28 @@ function topologicalSort(schema) {
|
|
|
73
114
|
}
|
|
74
115
|
/**
|
|
75
116
|
* Generate a CREATE TABLE statement for a single table definition.
|
|
117
|
+
*
|
|
118
|
+
* If `table.primaryKey` is set (composite primary key), emits a table-level
|
|
119
|
+
* `PRIMARY KEY ("col1", "col2", ...)` constraint instead of column-level
|
|
120
|
+
* `PRIMARY KEY` clauses on each member column. The composite PK column
|
|
121
|
+
* names are camelCase JS field names; they are converted to snake_case
|
|
122
|
+
* here.
|
|
123
|
+
*
|
|
124
|
+
* `resolveRef` (when supplied) maps raw `references: 'foo.id'` table names
|
|
125
|
+
* to their snake_case DDL form, so users can write either camelCase JS
|
|
126
|
+
* accessor names or snake_case DDL names.
|
|
76
127
|
*/
|
|
77
|
-
function generateCreateTable(table) {
|
|
128
|
+
function generateCreateTable(table, resolveRef) {
|
|
78
129
|
const tableName = table.name;
|
|
79
130
|
const columnDefs = [];
|
|
131
|
+
const compositePk = table.primaryKey && table.primaryKey.length > 0 ? table.primaryKey : null;
|
|
80
132
|
for (const [fieldName, config] of Object.entries(table.columns)) {
|
|
81
|
-
columnDefs.push(generateColumnDef(fieldName, config));
|
|
133
|
+
columnDefs.push(generateColumnDef(fieldName, config, resolveRef));
|
|
134
|
+
}
|
|
135
|
+
// Append a table-level PRIMARY KEY constraint when a composite PK is set.
|
|
136
|
+
if (compositePk) {
|
|
137
|
+
const cols = compositePk.map((c) => quoteIdent(camelToSnake(c))).join(', ');
|
|
138
|
+
columnDefs.push(`PRIMARY KEY (${cols})`);
|
|
82
139
|
}
|
|
83
140
|
const body = columnDefs.map((d) => ` ${d}`).join(',\n');
|
|
84
141
|
return `CREATE TABLE ${quoteIdent(tableName)} (\n${body}\n);`;
|
|
@@ -86,7 +143,7 @@ function generateCreateTable(table) {
|
|
|
86
143
|
/**
|
|
87
144
|
* Generate a single column definition line (e.g. "id BIGSERIAL PRIMARY KEY").
|
|
88
145
|
*/
|
|
89
|
-
function generateColumnDef(fieldName, config) {
|
|
146
|
+
function generateColumnDef(fieldName, config, resolveRef) {
|
|
90
147
|
const snakeName = camelToSnake(fieldName);
|
|
91
148
|
const parts = [quoteIdent(snakeName)];
|
|
92
149
|
// Type
|
|
@@ -120,11 +177,14 @@ function generateColumnDef(fieldName, config) {
|
|
|
120
177
|
const sqlDefault = normalizeDefault(config.defaultValue);
|
|
121
178
|
parts.push(`DEFAULT ${sqlDefault}`);
|
|
122
179
|
}
|
|
123
|
-
// REFERENCES
|
|
180
|
+
// REFERENCES — resolve the raw table name through the optional resolver so
|
|
181
|
+
// both camelCase accessor names and snake_case DDL names work.
|
|
124
182
|
if (config.referencesTarget) {
|
|
125
183
|
const refParts = config.referencesTarget.split('.');
|
|
126
184
|
if (refParts.length === 2) {
|
|
127
|
-
|
|
185
|
+
const rawTable = refParts[0];
|
|
186
|
+
const refTable = resolveRef ? resolveRef(rawTable) : rawTable;
|
|
187
|
+
parts.push(`REFERENCES ${quoteIdent(refTable)}(${quoteIdent(refParts[1])})`);
|
|
128
188
|
}
|
|
129
189
|
}
|
|
130
190
|
return parts.join(' ');
|
|
@@ -145,9 +205,7 @@ function normalizeDefault(val) {
|
|
|
145
205
|
return upper;
|
|
146
206
|
}
|
|
147
207
|
// Known SQL function calls: NOW(), CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, GEN_RANDOM_UUID()
|
|
148
|
-
const allowedFunctions = [
|
|
149
|
-
'NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'GEN_RANDOM_UUID()',
|
|
150
|
-
];
|
|
208
|
+
const allowedFunctions = ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'GEN_RANDOM_UUID()'];
|
|
151
209
|
if (allowedFunctions.includes(upper)) {
|
|
152
210
|
return upper;
|
|
153
211
|
}
|
|
@@ -155,8 +213,12 @@ function normalizeDefault(val) {
|
|
|
155
213
|
if (/^-?\d+(\.\d+)?$/.test(val.trim())) {
|
|
156
214
|
return val.trim();
|
|
157
215
|
}
|
|
158
|
-
// Simple single-quoted string literals (no
|
|
216
|
+
// Simple single-quoted string literals (no semicolons, no SQL statement keywords)
|
|
159
217
|
if (/^'[^']*'$/.test(val.trim())) {
|
|
218
|
+
const inner = val.trim().slice(1, -1);
|
|
219
|
+
if (/[;]/.test(inner) || /\b(DROP|ALTER|CREATE|INSERT|UPDATE|DELETE|GRANT|REVOKE|TRUNCATE)\b/i.test(inner)) {
|
|
220
|
+
throw new Error(`Suspicious default value: ${val}. String literals must not contain SQL statements.`);
|
|
221
|
+
}
|
|
160
222
|
return val.trim();
|
|
161
223
|
}
|
|
162
224
|
throw new Error(`Unsupported default value: ${val}. Use a SQL function, numeric, string literal, or NULL.`);
|
|
@@ -207,66 +269,158 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
207
269
|
maxLength: row.character_maximum_length,
|
|
208
270
|
};
|
|
209
271
|
}
|
|
210
|
-
|
|
211
|
-
const
|
|
272
|
+
// Get single-column UNIQUE constraints (excluding PKs)
|
|
273
|
+
const uniqueResult = await client.query(`SELECT tc.table_name, tc.constraint_name, kcu.column_name
|
|
274
|
+
FROM information_schema.table_constraints tc
|
|
275
|
+
JOIN information_schema.key_column_usage kcu
|
|
276
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
277
|
+
AND tc.table_schema = kcu.table_schema
|
|
278
|
+
WHERE tc.table_schema = 'public'
|
|
279
|
+
AND tc.constraint_type = 'UNIQUE'
|
|
280
|
+
AND tc.constraint_name IN (
|
|
281
|
+
SELECT constraint_name
|
|
282
|
+
FROM information_schema.key_column_usage
|
|
283
|
+
WHERE table_schema = 'public'
|
|
284
|
+
GROUP BY constraint_name
|
|
285
|
+
HAVING COUNT(*) = 1
|
|
286
|
+
)`);
|
|
287
|
+
// Map: table → column → constraint_name for single-col uniques
|
|
288
|
+
const dbUniques = {};
|
|
289
|
+
for (const row of uniqueResult.rows) {
|
|
290
|
+
if (!dbUniques[row.table_name])
|
|
291
|
+
dbUniques[row.table_name] = {};
|
|
292
|
+
dbUniques[row.table_name][row.column_name] = row.constraint_name;
|
|
293
|
+
}
|
|
294
|
+
// Build a set of DDL-facing snake_case table names that the schema defines.
|
|
295
|
+
const schemaDdlNames = new Set();
|
|
296
|
+
for (const def of Object.values(schema.tables))
|
|
297
|
+
schemaDdlNames.add(def.name);
|
|
298
|
+
const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [] };
|
|
299
|
+
const resolveRef = makeRefResolver(schema);
|
|
212
300
|
// Tables to create (in schema but not in DB)
|
|
213
301
|
const sorted = topologicalSort(schema);
|
|
214
|
-
for (const
|
|
215
|
-
|
|
216
|
-
|
|
302
|
+
for (const tableKey of sorted) {
|
|
303
|
+
const tableDef = schema.tables[tableKey];
|
|
304
|
+
const ddlName = tableDef.name;
|
|
305
|
+
if (!existingTables.has(ddlName)) {
|
|
217
306
|
result.create.push(tableDef);
|
|
218
|
-
result.statements.push(generateCreateTable(tableDef));
|
|
219
|
-
|
|
220
|
-
result.statements.push(...
|
|
307
|
+
result.statements.push(generateCreateTable(tableDef, resolveRef));
|
|
308
|
+
const fkIndexes = generateForeignKeyIndexes(tableDef);
|
|
309
|
+
result.statements.push(...fkIndexes);
|
|
310
|
+
// Reverse: DROP TABLE (with indexes — they drop automatically)
|
|
311
|
+
result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${quoteIdent(ddlName)} CASCADE;`);
|
|
221
312
|
}
|
|
222
313
|
}
|
|
223
314
|
// Tables to drop (in DB but not in schema)
|
|
224
315
|
for (const existingTable of existingTables) {
|
|
225
|
-
if (!
|
|
316
|
+
if (!schemaDdlNames.has(existingTable)) {
|
|
226
317
|
result.drop.push(existingTable);
|
|
227
318
|
// We don't auto-generate DROP statements for safety
|
|
228
319
|
}
|
|
229
320
|
}
|
|
230
321
|
// Tables to alter (exist in both)
|
|
231
|
-
for (const
|
|
322
|
+
for (const tableKey of sorted) {
|
|
323
|
+
const tableDef = schema.tables[tableKey];
|
|
324
|
+
const tableName = tableDef.name;
|
|
232
325
|
if (!existingTables.has(tableName))
|
|
233
326
|
continue;
|
|
234
|
-
const tableDef = schema.tables[tableName];
|
|
235
327
|
const dbCols = dbColumns[tableName] ?? {};
|
|
328
|
+
const tableUniques = dbUniques[tableName] ?? {};
|
|
236
329
|
const alterDef = { table: tableName, columns: [] };
|
|
237
330
|
for (const [fieldName, config] of Object.entries(tableDef.columns)) {
|
|
238
331
|
const snakeName = camelToSnake(fieldName);
|
|
239
332
|
const dbCol = dbCols[snakeName];
|
|
240
333
|
if (!dbCol) {
|
|
241
334
|
// Column exists in schema but not in DB — ADD COLUMN
|
|
242
|
-
const colDef = generateColumnDef(fieldName, config);
|
|
335
|
+
const colDef = generateColumnDef(fieldName, config, resolveRef);
|
|
243
336
|
const sql = `ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${colDef};`;
|
|
244
|
-
|
|
337
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} DROP COLUMN ${quoteIdent(snakeName)};`;
|
|
338
|
+
alterDef.columns.push({ column: snakeName, action: 'add', sql, reverseSql });
|
|
245
339
|
result.statements.push(sql);
|
|
340
|
+
result.reverseStatements.unshift(reverseSql);
|
|
246
341
|
continue;
|
|
247
342
|
}
|
|
248
343
|
// Check type mismatch
|
|
249
344
|
const expectedUdt = schemaTypeToUdt(config);
|
|
250
345
|
if (expectedUdt && dbCol.udtName !== expectedUdt) {
|
|
251
|
-
const sqlType = config.type === 'VARCHAR' && config.maxLength
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
alterDef.columns.push({ column: snakeName, action: 'alter_type', sql });
|
|
346
|
+
const sqlType = config.type === 'VARCHAR' && config.maxLength ? `VARCHAR(${config.maxLength})` : config.type;
|
|
347
|
+
const oldSqlType = udtToSqlType(dbCol.udtName, dbCol.maxLength);
|
|
348
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} TYPE ${sqlType} USING ${quoteIdent(snakeName)}::${sqlType};`;
|
|
349
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} TYPE ${oldSqlType} USING ${quoteIdent(snakeName)}::${oldSqlType};`;
|
|
350
|
+
alterDef.columns.push({ column: snakeName, action: 'alter_type', sql, reverseSql });
|
|
256
351
|
result.statements.push(sql);
|
|
352
|
+
result.reverseStatements.unshift(reverseSql);
|
|
257
353
|
}
|
|
258
354
|
// Check NOT NULL mismatch
|
|
259
355
|
const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || config.type === 'BIGSERIAL';
|
|
260
356
|
const isCurrentlyNullable = dbCol.isNullable;
|
|
261
357
|
if (shouldBeNotNull && isCurrentlyNullable && !config.isNullable) {
|
|
262
358
|
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET NOT NULL;`;
|
|
263
|
-
|
|
359
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} DROP NOT NULL;`;
|
|
360
|
+
alterDef.columns.push({ column: snakeName, action: 'set_not_null', sql, reverseSql });
|
|
264
361
|
result.statements.push(sql);
|
|
362
|
+
result.reverseStatements.unshift(reverseSql);
|
|
265
363
|
}
|
|
266
364
|
else if (!shouldBeNotNull && !isCurrentlyNullable && config.isNullable) {
|
|
267
365
|
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} DROP NOT NULL;`;
|
|
268
|
-
|
|
366
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET NOT NULL;`;
|
|
367
|
+
alterDef.columns.push({ column: snakeName, action: 'drop_not_null', sql, reverseSql });
|
|
269
368
|
result.statements.push(sql);
|
|
369
|
+
result.reverseStatements.unshift(reverseSql);
|
|
370
|
+
}
|
|
371
|
+
// Check DEFAULT value mismatch
|
|
372
|
+
const isSerial = config.type === 'BIGSERIAL';
|
|
373
|
+
if (!isSerial) {
|
|
374
|
+
const schemaDefault = config.defaultValue ? normalizeDefault(config.defaultValue) : null;
|
|
375
|
+
const dbDefault = dbCol.columnDefault;
|
|
376
|
+
if (schemaDefault && !dbDefault) {
|
|
377
|
+
// Schema has default, DB doesn't
|
|
378
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET DEFAULT ${schemaDefault};`;
|
|
379
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} DROP DEFAULT;`;
|
|
380
|
+
alterDef.columns.push({ column: snakeName, action: 'set_default', sql, reverseSql });
|
|
381
|
+
result.statements.push(sql);
|
|
382
|
+
result.reverseStatements.unshift(reverseSql);
|
|
383
|
+
}
|
|
384
|
+
else if (!schemaDefault && dbDefault && !isSequenceDefault(dbDefault)) {
|
|
385
|
+
// DB has a non-sequence default, schema doesn't
|
|
386
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} DROP DEFAULT;`;
|
|
387
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET DEFAULT ${dbDefault};`;
|
|
388
|
+
alterDef.columns.push({ column: snakeName, action: 'drop_default', sql, reverseSql });
|
|
389
|
+
result.statements.push(sql);
|
|
390
|
+
result.reverseStatements.unshift(reverseSql);
|
|
391
|
+
}
|
|
392
|
+
else if (schemaDefault &&
|
|
393
|
+
dbDefault &&
|
|
394
|
+
!isSequenceDefault(dbDefault) &&
|
|
395
|
+
!defaultsMatch(schemaDefault, dbDefault)) {
|
|
396
|
+
// Both have defaults but they differ
|
|
397
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET DEFAULT ${schemaDefault};`;
|
|
398
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET DEFAULT ${dbDefault};`;
|
|
399
|
+
alterDef.columns.push({ column: snakeName, action: 'set_default', sql, reverseSql });
|
|
400
|
+
result.statements.push(sql);
|
|
401
|
+
result.reverseStatements.unshift(reverseSql);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Check UNIQUE constraint mismatch (skip PKs — they're implicitly unique)
|
|
405
|
+
if (!config.isPrimaryKey) {
|
|
406
|
+
const hasDbUnique = snakeName in tableUniques;
|
|
407
|
+
const wantsUnique = config.isUnique === true;
|
|
408
|
+
if (wantsUnique && !hasDbUnique) {
|
|
409
|
+
const constraintName = `${tableName}_${snakeName}_key`;
|
|
410
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ADD CONSTRAINT ${quoteIdent(constraintName)} UNIQUE (${quoteIdent(snakeName)});`;
|
|
411
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} DROP CONSTRAINT ${quoteIdent(constraintName)};`;
|
|
412
|
+
alterDef.columns.push({ column: snakeName, action: 'add_unique', sql, reverseSql });
|
|
413
|
+
result.statements.push(sql);
|
|
414
|
+
result.reverseStatements.unshift(reverseSql);
|
|
415
|
+
}
|
|
416
|
+
else if (!wantsUnique && hasDbUnique) {
|
|
417
|
+
const constraintName = tableUniques[snakeName];
|
|
418
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} DROP CONSTRAINT ${quoteIdent(constraintName)};`;
|
|
419
|
+
const reverseSql = `ALTER TABLE ${quoteIdent(tableName)} ADD CONSTRAINT ${quoteIdent(constraintName)} UNIQUE (${quoteIdent(snakeName)});`;
|
|
420
|
+
alterDef.columns.push({ column: snakeName, action: 'drop_unique', sql, reverseSql });
|
|
421
|
+
result.statements.push(sql);
|
|
422
|
+
result.reverseStatements.unshift(reverseSql);
|
|
423
|
+
}
|
|
270
424
|
}
|
|
271
425
|
}
|
|
272
426
|
// Check for columns in DB that are not in schema
|
|
@@ -274,7 +428,8 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
274
428
|
const hasField = Object.entries(tableDef.columns).some(([fieldName]) => camelToSnake(fieldName) === dbColName);
|
|
275
429
|
if (!hasField) {
|
|
276
430
|
const sql = `ALTER TABLE ${quoteIdent(tableName)} DROP COLUMN ${quoteIdent(dbColName)};`;
|
|
277
|
-
|
|
431
|
+
const reverseSql = `-- Cannot auto-reverse DROP COLUMN for "${dbColName}" — add it back manually`;
|
|
432
|
+
alterDef.columns.push({ column: dbColName, action: 'drop', sql, reverseSql });
|
|
278
433
|
// Don't auto-add drops to statements for safety — user must opt in
|
|
279
434
|
}
|
|
280
435
|
}
|
|
@@ -311,6 +466,55 @@ function schemaTypeToUdt(config) {
|
|
|
311
466
|
};
|
|
312
467
|
return map[config.type] ?? null;
|
|
313
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Reverse map: PostgreSQL UDT name → SQL type (for generating reverse ALTER TYPE).
|
|
471
|
+
*/
|
|
472
|
+
function udtToSqlType(udtName, maxLength) {
|
|
473
|
+
const map = {
|
|
474
|
+
int8: 'BIGINT',
|
|
475
|
+
int4: 'INTEGER',
|
|
476
|
+
int2: 'SMALLINT',
|
|
477
|
+
text: 'TEXT',
|
|
478
|
+
varchar: maxLength ? `VARCHAR(${maxLength})` : 'VARCHAR',
|
|
479
|
+
bool: 'BOOLEAN',
|
|
480
|
+
timestamptz: 'TIMESTAMPTZ',
|
|
481
|
+
date: 'DATE',
|
|
482
|
+
jsonb: 'JSONB',
|
|
483
|
+
uuid: 'UUID',
|
|
484
|
+
float4: 'REAL',
|
|
485
|
+
float8: 'DOUBLE PRECISION',
|
|
486
|
+
numeric: 'NUMERIC',
|
|
487
|
+
bytea: 'BYTEA',
|
|
488
|
+
};
|
|
489
|
+
return map[udtName] ?? udtName.toUpperCase();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Normalize a database default value for comparison.
|
|
493
|
+
* Strips PostgreSQL type casts (e.g. 'free'::text → 'free') and wrapping parens.
|
|
494
|
+
*/
|
|
495
|
+
function normalizeDbDefault(dbDefault) {
|
|
496
|
+
let val = dbDefault;
|
|
497
|
+
// Strip type casts: 'free'::text → 'free', 0::integer → 0
|
|
498
|
+
val = val.replace(/::[\w\s"]+(\[\])?$/g, '').trim();
|
|
499
|
+
// Unwrap parens added by PostgreSQL: ('free') → 'free'
|
|
500
|
+
while (val.startsWith('(') && val.endsWith(')')) {
|
|
501
|
+
val = val.slice(1, -1).trim();
|
|
502
|
+
}
|
|
503
|
+
return val;
|
|
504
|
+
}
|
|
505
|
+
/** Check if a DB default is a sequence default (auto-generated for serial columns). */
|
|
506
|
+
function isSequenceDefault(dbDefault) {
|
|
507
|
+
return dbDefault.includes('nextval(');
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Compare a schema default against a database default, accounting for
|
|
511
|
+
* PostgreSQL's normalization of default values.
|
|
512
|
+
*/
|
|
513
|
+
function defaultsMatch(schemaDefault, dbDefault) {
|
|
514
|
+
const a = schemaDefault.toLowerCase().trim();
|
|
515
|
+
const b = normalizeDbDefault(dbDefault).toLowerCase().trim();
|
|
516
|
+
return a === b;
|
|
517
|
+
}
|
|
314
518
|
/**
|
|
315
519
|
* Push a schema definition to a live database.
|
|
316
520
|
*
|
|
@@ -358,6 +562,6 @@ export async function schemaPush(schema, connectionString, options = {}) {
|
|
|
358
562
|
*/
|
|
359
563
|
export function schemaToSQLString(schema) {
|
|
360
564
|
const statements = schemaToSQL(schema);
|
|
361
|
-
return statements.join('\n\n')
|
|
565
|
+
return `${statements.join('\n\n')}\n`;
|
|
362
566
|
}
|
|
363
567
|
//# sourceMappingURL=schema-sql.js.map
|
package/dist/schema.d.ts
CHANGED
package/dist/schema.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* turbine-orm — Schema metadata types
|
|
3
3
|
*
|
|
4
4
|
* These types represent the introspected database schema at runtime.
|
|
5
5
|
* They're used by the query builder, code generator, and CLI.
|
|
@@ -11,6 +11,9 @@ const PG_TO_TS = {
|
|
|
11
11
|
// Integers
|
|
12
12
|
int2: 'number',
|
|
13
13
|
int4: 'number',
|
|
14
|
+
// int8 maps to `number` for DX (auto-increment IDs, counts, etc.).
|
|
15
|
+
// Values exceeding Number.MAX_SAFE_INTEGER (2^53 - 1) are returned as
|
|
16
|
+
// `string` at runtime to avoid precision loss. See client.ts int8 parser.
|
|
14
17
|
int8: 'number',
|
|
15
18
|
float4: 'number',
|
|
16
19
|
float8: 'number',
|
|
@@ -119,7 +122,7 @@ export function snakeToPascal(s) {
|
|
|
119
122
|
/** Naive singularize: "posts" → "post", "categories" → "category" */
|
|
120
123
|
export function singularize(s) {
|
|
121
124
|
if (s.endsWith('ies'))
|
|
122
|
-
return s.slice(0, -3)
|
|
125
|
+
return `${s.slice(0, -3)}y`;
|
|
123
126
|
if (s.endsWith('ses') || s.endsWith('xes') || s.endsWith('zes'))
|
|
124
127
|
return s.slice(0, -2);
|
|
125
128
|
if (s.endsWith('s') && !s.endsWith('ss'))
|