@metaobjectsdev/migrate-ts 0.5.0-rc.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/LICENSE +189 -0
- package/README.md +73 -0
- package/dist/diff/index.d.ts +30 -0
- package/dist/diff/index.d.ts.map +1 -0
- package/dist/diff/index.js +226 -0
- package/dist/diff/index.js.map +1 -0
- package/dist/diff/rename-heuristic.d.ts +23 -0
- package/dist/diff/rename-heuristic.d.ts.map +1 -0
- package/dist/diff/rename-heuristic.js +236 -0
- package/dist/diff/rename-heuristic.js.map +1 -0
- package/dist/diff/status.d.ts +8 -0
- package/dist/diff/status.d.ts.map +1 -0
- package/dist/diff/status.js +53 -0
- package/dist/diff/status.js.map +1 -0
- package/dist/emit/index.d.ts +17 -0
- package/dist/emit/index.d.ts.map +1 -0
- package/dist/emit/index.js +18 -0
- package/dist/emit/index.js.map +1 -0
- package/dist/emit/postgres.d.ts +3 -0
- package/dist/emit/postgres.d.ts.map +1 -0
- package/dist/emit/postgres.js +181 -0
- package/dist/emit/postgres.js.map +1 -0
- package/dist/emit/sqlite.d.ts +3 -0
- package/dist/emit/sqlite.d.ts.map +1 -0
- package/dist/emit/sqlite.js +302 -0
- package/dist/emit/sqlite.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +54 -0
- package/dist/errors.js.map +1 -0
- package/dist/expected-schema.d.ts +15 -0
- package/dist/expected-schema.d.ts.map +1 -0
- package/dist/expected-schema.js +243 -0
- package/dist/expected-schema.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect/index.d.ts +6 -0
- package/dist/introspect/index.d.ts.map +1 -0
- package/dist/introspect/index.js +11 -0
- package/dist/introspect/index.js.map +1 -0
- package/dist/introspect/postgres.d.ts +57 -0
- package/dist/introspect/postgres.d.ts.map +1 -0
- package/dist/introspect/postgres.js +339 -0
- package/dist/introspect/postgres.js.map +1 -0
- package/dist/introspect/sqlite.d.ts +4 -0
- package/dist/introspect/sqlite.d.ts.map +1 -0
- package/dist/introspect/sqlite.js +192 -0
- package/dist/introspect/sqlite.js.map +1 -0
- package/dist/source-aware-diff.d.ts +20 -0
- package/dist/source-aware-diff.d.ts.map +1 -0
- package/dist/source-aware-diff.js +24 -0
- package/dist/source-aware-diff.js.map +1 -0
- package/dist/sql-type.d.ts +45 -0
- package/dist/sql-type.d.ts.map +1 -0
- package/dist/sql-type.js +76 -0
- package/dist/sql-type.js.map +1 -0
- package/dist/types.d.ts +223 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/view-ddl-postgres.d.ts +4 -0
- package/dist/view-ddl-postgres.d.ts.map +1 -0
- package/dist/view-ddl-postgres.js +13 -0
- package/dist/view-ddl-postgres.js.map +1 -0
- package/dist/view-ddl-sqlite.d.ts +3 -0
- package/dist/view-ddl-sqlite.d.ts.map +1 -0
- package/dist/view-ddl-sqlite.js +7 -0
- package/dist/view-ddl-sqlite.js.map +1 -0
- package/dist/view-diff.d.ts +13 -0
- package/dist/view-diff.d.ts.map +1 -0
- package/dist/view-diff.js +42 -0
- package/dist/view-diff.js.map +1 -0
- package/dist/write-migration.d.ts +19 -0
- package/dist/write-migration.d.ts.map +1 -0
- package/dist/write-migration.js +34 -0
- package/dist/write-migration.js.map +1 -0
- package/package.json +50 -0
- package/src/diff/index.ts +294 -0
- package/src/diff/rename-heuristic.ts +265 -0
- package/src/diff/status.ts +55 -0
- package/src/emit/index.ts +38 -0
- package/src/emit/postgres.ts +189 -0
- package/src/emit/sqlite.ts +322 -0
- package/src/errors.ts +58 -0
- package/src/expected-schema.ts +326 -0
- package/src/index.ts +49 -0
- package/src/introspect/index.ts +14 -0
- package/src/introspect/postgres.ts +428 -0
- package/src/introspect/sqlite.ts +216 -0
- package/src/source-aware-diff.ts +49 -0
- package/src/sql-type.ts +91 -0
- package/src/types.ts +174 -0
- package/src/view-ddl-postgres.ts +15 -0
- package/src/view-ddl-sqlite.ts +7 -0
- package/src/view-diff.ts +55 -0
- package/src/write-migration.ts +64 -0
package/src/sql-type.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical, dialect-neutral SQL type. Both buildExpectedSchema (metadata→snapshot)
|
|
3
|
+
* and introspect (db→snapshot) produce this shape; diff compares canonical to
|
|
4
|
+
* canonical; emit re-renders to dialect-specific SQL.
|
|
5
|
+
*
|
|
6
|
+
* Per spec §5.2.
|
|
7
|
+
*/
|
|
8
|
+
export type SqlType =
|
|
9
|
+
| { kind: "text"; maxLength?: number }
|
|
10
|
+
| { kind: "integer"; bits: 32 | 64 }
|
|
11
|
+
| { kind: "real" }
|
|
12
|
+
| { kind: "numeric"; precision?: number; scale?: number }
|
|
13
|
+
| { kind: "boolean" }
|
|
14
|
+
| { kind: "timestamp"; withTimezone: boolean }
|
|
15
|
+
| { kind: "date" }
|
|
16
|
+
| { kind: "json" }
|
|
17
|
+
| { kind: "blob" }
|
|
18
|
+
| { kind: "uuid" };
|
|
19
|
+
|
|
20
|
+
/** Structural equality on SqlType. */
|
|
21
|
+
export function sqlTypeEquals(a: SqlType, b: SqlType): boolean {
|
|
22
|
+
if (a.kind !== b.kind) return false;
|
|
23
|
+
switch (a.kind) {
|
|
24
|
+
case "text":
|
|
25
|
+
return a.maxLength === (b as Extract<SqlType, { kind: "text" }>).maxLength;
|
|
26
|
+
case "integer":
|
|
27
|
+
return a.bits === (b as Extract<SqlType, { kind: "integer" }>).bits;
|
|
28
|
+
case "numeric": {
|
|
29
|
+
const bn = b as Extract<SqlType, { kind: "numeric" }>;
|
|
30
|
+
return a.precision === bn.precision && a.scale === bn.scale;
|
|
31
|
+
}
|
|
32
|
+
case "timestamp":
|
|
33
|
+
return a.withTimezone === (b as Extract<SqlType, { kind: "timestamp" }>).withTimezone;
|
|
34
|
+
case "real":
|
|
35
|
+
case "boolean":
|
|
36
|
+
case "date":
|
|
37
|
+
case "json":
|
|
38
|
+
case "blob":
|
|
39
|
+
case "uuid":
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true if changing column type from `from` to `to` is provably non-lossy.
|
|
46
|
+
* Per spec §6.5. Conservative on purpose — false negatives just mean the user has
|
|
47
|
+
* to pass `allow.typeChange`; false positives could silently corrupt data.
|
|
48
|
+
*
|
|
49
|
+
* Returns false for identical types (caller should not emit a change-column-type
|
|
50
|
+
* in that case at all; this is defensive).
|
|
51
|
+
*/
|
|
52
|
+
export function isWidening(from: SqlType, to: SqlType): boolean {
|
|
53
|
+
if (sqlTypeEquals(from, to)) return false;
|
|
54
|
+
if (from.kind !== to.kind) return false; // cross-kind: always lossy
|
|
55
|
+
|
|
56
|
+
switch (from.kind) {
|
|
57
|
+
case "text": {
|
|
58
|
+
const t = to as Extract<SqlType, { kind: "text" }>;
|
|
59
|
+
// unbounded → bounded: lossy
|
|
60
|
+
if (from.maxLength === undefined) return false;
|
|
61
|
+
// bounded → unbounded: widening
|
|
62
|
+
if (t.maxLength === undefined) return true;
|
|
63
|
+
// bounded both: widening iff new ≥ old
|
|
64
|
+
return t.maxLength >= from.maxLength;
|
|
65
|
+
}
|
|
66
|
+
case "integer": {
|
|
67
|
+
const t = to as Extract<SqlType, { kind: "integer" }>;
|
|
68
|
+
return t.bits >= from.bits;
|
|
69
|
+
}
|
|
70
|
+
case "numeric": {
|
|
71
|
+
const t = to as Extract<SqlType, { kind: "numeric" }>;
|
|
72
|
+
const fp = from.precision ?? 0;
|
|
73
|
+
const fs = from.scale ?? 0;
|
|
74
|
+
const tp = t.precision ?? 0;
|
|
75
|
+
const ts = t.scale ?? 0;
|
|
76
|
+
// widening iff p2 ≥ p1 AND s2 = s1 AND (p2 - s2) ≥ (p1 - s1)
|
|
77
|
+
return tp >= fp && ts === fs && (tp - ts) >= (fp - fs);
|
|
78
|
+
}
|
|
79
|
+
// real/boolean/date/json/blob/uuid: same kind already handled by sqlTypeEquals;
|
|
80
|
+
// any difference here means the discriminant matched but structural equality failed
|
|
81
|
+
// (impossible given SqlType has no other variants for these kinds). Defensive false.
|
|
82
|
+
case "real":
|
|
83
|
+
case "boolean":
|
|
84
|
+
case "date":
|
|
85
|
+
case "json":
|
|
86
|
+
case "blob":
|
|
87
|
+
case "uuid":
|
|
88
|
+
case "timestamp":
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { SqlType } from "./sql-type.js";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Snapshot descriptors — same shape for expected (from metadata) and actual
|
|
5
|
+
// (from introspection). diff() compares two SchemaSnapshots symmetrically.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface SchemaSnapshot {
|
|
9
|
+
tables: TableDescriptor[];
|
|
10
|
+
/** Always empty in v0.1; populated by introspect for v0.3 future-proofing. */
|
|
11
|
+
views: ViewDescriptor[];
|
|
12
|
+
/**
|
|
13
|
+
* Dialect-specific metadata captured at introspect time. Used by emit
|
|
14
|
+
* (e.g., SQLite version → choose native ALTER vs recreate-and-copy fallback).
|
|
15
|
+
*/
|
|
16
|
+
meta?: SnapshotMeta;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SnapshotMeta {
|
|
20
|
+
sqliteVersion?: string; // e.g., "3.44.2"; only set for SQLite snapshots
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TableDescriptor {
|
|
24
|
+
name: string; // resolved db name (snake_case, plural)
|
|
25
|
+
/**
|
|
26
|
+
* DB schema this table lives in. Undefined for SQLite (no schema concept).
|
|
27
|
+
* For Postgres, undefined is normalized to "public" at SnapshotMeta boundaries;
|
|
28
|
+
* the diff and emit layers treat undefined === "public" as equivalent.
|
|
29
|
+
*/
|
|
30
|
+
schema?: string;
|
|
31
|
+
columns: ColumnDescriptor[];
|
|
32
|
+
indexes: IndexDescriptor[];
|
|
33
|
+
foreignKeys: FkDescriptor[];
|
|
34
|
+
primaryKey: string[]; // column names; [] if none
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ColumnDescriptor {
|
|
38
|
+
name: string;
|
|
39
|
+
sqlType: SqlType;
|
|
40
|
+
nullable: boolean;
|
|
41
|
+
default?: ColumnDefault;
|
|
42
|
+
identity?: "increment" | "uuid";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ColumnDefault {
|
|
46
|
+
kind: "literal" | "expr";
|
|
47
|
+
value: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IndexDescriptor {
|
|
51
|
+
name: string;
|
|
52
|
+
columns: string[];
|
|
53
|
+
unique: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FkDescriptor {
|
|
57
|
+
name: string;
|
|
58
|
+
columns: string[];
|
|
59
|
+
refTable: string;
|
|
60
|
+
refColumns: string[];
|
|
61
|
+
onDelete?: FkAction;
|
|
62
|
+
onUpdate?: FkAction;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type FkAction = "cascade" | "set-null" | "restrict" | "no-action";
|
|
66
|
+
|
|
67
|
+
export interface ViewDescriptor {
|
|
68
|
+
name: string;
|
|
69
|
+
/** Same semantics as TableDescriptor.schema. */
|
|
70
|
+
schema?: string;
|
|
71
|
+
// structural fields deferred to v0.3
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Change union — produced by diff(), consumed by emit().
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every variant with a `table: string` (or `table: TableDescriptor`) field also
|
|
80
|
+
* carries an optional `schema?: string`. For the descriptor-bearing variants
|
|
81
|
+
* (create-table, drop-table source is just a name) the schema is redundant with
|
|
82
|
+
* `table.schema` but kept in parallel so the emit layer has a single, uniform
|
|
83
|
+
* place to read schema regardless of variant.
|
|
84
|
+
*
|
|
85
|
+
* For Postgres, undefined is treated as equivalent to "public" by both diff
|
|
86
|
+
* (table identity normalization) and emit (qualified-name suppression).
|
|
87
|
+
* SQLite has no schema concept; the field is always undefined there.
|
|
88
|
+
*/
|
|
89
|
+
export type Change =
|
|
90
|
+
| { kind: "create-table"; table: TableDescriptor; schema?: string; status: ChangeStatus }
|
|
91
|
+
| { kind: "drop-table"; table: string; schema?: string; status: ChangeStatus }
|
|
92
|
+
| { kind: "rename-table"; from: string; to: string; schema?: string; status: ChangeStatus }
|
|
93
|
+
| { kind: "add-column"; table: string; schema?: string; column: ColumnDescriptor; status: ChangeStatus }
|
|
94
|
+
| { kind: "drop-column"; table: string; schema?: string; column: string; status: ChangeStatus }
|
|
95
|
+
| { kind: "rename-column"; table: string; schema?: string; from: string; to: string; status: ChangeStatus }
|
|
96
|
+
| { kind: "change-column-type"; table: string; schema?: string; column: string;
|
|
97
|
+
from: SqlType; to: SqlType; status: ChangeStatus }
|
|
98
|
+
| { kind: "change-column-nullable"; table: string; schema?: string; column: string;
|
|
99
|
+
from: boolean; to: boolean; status: ChangeStatus }
|
|
100
|
+
| { kind: "change-column-default"; table: string; schema?: string; column: string;
|
|
101
|
+
from?: ColumnDefault; to?: ColumnDefault; status: ChangeStatus }
|
|
102
|
+
| { kind: "add-index"; table: string; schema?: string; index: IndexDescriptor; status: ChangeStatus }
|
|
103
|
+
| { kind: "drop-index"; table: string; schema?: string; index: string; status: ChangeStatus }
|
|
104
|
+
| { kind: "add-fk"; table: string; schema?: string; fk: FkDescriptor; status: ChangeStatus }
|
|
105
|
+
| { kind: "drop-fk"; table: string; schema?: string; fk: string; status: ChangeStatus }
|
|
106
|
+
// Declared for v0.3, never produced in v0.1:
|
|
107
|
+
| { kind: "create-view"; view: ViewDescriptor; status: ChangeStatus }
|
|
108
|
+
| { kind: "drop-view"; view: string; status: ChangeStatus }
|
|
109
|
+
| { kind: "replace-view"; view: ViewDescriptor; status: ChangeStatus };
|
|
110
|
+
|
|
111
|
+
export type ChangeKind = Change["kind"];
|
|
112
|
+
|
|
113
|
+
export interface ChangeStatus {
|
|
114
|
+
state: "allowed" | "blocked";
|
|
115
|
+
blockedReason?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// diff() options
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export interface AllowOptions {
|
|
123
|
+
dropColumn?: boolean;
|
|
124
|
+
dropTable?: boolean;
|
|
125
|
+
/** Narrowing/lossy types only; widening always allowed regardless of this flag. */
|
|
126
|
+
typeChange?: boolean;
|
|
127
|
+
dropIndex?: boolean;
|
|
128
|
+
dropFk?: boolean;
|
|
129
|
+
/** Existing data must satisfy NOT NULL; diff cannot verify this. */
|
|
130
|
+
nullableToNotNull?: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type AmbiguousChange =
|
|
134
|
+
| {
|
|
135
|
+
kind: "possible-column-rename";
|
|
136
|
+
table: string;
|
|
137
|
+
from: { name: string; sqlType: SqlType };
|
|
138
|
+
to: { name: string; sqlType: SqlType };
|
|
139
|
+
}
|
|
140
|
+
| {
|
|
141
|
+
kind: "possible-table-rename";
|
|
142
|
+
from: { name: string; columnCount: number };
|
|
143
|
+
to: { name: string; columnCount: number };
|
|
144
|
+
columnOverlap: number; // 0..1 fraction
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type AmbiguousResolution = "rename" | "drop+add" | "abort";
|
|
148
|
+
|
|
149
|
+
export type AmbiguousCallback = (q: AmbiguousChange) => Promise<AmbiguousResolution>;
|
|
150
|
+
|
|
151
|
+
export interface DiffResult {
|
|
152
|
+
changes: Change[];
|
|
153
|
+
/** Subset of `changes` where status.state === "blocked"; convenience for CLI error messaging. */
|
|
154
|
+
blocked: Change[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// emit() result
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface EmitResult {
|
|
162
|
+
up: string;
|
|
163
|
+
down: string;
|
|
164
|
+
/**
|
|
165
|
+
* Tables rebuilt via the SQLite recreate-and-copy pattern. Empty for
|
|
166
|
+
* postgres (in-place ALTER). The CLI uses this to pre-drop only the
|
|
167
|
+
* views whose source tables are being recreated — SQLite's RENAME re-
|
|
168
|
+
* parses dependent view definitions and errors if any reference the
|
|
169
|
+
* mid-recreate source table.
|
|
170
|
+
*/
|
|
171
|
+
recreatedTables: ReadonlySet<string>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type Dialect = "postgres" | "sqlite";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ViewMigrationOpts } from "./view-diff.js";
|
|
2
|
+
|
|
3
|
+
export { type ViewMigrationOpts } from "./view-diff.js";
|
|
4
|
+
|
|
5
|
+
export function emitPostgresViewMigration(opts: ViewMigrationOpts): string {
|
|
6
|
+
switch (opts.diffClass) {
|
|
7
|
+
case "no-change": return "";
|
|
8
|
+
case "safe-append":
|
|
9
|
+
case "safe-replace":
|
|
10
|
+
// Replace CREATE VIEW with CREATE OR REPLACE VIEW.
|
|
11
|
+
return opts.createSql.replace(/^CREATE VIEW\b/i, "CREATE OR REPLACE VIEW");
|
|
12
|
+
case "breaking":
|
|
13
|
+
return `DROP VIEW IF EXISTS ${opts.viewName} CASCADE;\n${opts.createSql}`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ViewMigrationOpts } from "./view-diff.js";
|
|
2
|
+
|
|
3
|
+
export function emitSqliteViewMigration(opts: ViewMigrationOpts): string {
|
|
4
|
+
if (opts.diffClass === "no-change") return "";
|
|
5
|
+
// SQLite has no CREATE OR REPLACE VIEW; must drop+create. Wrap in txn.
|
|
6
|
+
return `BEGIN;\nDROP VIEW IF EXISTS ${opts.viewName};\n${opts.createSql}\nCOMMIT;`;
|
|
7
|
+
}
|
package/src/view-diff.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface ViewShape {
|
|
2
|
+
readonly columns: readonly string[];
|
|
3
|
+
readonly columnTypes?: Readonly<Record<string, string>>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type ViewDiffClass =
|
|
7
|
+
| "no-change"
|
|
8
|
+
| "safe-append"
|
|
9
|
+
| "safe-replace"
|
|
10
|
+
| "breaking";
|
|
11
|
+
|
|
12
|
+
export interface ViewMigrationOpts {
|
|
13
|
+
readonly diffClass: ViewDiffClass;
|
|
14
|
+
readonly viewName: string;
|
|
15
|
+
/** Full `CREATE VIEW <name> AS ...;` statement (semicolon included). */
|
|
16
|
+
readonly createSql: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function classifyViewDiff(prev: ViewShape, next: ViewShape): ViewDiffClass {
|
|
20
|
+
const prevCols = prev.columns;
|
|
21
|
+
const nextCols = next.columns;
|
|
22
|
+
|
|
23
|
+
// Dropped column = breaking.
|
|
24
|
+
for (const c of prevCols) {
|
|
25
|
+
if (!nextCols.includes(c)) return "breaking";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Type change on existing column = breaking.
|
|
29
|
+
if (prev.columnTypes && next.columnTypes) {
|
|
30
|
+
for (const c of prevCols) {
|
|
31
|
+
const pt = prev.columnTypes[c];
|
|
32
|
+
const nt = next.columnTypes[c];
|
|
33
|
+
if (pt && nt && pt !== nt) return "breaking";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (prevCols.length === nextCols.length) {
|
|
38
|
+
// Same length, same set → either identical order or reordered.
|
|
39
|
+
let identical = true;
|
|
40
|
+
for (let i = 0; i < prevCols.length; i++) {
|
|
41
|
+
if (prevCols[i] !== nextCols[i]) { identical = false; break; }
|
|
42
|
+
}
|
|
43
|
+
return identical ? "no-change" : "safe-replace";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Longer next, same prefix? → safe-append.
|
|
47
|
+
let prefixMatches = true;
|
|
48
|
+
for (let i = 0; i < prevCols.length; i++) {
|
|
49
|
+
if (prevCols[i] !== nextCols[i]) { prefixMatches = false; break; }
|
|
50
|
+
}
|
|
51
|
+
if (prefixMatches) return "safe-append";
|
|
52
|
+
|
|
53
|
+
// Mixed: reordered + appended → safe-replace (data shape unchanged at column level).
|
|
54
|
+
return "safe-replace";
|
|
55
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { EmitResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface WriteMigrationOptions {
|
|
6
|
+
/** Migrations root directory (e.g., ".meta/migrations"). Must already exist. */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** Human-readable slug; sanitized to lowercase + hyphens. */
|
|
9
|
+
slug: string;
|
|
10
|
+
/** Override for the timestamp source (UTC). Defaults to new Date(). */
|
|
11
|
+
now?: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WriteMigrationResult {
|
|
15
|
+
/** Absolute path to the per-migration directory created. */
|
|
16
|
+
dir: string;
|
|
17
|
+
/** Path to up.sql. */
|
|
18
|
+
upPath: string;
|
|
19
|
+
/** Path to down.sql. */
|
|
20
|
+
downPath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function writeMigration(
|
|
24
|
+
result: Pick<EmitResult, "up" | "down">,
|
|
25
|
+
opts: WriteMigrationOptions,
|
|
26
|
+
): Promise<WriteMigrationResult> {
|
|
27
|
+
const ts = formatTimestamp(opts.now ?? new Date());
|
|
28
|
+
const slug = sanitizeSlug(opts.slug);
|
|
29
|
+
const migrationDir = join(opts.dir, `${ts}-${slug}`);
|
|
30
|
+
|
|
31
|
+
// Create only the per-migration directory (not the root) — root must exist.
|
|
32
|
+
await mkdir(migrationDir, { recursive: false });
|
|
33
|
+
|
|
34
|
+
const upPath = join(migrationDir, "up.sql");
|
|
35
|
+
const downPath = join(migrationDir, "down.sql");
|
|
36
|
+
await writeFile(upPath, ensureTrailingNewline(result.up), "utf8");
|
|
37
|
+
await writeFile(downPath, ensureTrailingNewline(result.down), "utf8");
|
|
38
|
+
|
|
39
|
+
return { dir: migrationDir, upPath, downPath };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatTimestamp(d: Date): string {
|
|
43
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
44
|
+
return (
|
|
45
|
+
d.getUTCFullYear().toString() +
|
|
46
|
+
pad(d.getUTCMonth() + 1) +
|
|
47
|
+
pad(d.getUTCDate()) +
|
|
48
|
+
pad(d.getUTCHours()) +
|
|
49
|
+
pad(d.getUTCMinutes()) +
|
|
50
|
+
pad(d.getUTCSeconds())
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sanitizeSlug(raw: string): string {
|
|
55
|
+
return raw
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
58
|
+
.replace(/^-+|-+$/g, "")
|
|
59
|
+
.substring(0, 60); // cap length for sane filesystem use
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureTrailingNewline(s: string): string {
|
|
63
|
+
return s.endsWith("\n") ? s : s + "\n";
|
|
64
|
+
}
|