@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 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";