@metaobjectsdev/migrate-ts 0.8.1 → 0.9.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/README.md +1 -3
- package/dist/apply/apply.d.ts +61 -0
- package/dist/apply/apply.d.ts.map +1 -0
- package/dist/apply/apply.js +241 -0
- package/dist/apply/apply.js.map +1 -0
- package/dist/apply/ledger.d.ts +78 -0
- package/dist/apply/ledger.d.ts.map +1 -0
- package/dist/apply/ledger.js +146 -0
- package/dist/apply/ledger.js.map +1 -0
- package/dist/check-expr-compare.d.ts +13 -0
- package/dist/check-expr-compare.d.ts.map +1 -0
- package/dist/check-expr-compare.js +48 -0
- package/dist/check-expr-compare.js.map +1 -0
- package/dist/diff/index.d.ts +3 -1
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +57 -14
- package/dist/diff/index.js.map +1 -1
- package/dist/diff/status.js +3 -0
- package/dist/diff/status.js.map +1 -1
- package/dist/drift/classify.d.ts +16 -0
- package/dist/drift/classify.d.ts.map +1 -0
- package/dist/drift/classify.js +44 -0
- package/dist/drift/classify.js.map +1 -0
- package/dist/drift/drift.d.ts +32 -0
- package/dist/drift/drift.d.ts.map +1 -0
- package/dist/drift/drift.js +36 -0
- package/dist/drift/drift.js.map +1 -0
- package/dist/emit/d1-safety-pass.d.ts.map +1 -1
- package/dist/emit/d1-safety-pass.js +15 -45
- package/dist/emit/d1-safety-pass.js.map +1 -1
- package/dist/emit/postgres.d.ts.map +1 -1
- package/dist/emit/postgres.js +47 -4
- package/dist/emit/postgres.js.map +1 -1
- package/dist/emit/sqlite.d.ts.map +1 -1
- package/dist/emit/sqlite.js +22 -0
- package/dist/emit/sqlite.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -1
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +114 -5
- package/dist/expected-schema.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect/d1.d.ts.map +1 -1
- package/dist/introspect/d1.js +1 -0
- package/dist/introspect/d1.js.map +1 -1
- package/dist/introspect/postgres.d.ts.map +1 -1
- package/dist/introspect/postgres.js +38 -2
- package/dist/introspect/postgres.js.map +1 -1
- package/dist/introspect/sqlite.d.ts.map +1 -1
- package/dist/introspect/sqlite.js +13 -2
- package/dist/introspect/sqlite.js.map +1 -1
- package/dist/snapshot/checksum.d.ts +10 -0
- package/dist/snapshot/checksum.d.ts.map +1 -0
- package/dist/snapshot/checksum.js +14 -0
- package/dist/snapshot/checksum.js.map +1 -0
- package/dist/snapshot/plan.d.ts +25 -0
- package/dist/snapshot/plan.d.ts.map +1 -0
- package/dist/snapshot/plan.js +30 -0
- package/dist/snapshot/plan.js.map +1 -0
- package/dist/snapshot/serialize.d.ts +10 -0
- package/dist/snapshot/serialize.d.ts.map +1 -0
- package/dist/snapshot/serialize.js +63 -0
- package/dist/snapshot/serialize.js.map +1 -0
- package/dist/snapshot/store.d.ts +12 -0
- package/dist/snapshot/store.d.ts.map +1 -0
- package/dist/snapshot/store.js +32 -0
- package/dist/snapshot/store.js.map +1 -0
- package/dist/sql/split-statements.d.ts +12 -0
- package/dist/sql/split-statements.d.ts.map +1 -0
- package/dist/sql/split-statements.js +112 -0
- package/dist/sql/split-statements.js.map +1 -0
- package/dist/sql-type.d.ts +2 -0
- package/dist/sql-type.d.ts.map +1 -1
- package/dist/sql-type.js +2 -0
- package/dist/sql-type.js.map +1 -1
- package/dist/types.d.ts +36 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/verify/replay.d.ts +25 -0
- package/dist/verify/replay.d.ts.map +1 -0
- package/dist/verify/replay.js +25 -0
- package/dist/verify/replay.js.map +1 -0
- package/dist/view-sql-compare.d.ts +8 -0
- package/dist/view-sql-compare.d.ts.map +1 -0
- package/dist/view-sql-compare.js +44 -0
- package/dist/view-sql-compare.js.map +1 -0
- package/package.json +2 -2
- package/src/apply/apply.ts +340 -0
- package/src/apply/ledger.ts +241 -0
- package/src/check-expr-compare.ts +49 -0
- package/src/diff/index.ts +59 -15
- package/src/diff/status.ts +3 -0
- package/src/drift/classify.ts +56 -0
- package/src/drift/drift.ts +66 -0
- package/src/emit/d1-safety-pass.ts +16 -45
- package/src/emit/postgres.ts +47 -4
- package/src/emit/sqlite.ts +22 -0
- package/src/errors.ts +4 -0
- package/src/expected-schema.ts +124 -4
- package/src/index.ts +44 -0
- package/src/introspect/d1.ts +1 -0
- package/src/introspect/postgres.ts +38 -4
- package/src/introspect/sqlite.ts +13 -3
- package/src/snapshot/checksum.ts +15 -0
- package/src/snapshot/plan.ts +53 -0
- package/src/snapshot/serialize.ts +81 -0
- package/src/snapshot/store.ts +33 -0
- package/src/sql/split-statements.ts +115 -0
- package/src/sql-type.ts +3 -0
- package/src/types.ts +26 -9
- package/src/verify/replay.ts +43 -0
- package/src/view-sql-compare.ts +46 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/snapshot/serialize.ts
|
|
2
|
+
import type { SchemaSnapshot } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* On-disk format version for the committed schema snapshot. Bump when the
|
|
6
|
+
* SchemaSnapshot descriptor gains a field (a DDL-coverage feature); add the
|
|
7
|
+
* matching upgrade branch in parseSnapshot at the same time.
|
|
8
|
+
*/
|
|
9
|
+
export const SNAPSHOT_FORMAT_VERSION = 2;
|
|
10
|
+
|
|
11
|
+
interface SnapshotFile {
|
|
12
|
+
formatVersion: number;
|
|
13
|
+
snapshot: SchemaSnapshot;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sortByName<T extends { name: string }>(arr: readonly T[]): T[] {
|
|
17
|
+
return [...arr].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Sort arrays by name so serialization is order-independent. */
|
|
21
|
+
function canonicalize(s: SchemaSnapshot): SchemaSnapshot {
|
|
22
|
+
return {
|
|
23
|
+
tables: sortByName(s.tables).map((t) => ({
|
|
24
|
+
...t,
|
|
25
|
+
columns: sortByName(t.columns),
|
|
26
|
+
indexes: sortByName(t.indexes),
|
|
27
|
+
foreignKeys: sortByName(t.foreignKeys),
|
|
28
|
+
checks: sortByName(t.checks ?? []),
|
|
29
|
+
})),
|
|
30
|
+
views: sortByName(s.views),
|
|
31
|
+
...(s.meta ? { meta: s.meta } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** JSON.stringify with object keys sorted recursively (arrays left as-is). */
|
|
36
|
+
function stableStringify(value: unknown): string {
|
|
37
|
+
return JSON.stringify(
|
|
38
|
+
value,
|
|
39
|
+
(_key, v) => {
|
|
40
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
41
|
+
const obj = v as Record<string, unknown>;
|
|
42
|
+
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
},
|
|
46
|
+
2,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function serializeSnapshot(snapshot: SchemaSnapshot): string {
|
|
51
|
+
const file: SnapshotFile = {
|
|
52
|
+
formatVersion: SNAPSHOT_FORMAT_VERSION,
|
|
53
|
+
snapshot: canonicalize(snapshot),
|
|
54
|
+
};
|
|
55
|
+
return stableStringify(file) + "\n";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseSnapshot(text: string): SchemaSnapshot {
|
|
59
|
+
const file = JSON.parse(text) as SnapshotFile;
|
|
60
|
+
if (typeof file.formatVersion !== "number") {
|
|
61
|
+
throw new Error("snapshot file is missing a numeric 'formatVersion'");
|
|
62
|
+
}
|
|
63
|
+
if (file.formatVersion > SNAPSHOT_FORMAT_VERSION) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`snapshot formatVersion ${file.formatVersion} is newer than supported ` +
|
|
66
|
+
`${SNAPSHOT_FORMAT_VERSION}; upgrade @metaobjectsdev/migrate-ts`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (file.snapshot === null || typeof file.snapshot !== "object") {
|
|
70
|
+
throw new Error("snapshot file is missing a 'snapshot' object");
|
|
71
|
+
}
|
|
72
|
+
if (file.formatVersion < 2) {
|
|
73
|
+
// v1 → v2: the table descriptor gained `checks`; default older snapshots to [].
|
|
74
|
+
for (const t of file.snapshot.tables) {
|
|
75
|
+
if ((t as { checks?: unknown }).checks === undefined) {
|
|
76
|
+
(t as { checks: unknown[] }).checks = [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return file.snapshot;
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/snapshot/store.ts
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { Dialect, SchemaSnapshot } from "../types.js";
|
|
5
|
+
import { parseSnapshot, serializeSnapshot } from "./serialize.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Committed reference-snapshot path for a dialect, e.g.
|
|
9
|
+
* `<migrationsDir>/.schema.postgres.json`. d1 shares sqlite's schema, so both
|
|
10
|
+
* map to `.schema.sqlite.json`.
|
|
11
|
+
*/
|
|
12
|
+
export function snapshotPath(migrationsDir: string, dialect: Dialect): string {
|
|
13
|
+
const d = dialect === "d1" ? "sqlite" : dialect;
|
|
14
|
+
return join(migrationsDir, `.schema.${d}.json`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Read + parse the snapshot, or null if the file is absent. */
|
|
18
|
+
export async function readSnapshot(path: string): Promise<SchemaSnapshot | null> {
|
|
19
|
+
let text: string;
|
|
20
|
+
try {
|
|
21
|
+
text = await readFile(path, "utf8");
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
return parseSnapshot(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Serialize + write the snapshot, creating the parent directory if needed. */
|
|
30
|
+
export async function writeSnapshot(path: string, snapshot: SchemaSnapshot): Promise<void> {
|
|
31
|
+
await mkdir(dirname(path), { recursive: true });
|
|
32
|
+
await writeFile(path, serializeSnapshot(snapshot), "utf8");
|
|
33
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a SQL script into its top-level statements on `;` boundaries, ignoring
|
|
3
|
+
* separators that fall inside string literals, quoted identifiers, comments, or
|
|
4
|
+
* dollar-quoted blocks. The single source of truth for statement splitting —
|
|
5
|
+
* used both when applying migration files ({@link ../apply/apply.ts}) and when
|
|
6
|
+
* post-processing emitted DDL for the D1 target ({@link ../emit/d1-safety-pass.ts}).
|
|
7
|
+
*
|
|
8
|
+
* Returned statements are trimmed and DO NOT include the trailing `;`
|
|
9
|
+
* separator; callers that need a terminator re-add one.
|
|
10
|
+
*/
|
|
11
|
+
export function splitSqlStatements(text: string): string[] {
|
|
12
|
+
const statements: string[] = [];
|
|
13
|
+
let start = 0;
|
|
14
|
+
let i = 0;
|
|
15
|
+
const n = text.length;
|
|
16
|
+
|
|
17
|
+
while (i < n) {
|
|
18
|
+
const ch = text[i];
|
|
19
|
+
const next = text[i + 1];
|
|
20
|
+
|
|
21
|
+
// -- line comment: skip to end of line (or input).
|
|
22
|
+
if (ch === "-" && next === "-") {
|
|
23
|
+
i += 2;
|
|
24
|
+
while (i < n && text[i] !== "\n") i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// /* block comment */: skip to closing delimiter (or input).
|
|
29
|
+
if (ch === "/" && next === "*") {
|
|
30
|
+
i += 2;
|
|
31
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
32
|
+
i += 2; // consume the closing */ (clamped by the while below)
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Single-quoted literal: consume to the closing quote, treating '' as escape.
|
|
37
|
+
if (ch === "'") {
|
|
38
|
+
i++;
|
|
39
|
+
while (i < n) {
|
|
40
|
+
if (text[i] === "'" && text[i + 1] === "'") {
|
|
41
|
+
i += 2; // embedded ''
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (text[i] === "'") {
|
|
45
|
+
i++;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Double-quoted identifier: consume to the closing quote, treating "" as escape.
|
|
54
|
+
if (ch === '"') {
|
|
55
|
+
i++;
|
|
56
|
+
while (i < n) {
|
|
57
|
+
if (text[i] === '"' && text[i + 1] === '"') {
|
|
58
|
+
i += 2; // embedded ""
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (text[i] === '"') {
|
|
62
|
+
i++;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Dollar-quoted string: $tag$ … $tag$ with tag = [A-Za-z0-9_]*.
|
|
71
|
+
if (ch === "$") {
|
|
72
|
+
const open = matchDollarTag(text, i);
|
|
73
|
+
if (open !== null) {
|
|
74
|
+
const tag = text.slice(i, open); // includes both $ delimiters
|
|
75
|
+
i = open;
|
|
76
|
+
// Scan for the matching closing tag.
|
|
77
|
+
while (i < n) {
|
|
78
|
+
if (text[i] === "$" && text.startsWith(tag, i)) {
|
|
79
|
+
i += tag.length;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Statement separator (only reached when in no special state).
|
|
89
|
+
if (ch === ";") {
|
|
90
|
+
const stmt = text.slice(start, i).trim();
|
|
91
|
+
if (stmt.length > 0) statements.push(stmt);
|
|
92
|
+
i++;
|
|
93
|
+
start = i;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tail = text.slice(start).trim();
|
|
101
|
+
if (tail.length > 0) statements.push(tail);
|
|
102
|
+
return statements;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* If a dollar-quote tag opens at `pos` (text[pos] === "$"), return the index
|
|
107
|
+
* just past the opening `$tag$` delimiter; otherwise null. The tag body is
|
|
108
|
+
* `[A-Za-z0-9_]*` and must be terminated by a second `$`.
|
|
109
|
+
*/
|
|
110
|
+
function matchDollarTag(text: string, pos: number): number | null {
|
|
111
|
+
let j = pos + 1;
|
|
112
|
+
while (j < text.length && /[A-Za-z0-9_]/.test(text[j] as string)) j++;
|
|
113
|
+
if (text[j] === "$") return j + 1;
|
|
114
|
+
return null;
|
|
115
|
+
}
|
package/src/sql-type.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type SqlType =
|
|
|
14
14
|
| { kind: "boolean" }
|
|
15
15
|
| { kind: "timestamp"; withTimezone: boolean }
|
|
16
16
|
| { kind: "date" }
|
|
17
|
+
| { kind: "time" }
|
|
17
18
|
| { kind: "json" }
|
|
18
19
|
| { kind: "blob" }
|
|
19
20
|
| { kind: "uuid" };
|
|
@@ -36,6 +37,7 @@ export function sqlTypeEquals(a: SqlType, b: SqlType): boolean {
|
|
|
36
37
|
case "real4":
|
|
37
38
|
case "boolean":
|
|
38
39
|
case "date":
|
|
40
|
+
case "time":
|
|
39
41
|
case "json":
|
|
40
42
|
case "blob":
|
|
41
43
|
case "uuid":
|
|
@@ -85,6 +87,7 @@ export function isWidening(from: SqlType, to: SqlType): boolean {
|
|
|
85
87
|
case "real4":
|
|
86
88
|
case "boolean":
|
|
87
89
|
case "date":
|
|
90
|
+
case "time":
|
|
88
91
|
case "json":
|
|
89
92
|
case "blob":
|
|
90
93
|
case "uuid":
|
package/src/types.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface TableDescriptor {
|
|
|
31
31
|
columns: ColumnDescriptor[];
|
|
32
32
|
indexes: IndexDescriptor[];
|
|
33
33
|
foreignKeys: FkDescriptor[];
|
|
34
|
+
checks: CheckDescriptor[];
|
|
34
35
|
primaryKey: string[]; // column names; [] if none
|
|
35
36
|
/**
|
|
36
37
|
* Human-readable description threaded from entity `@description`.
|
|
@@ -67,6 +68,13 @@ export interface IndexDescriptor {
|
|
|
67
68
|
unique: boolean;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export interface CheckDescriptor {
|
|
72
|
+
/** Constraint name, e.g. `<table>_<column>_chk`. Diff/identity key. */
|
|
73
|
+
name: string;
|
|
74
|
+
/** The boolean SQL expression, e.g. `status IN ('OPEN','CLOSED')`. */
|
|
75
|
+
expression: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
export interface FkDescriptor {
|
|
71
79
|
name: string;
|
|
72
80
|
columns: string[];
|
|
@@ -83,11 +91,17 @@ export interface ViewDescriptor {
|
|
|
83
91
|
/** Same semantics as TableDescriptor.schema. */
|
|
84
92
|
schema?: string;
|
|
85
93
|
/**
|
|
86
|
-
* View
|
|
87
|
-
*
|
|
88
|
-
* `buildExpectedSchema`
|
|
89
|
-
*
|
|
90
|
-
*
|
|
94
|
+
* View definition SQL.
|
|
95
|
+
*
|
|
96
|
+
* On the EXPECTED side (`buildExpectedSchema` / `buildExpectedViews`) this is
|
|
97
|
+
* the view body — the SELECT clause through the FROM/WHERE/GROUP-BY tail.
|
|
98
|
+
*
|
|
99
|
+
* On the ACTUAL side (`introspect`) this is whatever the DB catalog stores:
|
|
100
|
+
* sqlite's `sqlite_master.sql` is the full `CREATE VIEW <name> AS <body>`
|
|
101
|
+
* statement, while Postgres' `information_schema.views.view_definition` is the
|
|
102
|
+
* body only. `diff`'s view-body comparator normalizes both sides (strips any
|
|
103
|
+
* leading `CREATE VIEW ... AS`, collapses whitespace) before comparing, so a
|
|
104
|
+
* body change triggers a `replace-view`.
|
|
91
105
|
*/
|
|
92
106
|
sql?: string;
|
|
93
107
|
}
|
|
@@ -109,10 +123,10 @@ export interface ViewDescriptor {
|
|
|
109
123
|
*/
|
|
110
124
|
export type Change =
|
|
111
125
|
| { kind: "create-table"; table: TableDescriptor; schema?: string; status: ChangeStatus }
|
|
112
|
-
| { kind: "drop-table"; table: string; schema?: string; status: ChangeStatus }
|
|
126
|
+
| { kind: "drop-table"; table: string; schema?: string; restore?: TableDescriptor; status: ChangeStatus }
|
|
113
127
|
| { kind: "rename-table"; from: string; to: string; schema?: string; status: ChangeStatus }
|
|
114
128
|
| { kind: "add-column"; table: string; schema?: string; column: ColumnDescriptor; status: ChangeStatus }
|
|
115
|
-
| { kind: "drop-column"; table: string; schema?: string; column: string; status: ChangeStatus }
|
|
129
|
+
| { kind: "drop-column"; table: string; schema?: string; column: string; restore?: ColumnDescriptor; status: ChangeStatus }
|
|
116
130
|
| { kind: "rename-column"; table: string; schema?: string; from: string; to: string; status: ChangeStatus }
|
|
117
131
|
| { kind: "change-column-type"; table: string; schema?: string; column: string;
|
|
118
132
|
from: SqlType; to: SqlType; status: ChangeStatus }
|
|
@@ -121,9 +135,11 @@ export type Change =
|
|
|
121
135
|
| { kind: "change-column-default"; table: string; schema?: string; column: string;
|
|
122
136
|
from?: ColumnDefault; to?: ColumnDefault; status: ChangeStatus }
|
|
123
137
|
| { kind: "add-index"; table: string; schema?: string; index: IndexDescriptor; status: ChangeStatus }
|
|
124
|
-
| { kind: "drop-index"; table: string; schema?: string; index: string; status: ChangeStatus }
|
|
138
|
+
| { kind: "drop-index"; table: string; schema?: string; index: string; restore?: IndexDescriptor; status: ChangeStatus }
|
|
125
139
|
| { kind: "add-fk"; table: string; schema?: string; fk: FkDescriptor; status: ChangeStatus }
|
|
126
|
-
| { kind: "drop-fk"; table: string; schema?: string; fk: string; status: ChangeStatus }
|
|
140
|
+
| { kind: "drop-fk"; table: string; schema?: string; fk: string; restore?: FkDescriptor; status: ChangeStatus }
|
|
141
|
+
| { kind: "add-check"; table: string; schema?: string; check: CheckDescriptor; status: ChangeStatus }
|
|
142
|
+
| { kind: "drop-check"; table: string; schema?: string; check: string; restore?: CheckDescriptor; status: ChangeStatus }
|
|
127
143
|
// Declared for v0.3, never produced in v0.1:
|
|
128
144
|
| { kind: "create-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus }
|
|
129
145
|
| { kind: "drop-view"; view: string; schema?: string; status: ChangeStatus }
|
|
@@ -147,6 +163,7 @@ export interface AllowOptions {
|
|
|
147
163
|
typeChange?: boolean;
|
|
148
164
|
dropIndex?: boolean;
|
|
149
165
|
dropFk?: boolean;
|
|
166
|
+
dropCheck?: boolean;
|
|
150
167
|
/** Existing data must satisfy NOT NULL; diff cannot verify this. */
|
|
151
168
|
nullableToNotNull?: boolean;
|
|
152
169
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/verify/replay.ts
|
|
2
|
+
import type { Kysely } from "kysely";
|
|
3
|
+
import { applyPending } from "../apply/apply.js";
|
|
4
|
+
import { MIGRATIONS_TABLE } from "../apply/ledger.js";
|
|
5
|
+
import { introspect } from "../introspect/index.js";
|
|
6
|
+
import { driftAgainstSnapshot, type DriftClassification } from "../drift/classify.js";
|
|
7
|
+
import type { Dialect, SchemaSnapshot } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export interface VerifyReplayArgs {
|
|
10
|
+
/** A FRESH, throwaway database. Replay applies every migration into it from empty. */
|
|
11
|
+
db: Kysely<Record<string, unknown>>;
|
|
12
|
+
dialect: Extract<Dialect, "postgres" | "sqlite">;
|
|
13
|
+
/** Directory holding the committed `<timestamp>-<slug>/up.sql` migrations. */
|
|
14
|
+
migrationsDir: string;
|
|
15
|
+
/** The committed snapshot the migrations are expected to reproduce. */
|
|
16
|
+
snapshot: SchemaSnapshot;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VerifyReplayResult extends DriftClassification {
|
|
20
|
+
/** True when the replayed schema matches the snapshot (no drift, no unmanaged). */
|
|
21
|
+
ok: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Replay all committed migrations into a fresh database, introspect the result,
|
|
26
|
+
* and compare it to the committed snapshot. A non-empty `drift`/`unmanaged` means
|
|
27
|
+
* the migrations-as-applied diverge from the snapshot — e.g. a hand-edited up.sql
|
|
28
|
+
* that changed structure the metadata-derived snapshot doesn't know about. The
|
|
29
|
+
* ledger sidecar table is excluded from the comparison.
|
|
30
|
+
*/
|
|
31
|
+
export async function verifyReplay(args: VerifyReplayArgs): Promise<VerifyReplayResult> {
|
|
32
|
+
await applyPending(args.db, args.migrationsDir, { dryRun: false, dialect: args.dialect });
|
|
33
|
+
const introspected = await introspect(args.db, args.dialect);
|
|
34
|
+
const actual: SchemaSnapshot = {
|
|
35
|
+
...introspected,
|
|
36
|
+
tables: introspected.tables.filter((t) => t.name !== MIGRATIONS_TABLE),
|
|
37
|
+
};
|
|
38
|
+
const classification = await driftAgainstSnapshot(args.snapshot, actual, args.dialect);
|
|
39
|
+
return {
|
|
40
|
+
...classification,
|
|
41
|
+
ok: classification.drift.length === 0 && classification.unmanaged.length === 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// view-sql-compare.ts — the single shared comparator for view definition SQL.
|
|
2
|
+
//
|
|
3
|
+
// View definition SQL arrives in two shapes across the pipeline:
|
|
4
|
+
// - EXPECTED (buildExpectedViews): the body only — `SELECT ... FROM ...`.
|
|
5
|
+
// - ACTUAL (introspect): sqlite's sqlite_master.sql is the full
|
|
6
|
+
// `CREATE VIEW <name> AS <body>`; Postgres' view_definition is body-only.
|
|
7
|
+
// - The CLI's readExistingViewSql synthesizes `CREATE VIEW <name> AS <body>`
|
|
8
|
+
// for Postgres so it matches what emitViewDdl produces.
|
|
9
|
+
//
|
|
10
|
+
// normalizeViewSql reduces any of these to a comparable canonical form so the
|
|
11
|
+
// diff (expected vs introspected) and the CLI (emitted DDL vs existing DDL) use
|
|
12
|
+
// ONE comparison. It strips a leading `CREATE [OR REPLACE] VIEW <name> AS`,
|
|
13
|
+
// collapses runs of whitespace to a single space, drops a trailing `;`, and
|
|
14
|
+
// lower-cases — view-body drift should be classified by structure, not by
|
|
15
|
+
// incidental whitespace/case/wrapper differences.
|
|
16
|
+
//
|
|
17
|
+
// CAVEAT (accepted tradeoff): lower-casing makes keyword/identifier comparison
|
|
18
|
+
// case-insensitive but can mask a difference that lives ONLY in a case-sensitive
|
|
19
|
+
// string literal in the body (e.g. `WHERE status = 'Active'` vs `'active'`) —
|
|
20
|
+
// such a change would NOT be flagged as drift. Acceptable for generated
|
|
21
|
+
// aggregate/passthrough projections (no literals); revisit if hand-authored
|
|
22
|
+
// views with case-sensitive literals become a drift concern. The name regex
|
|
23
|
+
// matches one whitespace/`(`-free token, so a quoted view name containing a
|
|
24
|
+
// space would not strip cleanly — also a non-issue for generated identifiers.
|
|
25
|
+
|
|
26
|
+
const CREATE_VIEW_PREFIX =
|
|
27
|
+
/^\s*create\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?view\s+(?:if\s+not\s+exists\s+)?[^\s(]+(?:\s*\([^)]*\))?\s+as\s+/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collapse a view definition (full `CREATE VIEW ... AS body` OR a bare body)
|
|
31
|
+
* to a canonical, comparable string. Whitespace-, case-, and wrapper-insensitive.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeViewSql(sql: string): string {
|
|
34
|
+
return sql
|
|
35
|
+
.replace(CREATE_VIEW_PREFIX, "")
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.replace(/;\s*$/, "")
|
|
38
|
+
.trim()
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when two view definitions are equivalent after normalization. */
|
|
43
|
+
export function viewSqlEquals(a: string | undefined, b: string | undefined): boolean {
|
|
44
|
+
if (a === undefined || b === undefined) return false;
|
|
45
|
+
return normalizeViewSql(a) === normalizeViewSql(b);
|
|
46
|
+
}
|