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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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 '
|
|
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 [
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
package/dist/cjs/schema-sql.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
|
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(' ');
|
|
@@ -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
|
|
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
|
-
|
|
220
|
-
const
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
result.statements.push(...
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
574
|
+
return `${statements.join('\n\n')}\n`;
|
|
371
575
|
}
|