@vibeorm/migrate 1.0.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 +95 -0
- package/package.json +42 -0
- package/src/ddl-builder.ts +442 -0
- package/src/index.ts +40 -0
- package/src/introspector.ts +618 -0
- package/src/migration-runner.ts +103 -0
- package/src/schema-differ.ts +673 -0
- package/src/schema-printer.ts +226 -0
- package/src/snapshot.ts +141 -0
- package/src/sql-utils.ts +45 -0
- package/src/types.ts +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @vibeorm/migrate
|
|
2
|
+
|
|
3
|
+
Migration, introspection, and schema diff toolkit for VibeORM. Handles DDL generation, database introspection, schema comparison, migration tracking, and `.prisma` schema printing.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @vibeorm/migrate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **DDL generation** — schema IR to PostgreSQL DDL (enums, tables, constraints, indexes, foreign keys, M:N join tables)
|
|
14
|
+
- **Introspection** — reverse-engineer a live PostgreSQL database into schema IR
|
|
15
|
+
- **Schema diffing** — compute migration operations between two schema versions
|
|
16
|
+
- **Migration runner** — track and apply migrations via a `_vibeorm_migrations` table
|
|
17
|
+
- **Snapshot management** — serialize/deserialize schema IR as JSON for diffing
|
|
18
|
+
- **Schema printing** — convert schema IR back to `.prisma` format
|
|
19
|
+
|
|
20
|
+
## API
|
|
21
|
+
|
|
22
|
+
### DDL
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { buildDDL } from "@vibeorm/migrate";
|
|
26
|
+
|
|
27
|
+
const { sql, statements } = buildDDL({ schema });
|
|
28
|
+
// sql: full DDL wrapped in BEGIN/COMMIT
|
|
29
|
+
// statements: individual DDL statements
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Introspection
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { introspect } from "@vibeorm/migrate";
|
|
36
|
+
|
|
37
|
+
const schema = await introspect({ executor });
|
|
38
|
+
// Returns a Schema IR from a live database
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Schema Diffing
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { diffSchemas } from "@vibeorm/migrate";
|
|
45
|
+
|
|
46
|
+
const operations = diffSchemas({ previous: oldSchema, current: newSchema });
|
|
47
|
+
// Returns DiffOperation[] with isDestructive flags
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Migration Runner
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import {
|
|
54
|
+
ensureMigrationTable,
|
|
55
|
+
getAppliedMigrations,
|
|
56
|
+
applyMigration,
|
|
57
|
+
removeMigrationRecord,
|
|
58
|
+
markMigrationApplied,
|
|
59
|
+
} from "@vibeorm/migrate";
|
|
60
|
+
|
|
61
|
+
await ensureMigrationTable({ executor });
|
|
62
|
+
const applied = await getAppliedMigrations({ executor });
|
|
63
|
+
await applyMigration({ executor, migrationName: "20260101_init", sql, checksum });
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Snapshots
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import {
|
|
70
|
+
saveSnapshot,
|
|
71
|
+
loadLatestSnapshot,
|
|
72
|
+
loadSnapshot,
|
|
73
|
+
saveJournal,
|
|
74
|
+
loadJournal,
|
|
75
|
+
generateTimestamp,
|
|
76
|
+
computeChecksum,
|
|
77
|
+
} from "@vibeorm/migrate";
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Schema Printing
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { printSchema } from "@vibeorm/migrate";
|
|
84
|
+
|
|
85
|
+
const prismaText = printSchema({ schema });
|
|
86
|
+
// Returns .prisma formatted schema text
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Diff Operation Types
|
|
90
|
+
|
|
91
|
+
18 operation types: `createEnum`, `addEnumValue`, `removeEnumValue`, `dropEnum`, `createTable`, `dropTable`, `addColumn`, `dropColumn`, `alterColumnType`, `alterColumnNullability`, `alterColumnDefault`, `addUnique`, `dropUnique`, `addIndex`, `dropIndex`, `addForeignKey`, `dropForeignKey`, `createJoinTable`, `dropJoinTable`.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
[MIT](../../LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibeorm/migrate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Migration, introspection, and schema diff toolkit for VibeORM",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"orm",
|
|
8
|
+
"migrations",
|
|
9
|
+
"postgresql",
|
|
10
|
+
"bun",
|
|
11
|
+
"typescript",
|
|
12
|
+
"introspection"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"default": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/vibeorm/vibeorm.git",
|
|
27
|
+
"directory": "packages/migrate"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/vibeorm/vibeorm/tree/master/packages/migrate",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/vibeorm/vibeorm/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.1.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@vibeorm/parser": "workspace:*"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DDL Builder — Schema IR → DDL SQL
|
|
3
|
+
*
|
|
4
|
+
* Takes a parsed Schema IR and produces the complete set of DDL statements
|
|
5
|
+
* to create the database from scratch (enums, tables, constraints, indexes, FKs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Schema, Model, Field, ScalarField, EnumField, RelationField, DefaultValue, Enum, EnumValue } from "@vibeorm/parser";
|
|
9
|
+
import { PRISMA_TO_PG } from "@vibeorm/parser";
|
|
10
|
+
|
|
11
|
+
export type DDLResult = {
|
|
12
|
+
/** The full DDL SQL wrapped in BEGIN/COMMIT */
|
|
13
|
+
sql: string;
|
|
14
|
+
/** Individual DDL statements (without BEGIN/COMMIT wrapper) */
|
|
15
|
+
statements: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function q(params: { name: string }): string {
|
|
21
|
+
return `"${params.name}"`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDbName(params: { model: Model }): string {
|
|
25
|
+
return params.model.dbName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getFieldDbName(params: { field: ScalarField | EnumField }): string {
|
|
29
|
+
return params.field.dbName;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveColumnType(params: { field: ScalarField }): string {
|
|
33
|
+
const { field } = params;
|
|
34
|
+
|
|
35
|
+
// Autoincrement uses SERIAL
|
|
36
|
+
if (field.default?.kind === "autoincrement") {
|
|
37
|
+
return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Native type override (e.g., @db.VarChar(255))
|
|
41
|
+
if (field.nativeType) {
|
|
42
|
+
return field.nativeType;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// List fields use PostgreSQL arrays
|
|
46
|
+
if (field.isList) {
|
|
47
|
+
return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return PRISMA_TO_PG[field.prismaType];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve to the base column type (no SERIAL — used for FK reference columns) */
|
|
54
|
+
function resolveBaseColumnType(params: { field: ScalarField }): string {
|
|
55
|
+
const { field } = params;
|
|
56
|
+
if (field.nativeType) return field.nativeType;
|
|
57
|
+
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
58
|
+
return PRISMA_TO_PG[field.prismaType];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveDefault(params: { field: ScalarField | EnumField }): string | undefined {
|
|
62
|
+
const { field } = params;
|
|
63
|
+
const def = field.default;
|
|
64
|
+
if (!def) return undefined;
|
|
65
|
+
|
|
66
|
+
switch (def.kind) {
|
|
67
|
+
case "autoincrement":
|
|
68
|
+
// Handled by SERIAL type, no explicit DEFAULT needed
|
|
69
|
+
return undefined;
|
|
70
|
+
case "now":
|
|
71
|
+
return "CURRENT_TIMESTAMP";
|
|
72
|
+
case "uuid":
|
|
73
|
+
return "gen_random_uuid()::TEXT";
|
|
74
|
+
case "cuid":
|
|
75
|
+
// No native PG equivalent, handled at application level
|
|
76
|
+
return undefined;
|
|
77
|
+
case "nanoid":
|
|
78
|
+
// No native PG equivalent, handled at application level
|
|
79
|
+
return undefined;
|
|
80
|
+
case "ulid":
|
|
81
|
+
// No native PG equivalent, handled at application level
|
|
82
|
+
return undefined;
|
|
83
|
+
case "dbgenerated":
|
|
84
|
+
return def.value;
|
|
85
|
+
case "literal": {
|
|
86
|
+
const val = def.value;
|
|
87
|
+
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
|
88
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
89
|
+
return String(val);
|
|
90
|
+
}
|
|
91
|
+
default:
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Enum DDL ─────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function buildEnumStatements(params: { enums: Enum[] }): string[] {
|
|
99
|
+
const statements: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (const enumDef of params.enums) {
|
|
102
|
+
const typeName = enumDef.dbName ?? enumDef.name;
|
|
103
|
+
const values = enumDef.values
|
|
104
|
+
.map((v) => `'${(v.dbName ?? v.name).replace(/'/g, "''")}'`)
|
|
105
|
+
.join(", ");
|
|
106
|
+
|
|
107
|
+
// Use DO block for safe creation (no IF NOT EXISTS for CREATE TYPE)
|
|
108
|
+
statements.push(
|
|
109
|
+
`DO $$ BEGIN\n` +
|
|
110
|
+
` CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\n` +
|
|
111
|
+
`EXCEPTION WHEN duplicate_object THEN NULL;\n` +
|
|
112
|
+
`END $$;`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return statements;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Table DDL ────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function buildTableStatement(params: { model: Model; enums: Enum[] }): string {
|
|
122
|
+
const { model, enums } = params;
|
|
123
|
+
const tableName = getDbName({ model });
|
|
124
|
+
const columns: string[] = [];
|
|
125
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
126
|
+
|
|
127
|
+
// Collect scalar + enum fields (not relations)
|
|
128
|
+
for (const field of model.fields) {
|
|
129
|
+
if (field.kind === "relation") continue;
|
|
130
|
+
|
|
131
|
+
const colName = getFieldDbName({ field });
|
|
132
|
+
let colType: string;
|
|
133
|
+
|
|
134
|
+
if (field.kind === "enum") {
|
|
135
|
+
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
136
|
+
const enumDbName = enumDef?.dbName ?? field.enumName;
|
|
137
|
+
colType = q({ name: enumDbName });
|
|
138
|
+
if (field.isList) colType += "[]";
|
|
139
|
+
} else {
|
|
140
|
+
colType = resolveColumnType({ field });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let colDef = ` ${q({ name: colName })} ${colType}`;
|
|
144
|
+
|
|
145
|
+
// NOT NULL (unless optional or autoincrement serial which is implicitly NOT NULL)
|
|
146
|
+
if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
|
|
147
|
+
colDef += " NOT NULL";
|
|
148
|
+
} else if (field.kind === "enum" && field.isRequired) {
|
|
149
|
+
colDef += " NOT NULL";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// DEFAULT
|
|
153
|
+
const defaultExpr = resolveDefault({ field });
|
|
154
|
+
if (defaultExpr) {
|
|
155
|
+
colDef += ` DEFAULT ${defaultExpr}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Inline UNIQUE for single-field unique
|
|
159
|
+
if (field.isUnique) {
|
|
160
|
+
colDef += " UNIQUE";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
columns.push(colDef);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Primary key constraint
|
|
167
|
+
const pkFields = model.primaryKey.fields
|
|
168
|
+
.map((f) => {
|
|
169
|
+
const field = model.fields.find((fld) => fld.name === f);
|
|
170
|
+
if (field && field.kind !== "relation") {
|
|
171
|
+
return q({ name: getFieldDbName({ field }) });
|
|
172
|
+
}
|
|
173
|
+
return q({ name: f });
|
|
174
|
+
})
|
|
175
|
+
.join(", ");
|
|
176
|
+
|
|
177
|
+
const pkName = `${tableName}_pkey`;
|
|
178
|
+
columns.push(` CONSTRAINT ${q({ name: pkName })} PRIMARY KEY (${pkFields})`);
|
|
179
|
+
|
|
180
|
+
return `CREATE TABLE ${q({ name: tableName })} (\n${columns.join(",\n")}\n);`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Unique Index DDL ─────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function buildUniqueIndexStatements(params: { model: Model }): string[] {
|
|
186
|
+
const { model } = params;
|
|
187
|
+
const statements: string[] = [];
|
|
188
|
+
const tableName = getDbName({ model });
|
|
189
|
+
|
|
190
|
+
for (const unique of model.uniqueConstraints) {
|
|
191
|
+
// Skip single-field uniques (handled inline on column)
|
|
192
|
+
if (unique.fields.length === 1) continue;
|
|
193
|
+
|
|
194
|
+
const cols = unique.fields
|
|
195
|
+
.map((f) => {
|
|
196
|
+
const field = model.fields.find((fld) => fld.name === f);
|
|
197
|
+
if (field && field.kind !== "relation") {
|
|
198
|
+
return q({ name: getFieldDbName({ field }) });
|
|
199
|
+
}
|
|
200
|
+
return q({ name: f });
|
|
201
|
+
})
|
|
202
|
+
.join(", ");
|
|
203
|
+
|
|
204
|
+
const idxName = unique.name ?? `${tableName}_${unique.fields.join("_")}_key`;
|
|
205
|
+
statements.push(
|
|
206
|
+
`CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return statements;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Regular Index DDL ────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function buildIndexStatements(params: { model: Model }): string[] {
|
|
216
|
+
const { model } = params;
|
|
217
|
+
const statements: string[] = [];
|
|
218
|
+
const tableName = getDbName({ model });
|
|
219
|
+
|
|
220
|
+
for (const idx of model.indexes) {
|
|
221
|
+
const cols = idx.fields
|
|
222
|
+
.map((f) => {
|
|
223
|
+
const field = model.fields.find((fld) => fld.name === f);
|
|
224
|
+
if (field && field.kind !== "relation") {
|
|
225
|
+
return q({ name: getFieldDbName({ field }) });
|
|
226
|
+
}
|
|
227
|
+
return q({ name: f });
|
|
228
|
+
})
|
|
229
|
+
.join(", ");
|
|
230
|
+
|
|
231
|
+
const idxName = idx.name ?? `${tableName}_${idx.fields.join("_")}_idx`;
|
|
232
|
+
statements.push(
|
|
233
|
+
`CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return statements;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Foreign Key DDL ──────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function buildForeignKeyStatements(params: { model: Model; allModels: Model[] }): string[] {
|
|
243
|
+
const { model, allModels } = params;
|
|
244
|
+
const statements: string[] = [];
|
|
245
|
+
const tableName = getDbName({ model });
|
|
246
|
+
|
|
247
|
+
for (const field of model.fields) {
|
|
248
|
+
if (field.kind !== "relation") continue;
|
|
249
|
+
if (!field.relation.isForeignKey) continue;
|
|
250
|
+
if (field.relation.fields.length === 0) continue;
|
|
251
|
+
|
|
252
|
+
const relatedModel = allModels.find((m) => m.name === field.relatedModel);
|
|
253
|
+
if (!relatedModel) continue;
|
|
254
|
+
|
|
255
|
+
const relatedTableName = getDbName({ model: relatedModel });
|
|
256
|
+
|
|
257
|
+
const fkCols = field.relation.fields
|
|
258
|
+
.map((f) => {
|
|
259
|
+
const fld = model.fields.find((mf) => mf.name === f);
|
|
260
|
+
if (fld && fld.kind !== "relation") return q({ name: getFieldDbName({ field: fld }) });
|
|
261
|
+
return q({ name: f });
|
|
262
|
+
})
|
|
263
|
+
.join(", ");
|
|
264
|
+
|
|
265
|
+
const refCols = field.relation.references
|
|
266
|
+
.map((f) => {
|
|
267
|
+
const fld = relatedModel.fields.find((mf) => mf.name === f);
|
|
268
|
+
if (fld && fld.kind !== "relation") return q({ name: getFieldDbName({ field: fld }) });
|
|
269
|
+
return q({ name: f });
|
|
270
|
+
})
|
|
271
|
+
.join(", ");
|
|
272
|
+
|
|
273
|
+
const constraintName = `${tableName}_${field.relation.fields.join("_")}_fkey`;
|
|
274
|
+
statements.push(
|
|
275
|
+
`ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} ` +
|
|
276
|
+
`FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ` +
|
|
277
|
+
`ON DELETE RESTRICT ON UPDATE CASCADE;`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return statements;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Implicit M:N Join Table ──────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
type ImplicitManyToMany = {
|
|
287
|
+
modelA: string;
|
|
288
|
+
modelB: string;
|
|
289
|
+
tableA: string;
|
|
290
|
+
tableB: string;
|
|
291
|
+
pkFieldA: string;
|
|
292
|
+
pkFieldB: string;
|
|
293
|
+
pkTypeA: string;
|
|
294
|
+
pkTypeB: string;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function detectImplicitManyToMany(params: { schema: Schema }): ImplicitManyToMany[] {
|
|
298
|
+
const { schema } = params;
|
|
299
|
+
const result: ImplicitManyToMany[] = [];
|
|
300
|
+
const seen = new Set<string>();
|
|
301
|
+
|
|
302
|
+
for (const model of schema.models) {
|
|
303
|
+
for (const field of model.fields) {
|
|
304
|
+
if (field.kind !== "relation") continue;
|
|
305
|
+
if (!field.isList) continue;
|
|
306
|
+
if (field.relation.isForeignKey) continue;
|
|
307
|
+
if (field.relation.fields.length > 0) continue;
|
|
308
|
+
|
|
309
|
+
// Check if the other side is also a list with no FK fields
|
|
310
|
+
const relatedModel = schema.models.find((m) => m.name === field.relatedModel);
|
|
311
|
+
if (!relatedModel) continue;
|
|
312
|
+
|
|
313
|
+
const backRef = relatedModel.fields.find(
|
|
314
|
+
(f) =>
|
|
315
|
+
f.kind === "relation" &&
|
|
316
|
+
f.relatedModel === model.name &&
|
|
317
|
+
f.isList &&
|
|
318
|
+
!f.relation.isForeignKey &&
|
|
319
|
+
f.relation.fields.length === 0
|
|
320
|
+
);
|
|
321
|
+
if (!backRef) continue;
|
|
322
|
+
|
|
323
|
+
// Sort alphabetically for consistent naming
|
|
324
|
+
const sorted = [model.name, relatedModel.name].sort();
|
|
325
|
+
const nameA = sorted[0]!;
|
|
326
|
+
const nameB = sorted[1]!;
|
|
327
|
+
const key = `${nameA}_${nameB}`;
|
|
328
|
+
if (seen.has(key)) continue;
|
|
329
|
+
seen.add(key);
|
|
330
|
+
|
|
331
|
+
const modelADef = schema.models.find((m) => m.name === nameA)!;
|
|
332
|
+
const modelBDef = schema.models.find((m) => m.name === nameB)!;
|
|
333
|
+
|
|
334
|
+
const pkFieldA = modelADef.primaryKey.fields[0]!;
|
|
335
|
+
const pkFieldB = modelBDef.primaryKey.fields[0]!;
|
|
336
|
+
|
|
337
|
+
const pkFieldDefA = modelADef.fields.find((f) => f.name === pkFieldA);
|
|
338
|
+
const pkFieldDefB = modelBDef.fields.find((f) => f.name === pkFieldB);
|
|
339
|
+
|
|
340
|
+
const pkTypeA = pkFieldDefA && pkFieldDefA.kind === "scalar"
|
|
341
|
+
? resolveBaseColumnType({ field: pkFieldDefA })
|
|
342
|
+
: "INTEGER";
|
|
343
|
+
const pkTypeB = pkFieldDefB && pkFieldDefB.kind === "scalar"
|
|
344
|
+
? resolveBaseColumnType({ field: pkFieldDefB })
|
|
345
|
+
: "INTEGER";
|
|
346
|
+
|
|
347
|
+
result.push({
|
|
348
|
+
modelA: nameA,
|
|
349
|
+
modelB: nameB,
|
|
350
|
+
tableA: getDbName({ model: modelADef }),
|
|
351
|
+
tableB: getDbName({ model: modelBDef }),
|
|
352
|
+
pkFieldA,
|
|
353
|
+
pkFieldB,
|
|
354
|
+
pkTypeA,
|
|
355
|
+
pkTypeB,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function buildJoinTableStatements(params: { joinTable: ImplicitManyToMany }): string[] {
|
|
364
|
+
const { joinTable } = params;
|
|
365
|
+
const tableName = `_${joinTable.modelA}To${joinTable.modelB}`;
|
|
366
|
+
const statements: string[] = [];
|
|
367
|
+
|
|
368
|
+
// Create join table
|
|
369
|
+
statements.push(
|
|
370
|
+
`CREATE TABLE ${q({ name: tableName })} (\n` +
|
|
371
|
+
` "A" ${joinTable.pkTypeA} NOT NULL,\n` +
|
|
372
|
+
` "B" ${joinTable.pkTypeB} NOT NULL\n` +
|
|
373
|
+
`);`
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// Composite unique index on (A, B)
|
|
377
|
+
statements.push(
|
|
378
|
+
`CREATE UNIQUE INDEX ${q({ name: `${tableName}_AB_unique` })} ON ${q({ name: tableName })} ("A", "B");`
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Index on B for reverse lookups
|
|
382
|
+
statements.push(
|
|
383
|
+
`CREATE INDEX ${q({ name: `${tableName}_B_index` })} ON ${q({ name: tableName })} ("B");`
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
// Foreign key A → modelA
|
|
387
|
+
statements.push(
|
|
388
|
+
`ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: `${tableName}_A_fkey` })} ` +
|
|
389
|
+
`FOREIGN KEY ("A") REFERENCES ${q({ name: joinTable.tableA })} ("${joinTable.pkFieldA}") ` +
|
|
390
|
+
`ON DELETE CASCADE ON UPDATE CASCADE;`
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Foreign key B → modelB
|
|
394
|
+
statements.push(
|
|
395
|
+
`ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: `${tableName}_B_fkey` })} ` +
|
|
396
|
+
`FOREIGN KEY ("B") REFERENCES ${q({ name: joinTable.tableB })} ("${joinTable.pkFieldB}") ` +
|
|
397
|
+
`ON DELETE CASCADE ON UPDATE CASCADE;`
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return statements;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Main Entry Point ─────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
export function buildDDL(params: { schema: Schema }): DDLResult {
|
|
406
|
+
const { schema } = params;
|
|
407
|
+
const statements: string[] = [];
|
|
408
|
+
|
|
409
|
+
// 1. Enums
|
|
410
|
+
statements.push(...buildEnumStatements({ enums: schema.enums }));
|
|
411
|
+
|
|
412
|
+
// 2. Tables
|
|
413
|
+
for (const model of schema.models) {
|
|
414
|
+
statements.push(buildTableStatement({ model, enums: schema.enums }));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 3. Unique indexes (multi-column only; single-column handled inline)
|
|
418
|
+
for (const model of schema.models) {
|
|
419
|
+
statements.push(...buildUniqueIndexStatements({ model }));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 4. Regular indexes
|
|
423
|
+
for (const model of schema.models) {
|
|
424
|
+
statements.push(...buildIndexStatements({ model }));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 5. Foreign keys (after all tables exist)
|
|
428
|
+
for (const model of schema.models) {
|
|
429
|
+
statements.push(...buildForeignKeyStatements({ model, allModels: schema.models }));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 6. Implicit M:N join tables
|
|
433
|
+
const joinTables = detectImplicitManyToMany({ schema });
|
|
434
|
+
for (const jt of joinTables) {
|
|
435
|
+
statements.push(...buildJoinTableStatements({ joinTable: jt }));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Wrap in transaction
|
|
439
|
+
const sql = `BEGIN;\n\n${statements.join("\n\n")}\n\nCOMMIT;`;
|
|
440
|
+
|
|
441
|
+
return { sql, statements };
|
|
442
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vibeorm/migrate
|
|
3
|
+
*
|
|
4
|
+
* Core migration modules: DDL generation, database introspection,
|
|
5
|
+
* schema diffing, snapshot management, migration execution, and schema printing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { buildDDL } from "./ddl-builder.ts";
|
|
9
|
+
export type { DDLResult } from "./ddl-builder.ts";
|
|
10
|
+
|
|
11
|
+
export { introspect } from "./introspector.ts";
|
|
12
|
+
|
|
13
|
+
export { diffSchemas } from "./schema-differ.ts";
|
|
14
|
+
export type { DiffOperation, DiffOperationType } from "./schema-differ.ts";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
saveSnapshot,
|
|
18
|
+
loadLatestSnapshot,
|
|
19
|
+
loadSnapshot,
|
|
20
|
+
saveJournal,
|
|
21
|
+
loadJournal,
|
|
22
|
+
generateTimestamp,
|
|
23
|
+
computeChecksum,
|
|
24
|
+
} from "./snapshot.ts";
|
|
25
|
+
export type { JournalEntry, Journal } from "./snapshot.ts";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
ensureMigrationTable,
|
|
29
|
+
getAppliedMigrations,
|
|
30
|
+
applyMigration,
|
|
31
|
+
removeMigrationRecord,
|
|
32
|
+
markMigrationApplied,
|
|
33
|
+
} from "./migration-runner.ts";
|
|
34
|
+
export type { AppliedMigration } from "./migration-runner.ts";
|
|
35
|
+
|
|
36
|
+
export { printSchema } from "./schema-printer.ts";
|
|
37
|
+
|
|
38
|
+
export { splitSqlStatements } from "./sql-utils.ts";
|
|
39
|
+
|
|
40
|
+
export type { SqlExecutor } from "./types.ts";
|