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.
Files changed (50) hide show
  1. package/README.md +292 -26
  2. package/dist/cjs/cli/config.js +5 -15
  3. package/dist/cjs/cli/index.js +311 -43
  4. package/dist/cjs/cli/loader.js +129 -0
  5. package/dist/cjs/cli/migrate.js +96 -47
  6. package/dist/cjs/cli/ui.js +5 -9
  7. package/dist/cjs/client.js +158 -49
  8. package/dist/cjs/errors.js +424 -0
  9. package/dist/cjs/generate.js +145 -14
  10. package/dist/cjs/index.js +43 -20
  11. package/dist/cjs/introspect.js +3 -5
  12. package/dist/cjs/pipeline.js +9 -2
  13. package/dist/cjs/query.js +544 -115
  14. package/dist/cjs/schema-builder.js +150 -30
  15. package/dist/cjs/schema-sql.js +241 -37
  16. package/dist/cjs/schema.js +5 -2
  17. package/dist/cjs/serverless.js +88 -176
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +316 -48
  20. package/dist/cli/loader.d.ts +45 -0
  21. package/dist/cli/loader.js +91 -0
  22. package/dist/cli/migrate.d.ts +13 -2
  23. package/dist/cli/migrate.js +97 -48
  24. package/dist/cli/ui.d.ts +1 -1
  25. package/dist/cli/ui.js +5 -9
  26. package/dist/client.d.ts +92 -4
  27. package/dist/client.js +158 -49
  28. package/dist/errors.d.ts +225 -0
  29. package/dist/errors.js +405 -0
  30. package/dist/generate.d.ts +7 -1
  31. package/dist/generate.js +148 -18
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +16 -12
  34. package/dist/introspect.d.ts +1 -1
  35. package/dist/introspect.js +4 -6
  36. package/dist/pipeline.d.ts +1 -1
  37. package/dist/pipeline.js +9 -2
  38. package/dist/query.d.ts +374 -38
  39. package/dist/query.js +545 -116
  40. package/dist/schema-builder.d.ts +38 -5
  41. package/dist/schema-builder.js +150 -31
  42. package/dist/schema-sql.d.ts +7 -3
  43. package/dist/schema-sql.js +241 -37
  44. package/dist/schema.d.ts +1 -1
  45. package/dist/schema.js +5 -2
  46. package/dist/serverless.d.ts +92 -139
  47. package/dist/serverless.js +87 -173
  48. package/package.json +33 -16
  49. package/dist/types.d.ts +0 -93
  50. package/dist/types.js +0 -126
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * @batadata/turbine — Schema Builder
3
+ * turbine-orm — Schema Builder
4
4
  *
5
5
  * TypeScript-first schema definition API. Define your database schema
6
6
  * as plain objects — no method chaining, no DSL. Fully type-checked,
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @example
10
10
  * ```ts
11
- * import { defineSchema } from '@batadata/turbine';
11
+ * import { defineSchema } from 'turbine-orm';
12
12
  *
13
13
  * export default defineSchema({
14
14
  * users: {
@@ -47,6 +47,9 @@ const TYPE_MAP = {
47
47
  };
48
48
  /** Convert a user-facing ColumnDef to the internal ColumnConfig */
49
49
  function resolveColumn(def) {
50
+ if (!(def.type in TYPE_MAP)) {
51
+ throw new Error(`Invalid column type "${def.type}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`);
52
+ }
50
53
  return {
51
54
  type: TYPE_MAP[def.type],
52
55
  isPrimaryKey: def.primaryKey ?? false,
@@ -83,23 +86,74 @@ function isTableDef(v) {
83
86
  */
84
87
  function defineSchema(input) {
85
88
  const tables = {};
86
- 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);
87
93
  if (isTableDef(value)) {
88
94
  // Legacy format: defineSchema({ users: table({ ... }) })
89
- value.name = tableName;
90
- 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;
91
99
  }
92
100
  else {
93
101
  // Object format: defineSchema({ users: { id: { type: 'serial' }, ... } })
102
+ const raw = value;
94
103
  const columns = {};
95
- 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
96
117
  columns[fieldName] = resolveColumn(def);
97
118
  }
98
- 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
+ };
99
146
  }
100
147
  }
101
148
  return { tables };
102
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
+ }
103
157
  // ---------------------------------------------------------------------------
104
158
  // Legacy compat — ColumnBuilder still works for existing code
105
159
  // ---------------------------------------------------------------------------
@@ -117,28 +171,94 @@ class ColumnBuilder {
117
171
  maxLength: null,
118
172
  };
119
173
  }
120
- serial() { this._config.type = 'BIGSERIAL'; return this; }
121
- bigint() { this._config.type = 'BIGINT'; return this; }
122
- integer() { this._config.type = 'INTEGER'; return this; }
123
- smallint() { this._config.type = 'SMALLINT'; return this; }
124
- text() { this._config.type = 'TEXT'; return this; }
125
- varchar(length) { this._config.type = 'VARCHAR'; this._config.maxLength = length; return this; }
126
- boolean() { this._config.type = 'BOOLEAN'; return this; }
127
- timestamp() { this._config.type = 'TIMESTAMPTZ'; return this; }
128
- date() { this._config.type = 'DATE'; return this; }
129
- json() { this._config.type = 'JSONB'; return this; }
130
- uuid() { this._config.type = 'UUID'; return this; }
131
- real() { this._config.type = 'REAL'; return this; }
132
- doublePrecision() { this._config.type = 'DOUBLE PRECISION'; return this; }
133
- numeric() { this._config.type = 'NUMERIC'; return this; }
134
- bytea() { this._config.type = 'BYTEA'; return this; }
135
- primaryKey() { this._config.isPrimaryKey = true; return this; }
136
- notNull() { this._config.isNotNull = true; return this; }
137
- nullable() { this._config.isNullable = true; return this; }
138
- unique() { this._config.isUnique = true; return this; }
139
- default(val) { this._config.defaultValue = val; return this; }
140
- references(target) { this._config.referencesTarget = target; return this; }
141
- build() { return { ...this._config }; }
174
+ serial() {
175
+ this._config.type = 'BIGSERIAL';
176
+ return this;
177
+ }
178
+ bigint() {
179
+ this._config.type = 'BIGINT';
180
+ return this;
181
+ }
182
+ integer() {
183
+ this._config.type = 'INTEGER';
184
+ return this;
185
+ }
186
+ smallint() {
187
+ this._config.type = 'SMALLINT';
188
+ return this;
189
+ }
190
+ text() {
191
+ this._config.type = 'TEXT';
192
+ return this;
193
+ }
194
+ varchar(length) {
195
+ this._config.type = 'VARCHAR';
196
+ this._config.maxLength = length;
197
+ return this;
198
+ }
199
+ boolean() {
200
+ this._config.type = 'BOOLEAN';
201
+ return this;
202
+ }
203
+ timestamp() {
204
+ this._config.type = 'TIMESTAMPTZ';
205
+ return this;
206
+ }
207
+ date() {
208
+ this._config.type = 'DATE';
209
+ return this;
210
+ }
211
+ json() {
212
+ this._config.type = 'JSONB';
213
+ return this;
214
+ }
215
+ uuid() {
216
+ this._config.type = 'UUID';
217
+ return this;
218
+ }
219
+ real() {
220
+ this._config.type = 'REAL';
221
+ return this;
222
+ }
223
+ doublePrecision() {
224
+ this._config.type = 'DOUBLE PRECISION';
225
+ return this;
226
+ }
227
+ numeric() {
228
+ this._config.type = 'NUMERIC';
229
+ return this;
230
+ }
231
+ bytea() {
232
+ this._config.type = 'BYTEA';
233
+ return this;
234
+ }
235
+ primaryKey() {
236
+ this._config.isPrimaryKey = true;
237
+ return this;
238
+ }
239
+ notNull() {
240
+ this._config.isNotNull = true;
241
+ return this;
242
+ }
243
+ nullable() {
244
+ this._config.isNullable = true;
245
+ return this;
246
+ }
247
+ unique() {
248
+ this._config.isUnique = true;
249
+ return this;
250
+ }
251
+ default(val) {
252
+ this._config.defaultValue = val;
253
+ return this;
254
+ }
255
+ references(target) {
256
+ this._config.referencesTarget = target;
257
+ return this;
258
+ }
259
+ build() {
260
+ return { ...this._config };
261
+ }
142
262
  }
143
263
  exports.ColumnBuilder = ColumnBuilder;
144
264
  /** @deprecated Use defineSchema() with plain objects instead */
@@ -160,7 +280,7 @@ function table(columns) {
160
280
  for (const [fieldName, builder] of Object.entries(columns)) {
161
281
  built[fieldName] = builder.build();
162
282
  }
163
- return { name: '', columns: built };
283
+ return { name: '', accessor: '', columns: built };
164
284
  }
165
285
  // ---------------------------------------------------------------------------
166
286
  // Helpers
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * @batadata/turbine — Schema SQL Generator
3
+ * turbine-orm — Schema SQL Generator
4
4
  *
5
5
  * Converts a SchemaDef (from defineSchema) into executable DDL statements.
6
6
  * Also provides diff and push commands for syncing schema to a live database.
@@ -14,8 +14,8 @@ exports.schemaDiff = schemaDiff;
14
14
  exports.schemaPush = schemaPush;
15
15
  exports.schemaToSQLString = schemaToSQLString;
16
16
  const pg_1 = __importDefault(require("pg"));
17
- const schema_js_1 = require("./schema.js");
18
17
  const query_js_1 = require("./query.js");
18
+ const schema_js_1 = require("./schema.js");
19
19
  // ---------------------------------------------------------------------------
20
20
  // SQL Generation — SchemaDef → CREATE TABLE statements
21
21
  // ---------------------------------------------------------------------------
@@ -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(' ');
@@ -154,9 +214,7 @@ function normalizeDefault(val) {
154
214
  return upper;
155
215
  }
156
216
  // Known SQL function calls: NOW(), CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, GEN_RANDOM_UUID()
157
- const allowedFunctions = [
158
- 'NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'GEN_RANDOM_UUID()',
159
- ];
217
+ const allowedFunctions = ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'GEN_RANDOM_UUID()'];
160
218
  if (allowedFunctions.includes(upper)) {
161
219
  return upper;
162
220
  }
@@ -164,8 +222,12 @@ function normalizeDefault(val) {
164
222
  if (/^-?\d+(\.\d+)?$/.test(val.trim())) {
165
223
  return val.trim();
166
224
  }
167
- // Simple single-quoted string literals (no nested quotes)
225
+ // Simple single-quoted string literals (no semicolons, no SQL statement keywords)
168
226
  if (/^'[^']*'$/.test(val.trim())) {
227
+ const inner = val.trim().slice(1, -1);
228
+ if (/[;]/.test(inner) || /\b(DROP|ALTER|CREATE|INSERT|UPDATE|DELETE|GRANT|REVOKE|TRUNCATE)\b/i.test(inner)) {
229
+ throw new Error(`Suspicious default value: ${val}. String literals must not contain SQL statements.`);
230
+ }
169
231
  return val.trim();
170
232
  }
171
233
  throw new Error(`Unsupported default value: ${val}. Use a SQL function, numeric, string literal, or NULL.`);
@@ -216,66 +278,158 @@ async function schemaDiff(schema, connectionString) {
216
278
  maxLength: row.character_maximum_length,
217
279
  };
218
280
  }
219
- const schemaTableNames = new Set(Object.keys(schema.tables));
220
- const result = { create: [], alter: [], drop: [], statements: [] };
281
+ // Get single-column UNIQUE constraints (excluding PKs)
282
+ const uniqueResult = await client.query(`SELECT tc.table_name, tc.constraint_name, kcu.column_name
283
+ FROM information_schema.table_constraints tc
284
+ JOIN information_schema.key_column_usage kcu
285
+ ON tc.constraint_name = kcu.constraint_name
286
+ AND tc.table_schema = kcu.table_schema
287
+ WHERE tc.table_schema = 'public'
288
+ AND tc.constraint_type = 'UNIQUE'
289
+ AND tc.constraint_name IN (
290
+ SELECT constraint_name
291
+ FROM information_schema.key_column_usage
292
+ WHERE table_schema = 'public'
293
+ GROUP BY constraint_name
294
+ HAVING COUNT(*) = 1
295
+ )`);
296
+ // Map: table → column → constraint_name for single-col uniques
297
+ const dbUniques = {};
298
+ for (const row of uniqueResult.rows) {
299
+ if (!dbUniques[row.table_name])
300
+ dbUniques[row.table_name] = {};
301
+ dbUniques[row.table_name][row.column_name] = row.constraint_name;
302
+ }
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);
307
+ const result = { create: [], alter: [], drop: [], statements: [], reverseStatements: [] };
308
+ const resolveRef = makeRefResolver(schema);
221
309
  // Tables to create (in schema but not in DB)
222
310
  const sorted = topologicalSort(schema);
223
- for (const tableName of sorted) {
224
- if (!existingTables.has(tableName)) {
225
- 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)) {
226
315
  result.create.push(tableDef);
227
- result.statements.push(generateCreateTable(tableDef));
228
- // Also add FK indexes
229
- result.statements.push(...generateForeignKeyIndexes(tableDef));
316
+ result.statements.push(generateCreateTable(tableDef, resolveRef));
317
+ const fkIndexes = generateForeignKeyIndexes(tableDef);
318
+ result.statements.push(...fkIndexes);
319
+ // Reverse: DROP TABLE (with indexes — they drop automatically)
320
+ result.reverseStatements.unshift(`DROP TABLE IF EXISTS ${(0, query_js_1.quoteIdent)(ddlName)} CASCADE;`);
230
321
  }
231
322
  }
232
323
  // Tables to drop (in DB but not in schema)
233
324
  for (const existingTable of existingTables) {
234
- if (!schemaTableNames.has(existingTable)) {
325
+ if (!schemaDdlNames.has(existingTable)) {
235
326
  result.drop.push(existingTable);
236
327
  // We don't auto-generate DROP statements for safety
237
328
  }
238
329
  }
239
330
  // Tables to alter (exist in both)
240
- for (const tableName of sorted) {
331
+ for (const tableKey of sorted) {
332
+ const tableDef = schema.tables[tableKey];
333
+ const tableName = tableDef.name;
241
334
  if (!existingTables.has(tableName))
242
335
  continue;
243
- const tableDef = schema.tables[tableName];
244
336
  const dbCols = dbColumns[tableName] ?? {};
337
+ const tableUniques = dbUniques[tableName] ?? {};
245
338
  const alterDef = { table: tableName, columns: [] };
246
339
  for (const [fieldName, config] of Object.entries(tableDef.columns)) {
247
340
  const snakeName = (0, schema_js_1.camelToSnake)(fieldName);
248
341
  const dbCol = dbCols[snakeName];
249
342
  if (!dbCol) {
250
343
  // Column exists in schema but not in DB — ADD COLUMN
251
- const colDef = generateColumnDef(fieldName, config);
344
+ const colDef = generateColumnDef(fieldName, config, resolveRef);
252
345
  const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ADD COLUMN ${colDef};`;
253
- alterDef.columns.push({ column: snakeName, action: 'add', sql });
346
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} DROP COLUMN ${(0, query_js_1.quoteIdent)(snakeName)};`;
347
+ alterDef.columns.push({ column: snakeName, action: 'add', sql, reverseSql });
254
348
  result.statements.push(sql);
349
+ result.reverseStatements.unshift(reverseSql);
255
350
  continue;
256
351
  }
257
352
  // Check type mismatch
258
353
  const expectedUdt = schemaTypeToUdt(config);
259
354
  if (expectedUdt && dbCol.udtName !== expectedUdt) {
260
- const sqlType = config.type === 'VARCHAR' && config.maxLength
261
- ? `VARCHAR(${config.maxLength})`
262
- : config.type;
263
- const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} TYPE ${sqlType};`;
264
- alterDef.columns.push({ column: snakeName, action: 'alter_type', sql });
355
+ const sqlType = config.type === 'VARCHAR' && config.maxLength ? `VARCHAR(${config.maxLength})` : config.type;
356
+ const oldSqlType = udtToSqlType(dbCol.udtName, dbCol.maxLength);
357
+ const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} TYPE ${sqlType} USING ${(0, query_js_1.quoteIdent)(snakeName)}::${sqlType};`;
358
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} TYPE ${oldSqlType} USING ${(0, query_js_1.quoteIdent)(snakeName)}::${oldSqlType};`;
359
+ alterDef.columns.push({ column: snakeName, action: 'alter_type', sql, reverseSql });
265
360
  result.statements.push(sql);
361
+ result.reverseStatements.unshift(reverseSql);
266
362
  }
267
363
  // Check NOT NULL mismatch
268
364
  const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || config.type === 'BIGSERIAL';
269
365
  const isCurrentlyNullable = dbCol.isNullable;
270
366
  if (shouldBeNotNull && isCurrentlyNullable && !config.isNullable) {
271
367
  const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} SET NOT NULL;`;
272
- alterDef.columns.push({ column: snakeName, action: 'set_not_null', sql });
368
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} DROP NOT NULL;`;
369
+ alterDef.columns.push({ column: snakeName, action: 'set_not_null', sql, reverseSql });
273
370
  result.statements.push(sql);
371
+ result.reverseStatements.unshift(reverseSql);
274
372
  }
275
373
  else if (!shouldBeNotNull && !isCurrentlyNullable && config.isNullable) {
276
374
  const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} DROP NOT NULL;`;
277
- alterDef.columns.push({ column: snakeName, action: 'drop_not_null', sql });
375
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} SET NOT NULL;`;
376
+ alterDef.columns.push({ column: snakeName, action: 'drop_not_null', sql, reverseSql });
278
377
  result.statements.push(sql);
378
+ result.reverseStatements.unshift(reverseSql);
379
+ }
380
+ // Check DEFAULT value mismatch
381
+ const isSerial = config.type === 'BIGSERIAL';
382
+ if (!isSerial) {
383
+ const schemaDefault = config.defaultValue ? normalizeDefault(config.defaultValue) : null;
384
+ const dbDefault = dbCol.columnDefault;
385
+ if (schemaDefault && !dbDefault) {
386
+ // Schema has default, DB doesn't
387
+ const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} SET DEFAULT ${schemaDefault};`;
388
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} DROP DEFAULT;`;
389
+ alterDef.columns.push({ column: snakeName, action: 'set_default', sql, reverseSql });
390
+ result.statements.push(sql);
391
+ result.reverseStatements.unshift(reverseSql);
392
+ }
393
+ else if (!schemaDefault && dbDefault && !isSequenceDefault(dbDefault)) {
394
+ // DB has a non-sequence default, schema doesn't
395
+ const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} DROP DEFAULT;`;
396
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} SET DEFAULT ${dbDefault};`;
397
+ alterDef.columns.push({ column: snakeName, action: 'drop_default', sql, reverseSql });
398
+ result.statements.push(sql);
399
+ result.reverseStatements.unshift(reverseSql);
400
+ }
401
+ else if (schemaDefault &&
402
+ dbDefault &&
403
+ !isSequenceDefault(dbDefault) &&
404
+ !defaultsMatch(schemaDefault, dbDefault)) {
405
+ // Both have defaults but they differ
406
+ const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} SET DEFAULT ${schemaDefault};`;
407
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ALTER COLUMN ${(0, query_js_1.quoteIdent)(snakeName)} SET DEFAULT ${dbDefault};`;
408
+ alterDef.columns.push({ column: snakeName, action: 'set_default', sql, reverseSql });
409
+ result.statements.push(sql);
410
+ result.reverseStatements.unshift(reverseSql);
411
+ }
412
+ }
413
+ // Check UNIQUE constraint mismatch (skip PKs — they're implicitly unique)
414
+ if (!config.isPrimaryKey) {
415
+ const hasDbUnique = snakeName in tableUniques;
416
+ const wantsUnique = config.isUnique === true;
417
+ if (wantsUnique && !hasDbUnique) {
418
+ const constraintName = `${tableName}_${snakeName}_key`;
419
+ const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ADD CONSTRAINT ${(0, query_js_1.quoteIdent)(constraintName)} UNIQUE (${(0, query_js_1.quoteIdent)(snakeName)});`;
420
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} DROP CONSTRAINT ${(0, query_js_1.quoteIdent)(constraintName)};`;
421
+ alterDef.columns.push({ column: snakeName, action: 'add_unique', sql, reverseSql });
422
+ result.statements.push(sql);
423
+ result.reverseStatements.unshift(reverseSql);
424
+ }
425
+ else if (!wantsUnique && hasDbUnique) {
426
+ const constraintName = tableUniques[snakeName];
427
+ const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} DROP CONSTRAINT ${(0, query_js_1.quoteIdent)(constraintName)};`;
428
+ const reverseSql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} ADD CONSTRAINT ${(0, query_js_1.quoteIdent)(constraintName)} UNIQUE (${(0, query_js_1.quoteIdent)(snakeName)});`;
429
+ alterDef.columns.push({ column: snakeName, action: 'drop_unique', sql, reverseSql });
430
+ result.statements.push(sql);
431
+ result.reverseStatements.unshift(reverseSql);
432
+ }
279
433
  }
280
434
  }
281
435
  // Check for columns in DB that are not in schema
@@ -283,7 +437,8 @@ async function schemaDiff(schema, connectionString) {
283
437
  const hasField = Object.entries(tableDef.columns).some(([fieldName]) => (0, schema_js_1.camelToSnake)(fieldName) === dbColName);
284
438
  if (!hasField) {
285
439
  const sql = `ALTER TABLE ${(0, query_js_1.quoteIdent)(tableName)} DROP COLUMN ${(0, query_js_1.quoteIdent)(dbColName)};`;
286
- alterDef.columns.push({ column: dbColName, action: 'drop', sql });
440
+ const reverseSql = `-- Cannot auto-reverse DROP COLUMN for "${dbColName}" add it back manually`;
441
+ alterDef.columns.push({ column: dbColName, action: 'drop', sql, reverseSql });
287
442
  // Don't auto-add drops to statements for safety — user must opt in
288
443
  }
289
444
  }
@@ -320,6 +475,55 @@ function schemaTypeToUdt(config) {
320
475
  };
321
476
  return map[config.type] ?? null;
322
477
  }
478
+ /**
479
+ * Reverse map: PostgreSQL UDT name → SQL type (for generating reverse ALTER TYPE).
480
+ */
481
+ function udtToSqlType(udtName, maxLength) {
482
+ const map = {
483
+ int8: 'BIGINT',
484
+ int4: 'INTEGER',
485
+ int2: 'SMALLINT',
486
+ text: 'TEXT',
487
+ varchar: maxLength ? `VARCHAR(${maxLength})` : 'VARCHAR',
488
+ bool: 'BOOLEAN',
489
+ timestamptz: 'TIMESTAMPTZ',
490
+ date: 'DATE',
491
+ jsonb: 'JSONB',
492
+ uuid: 'UUID',
493
+ float4: 'REAL',
494
+ float8: 'DOUBLE PRECISION',
495
+ numeric: 'NUMERIC',
496
+ bytea: 'BYTEA',
497
+ };
498
+ return map[udtName] ?? udtName.toUpperCase();
499
+ }
500
+ /**
501
+ * Normalize a database default value for comparison.
502
+ * Strips PostgreSQL type casts (e.g. 'free'::text → 'free') and wrapping parens.
503
+ */
504
+ function normalizeDbDefault(dbDefault) {
505
+ let val = dbDefault;
506
+ // Strip type casts: 'free'::text → 'free', 0::integer → 0
507
+ val = val.replace(/::[\w\s"]+(\[\])?$/g, '').trim();
508
+ // Unwrap parens added by PostgreSQL: ('free') → 'free'
509
+ while (val.startsWith('(') && val.endsWith(')')) {
510
+ val = val.slice(1, -1).trim();
511
+ }
512
+ return val;
513
+ }
514
+ /** Check if a DB default is a sequence default (auto-generated for serial columns). */
515
+ function isSequenceDefault(dbDefault) {
516
+ return dbDefault.includes('nextval(');
517
+ }
518
+ /**
519
+ * Compare a schema default against a database default, accounting for
520
+ * PostgreSQL's normalization of default values.
521
+ */
522
+ function defaultsMatch(schemaDefault, dbDefault) {
523
+ const a = schemaDefault.toLowerCase().trim();
524
+ const b = normalizeDbDefault(dbDefault).toLowerCase().trim();
525
+ return a === b;
526
+ }
323
527
  /**
324
528
  * Push a schema definition to a live database.
325
529
  *
@@ -367,5 +571,5 @@ async function schemaPush(schema, connectionString, options = {}) {
367
571
  */
368
572
  function schemaToSQLString(schema) {
369
573
  const statements = schemaToSQL(schema);
370
- return statements.join('\n\n') + '\n';
574
+ return `${statements.join('\n\n')}\n`;
371
575
  }