@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.
Files changed (98) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +73 -0
  3. package/dist/diff/index.d.ts +30 -0
  4. package/dist/diff/index.d.ts.map +1 -0
  5. package/dist/diff/index.js +226 -0
  6. package/dist/diff/index.js.map +1 -0
  7. package/dist/diff/rename-heuristic.d.ts +23 -0
  8. package/dist/diff/rename-heuristic.d.ts.map +1 -0
  9. package/dist/diff/rename-heuristic.js +236 -0
  10. package/dist/diff/rename-heuristic.js.map +1 -0
  11. package/dist/diff/status.d.ts +8 -0
  12. package/dist/diff/status.d.ts.map +1 -0
  13. package/dist/diff/status.js +53 -0
  14. package/dist/diff/status.js.map +1 -0
  15. package/dist/emit/index.d.ts +17 -0
  16. package/dist/emit/index.d.ts.map +1 -0
  17. package/dist/emit/index.js +18 -0
  18. package/dist/emit/index.js.map +1 -0
  19. package/dist/emit/postgres.d.ts +3 -0
  20. package/dist/emit/postgres.d.ts.map +1 -0
  21. package/dist/emit/postgres.js +181 -0
  22. package/dist/emit/postgres.js.map +1 -0
  23. package/dist/emit/sqlite.d.ts +3 -0
  24. package/dist/emit/sqlite.d.ts.map +1 -0
  25. package/dist/emit/sqlite.js +302 -0
  26. package/dist/emit/sqlite.js.map +1 -0
  27. package/dist/errors.d.ts +8 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +54 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/expected-schema.d.ts +15 -0
  32. package/dist/expected-schema.d.ts.map +1 -0
  33. package/dist/expected-schema.js +243 -0
  34. package/dist/expected-schema.js.map +1 -0
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +25 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/introspect/index.d.ts +6 -0
  40. package/dist/introspect/index.d.ts.map +1 -0
  41. package/dist/introspect/index.js +11 -0
  42. package/dist/introspect/index.js.map +1 -0
  43. package/dist/introspect/postgres.d.ts +57 -0
  44. package/dist/introspect/postgres.d.ts.map +1 -0
  45. package/dist/introspect/postgres.js +339 -0
  46. package/dist/introspect/postgres.js.map +1 -0
  47. package/dist/introspect/sqlite.d.ts +4 -0
  48. package/dist/introspect/sqlite.d.ts.map +1 -0
  49. package/dist/introspect/sqlite.js +192 -0
  50. package/dist/introspect/sqlite.js.map +1 -0
  51. package/dist/source-aware-diff.d.ts +20 -0
  52. package/dist/source-aware-diff.d.ts.map +1 -0
  53. package/dist/source-aware-diff.js +24 -0
  54. package/dist/source-aware-diff.js.map +1 -0
  55. package/dist/sql-type.d.ts +45 -0
  56. package/dist/sql-type.d.ts.map +1 -0
  57. package/dist/sql-type.js +76 -0
  58. package/dist/sql-type.js.map +1 -0
  59. package/dist/types.d.ts +223 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/view-ddl-postgres.d.ts +4 -0
  64. package/dist/view-ddl-postgres.d.ts.map +1 -0
  65. package/dist/view-ddl-postgres.js +13 -0
  66. package/dist/view-ddl-postgres.js.map +1 -0
  67. package/dist/view-ddl-sqlite.d.ts +3 -0
  68. package/dist/view-ddl-sqlite.d.ts.map +1 -0
  69. package/dist/view-ddl-sqlite.js +7 -0
  70. package/dist/view-ddl-sqlite.js.map +1 -0
  71. package/dist/view-diff.d.ts +13 -0
  72. package/dist/view-diff.d.ts.map +1 -0
  73. package/dist/view-diff.js +42 -0
  74. package/dist/view-diff.js.map +1 -0
  75. package/dist/write-migration.d.ts +19 -0
  76. package/dist/write-migration.d.ts.map +1 -0
  77. package/dist/write-migration.js +34 -0
  78. package/dist/write-migration.js.map +1 -0
  79. package/package.json +50 -0
  80. package/src/diff/index.ts +294 -0
  81. package/src/diff/rename-heuristic.ts +265 -0
  82. package/src/diff/status.ts +55 -0
  83. package/src/emit/index.ts +38 -0
  84. package/src/emit/postgres.ts +189 -0
  85. package/src/emit/sqlite.ts +322 -0
  86. package/src/errors.ts +58 -0
  87. package/src/expected-schema.ts +326 -0
  88. package/src/index.ts +49 -0
  89. package/src/introspect/index.ts +14 -0
  90. package/src/introspect/postgres.ts +428 -0
  91. package/src/introspect/sqlite.ts +216 -0
  92. package/src/source-aware-diff.ts +49 -0
  93. package/src/sql-type.ts +91 -0
  94. package/src/types.ts +174 -0
  95. package/src/view-ddl-postgres.ts +15 -0
  96. package/src/view-ddl-sqlite.ts +7 -0
  97. package/src/view-diff.ts +55 -0
  98. package/src/write-migration.ts +64 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-ddl-sqlite.d.ts","sourceRoot":"","sources":["../src/view-ddl-sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAExD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAIvE"}
@@ -0,0 +1,7 @@
1
+ export function emitSqliteViewMigration(opts) {
2
+ if (opts.diffClass === "no-change")
3
+ return "";
4
+ // SQLite has no CREATE OR REPLACE VIEW; must drop+create. Wrap in txn.
5
+ return `BEGIN;\nDROP VIEW IF EXISTS ${opts.viewName};\n${opts.createSql}\nCOMMIT;`;
6
+ }
7
+ //# sourceMappingURL=view-ddl-sqlite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-ddl-sqlite.js","sourceRoot":"","sources":["../src/view-ddl-sqlite.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,uBAAuB,CAAC,IAAuB;IAC7D,IAAI,IAAI,CAAC,SAAS,KAAK,WAAW;QAAE,OAAO,EAAE,CAAC;IAC9C,uEAAuE;IACvE,OAAO,+BAA+B,IAAI,CAAC,QAAQ,MAAM,IAAI,CAAC,SAAS,WAAW,CAAC;AACrF,CAAC"}
@@ -0,0 +1,13 @@
1
+ export interface ViewShape {
2
+ readonly columns: readonly string[];
3
+ readonly columnTypes?: Readonly<Record<string, string>>;
4
+ }
5
+ export type ViewDiffClass = "no-change" | "safe-append" | "safe-replace" | "breaking";
6
+ export interface ViewMigrationOpts {
7
+ readonly diffClass: ViewDiffClass;
8
+ readonly viewName: string;
9
+ /** Full `CREATE VIEW <name> AS ...;` statement (semicolon included). */
10
+ readonly createSql: string;
11
+ }
12
+ export declare function classifyViewDiff(prev: ViewShape, next: ViewShape): ViewDiffClass;
13
+ //# sourceMappingURL=view-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-diff.d.ts","sourceRoot":"","sources":["../src/view-diff.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACzD;AAED,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,aAAa,GACb,cAAc,GACd,UAAU,CAAC;AAEf,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,GAAG,aAAa,CAoChF"}
@@ -0,0 +1,42 @@
1
+ export function classifyViewDiff(prev, next) {
2
+ const prevCols = prev.columns;
3
+ const nextCols = next.columns;
4
+ // Dropped column = breaking.
5
+ for (const c of prevCols) {
6
+ if (!nextCols.includes(c))
7
+ return "breaking";
8
+ }
9
+ // Type change on existing column = breaking.
10
+ if (prev.columnTypes && next.columnTypes) {
11
+ for (const c of prevCols) {
12
+ const pt = prev.columnTypes[c];
13
+ const nt = next.columnTypes[c];
14
+ if (pt && nt && pt !== nt)
15
+ return "breaking";
16
+ }
17
+ }
18
+ if (prevCols.length === nextCols.length) {
19
+ // Same length, same set → either identical order or reordered.
20
+ let identical = true;
21
+ for (let i = 0; i < prevCols.length; i++) {
22
+ if (prevCols[i] !== nextCols[i]) {
23
+ identical = false;
24
+ break;
25
+ }
26
+ }
27
+ return identical ? "no-change" : "safe-replace";
28
+ }
29
+ // Longer next, same prefix? → safe-append.
30
+ let prefixMatches = true;
31
+ for (let i = 0; i < prevCols.length; i++) {
32
+ if (prevCols[i] !== nextCols[i]) {
33
+ prefixMatches = false;
34
+ break;
35
+ }
36
+ }
37
+ if (prefixMatches)
38
+ return "safe-append";
39
+ // Mixed: reordered + appended → safe-replace (data shape unchanged at column level).
40
+ return "safe-replace";
41
+ }
42
+ //# sourceMappingURL=view-diff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-diff.js","sourceRoot":"","sources":["../src/view-diff.ts"],"names":[],"mappings":"AAkBA,MAAM,UAAU,gBAAgB,CAAC,IAAe,EAAE,IAAe;IAC/D,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC;IAC9B,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC;IAE9B,6BAA6B;IAC7B,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QACzB,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,OAAO,UAAU,CAAC;IAC/C,CAAC;IAED,6CAA6C;IAC7C,IAAI,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;QACzC,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;YACzB,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;YAC/B,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;YAC/B,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE;gBAAE,OAAO,UAAU,CAAC;QAC/C,CAAC;IACH,CAAC;IAED,IAAI,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,EAAE,CAAC;QACxC,+DAA+D;QAC/D,IAAI,SAAS,GAAG,IAAI,CAAC;QACrB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACzC,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;gBAAC,SAAS,GAAG,KAAK,CAAC;gBAAC,MAAM;YAAC,CAAC;QAChE,CAAC;QACD,OAAO,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,cAAc,CAAC;IAClD,CAAC;IAED,2CAA2C;IAC3C,IAAI,aAAa,GAAG,IAAI,CAAC;IACzB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACzC,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YAAC,aAAa,GAAG,KAAK,CAAC;YAAC,MAAM;QAAC,CAAC;IACpE,CAAC;IACD,IAAI,aAAa;QAAE,OAAO,aAAa,CAAC;IAExC,qFAAqF;IACrF,OAAO,cAAc,CAAC;AACxB,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { EmitResult } from "./types.js";
2
+ export interface WriteMigrationOptions {
3
+ /** Migrations root directory (e.g., ".meta/migrations"). Must already exist. */
4
+ dir: string;
5
+ /** Human-readable slug; sanitized to lowercase + hyphens. */
6
+ slug: string;
7
+ /** Override for the timestamp source (UTC). Defaults to new Date(). */
8
+ now?: Date;
9
+ }
10
+ export interface WriteMigrationResult {
11
+ /** Absolute path to the per-migration directory created. */
12
+ dir: string;
13
+ /** Path to up.sql. */
14
+ upPath: string;
15
+ /** Path to down.sql. */
16
+ downPath: string;
17
+ }
18
+ export declare function writeMigration(result: Pick<EmitResult, "up" | "down">, opts: WriteMigrationOptions): Promise<WriteMigrationResult>;
19
+ //# sourceMappingURL=write-migration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-migration.d.ts","sourceRoot":"","sources":["../src/write-migration.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAC;IACZ,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,wBAAwB;IACxB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,cAAc,CAClC,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM,CAAC,EACvC,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
@@ -0,0 +1,34 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function writeMigration(result, opts) {
4
+ const ts = formatTimestamp(opts.now ?? new Date());
5
+ const slug = sanitizeSlug(opts.slug);
6
+ const migrationDir = join(opts.dir, `${ts}-${slug}`);
7
+ // Create only the per-migration directory (not the root) — root must exist.
8
+ await mkdir(migrationDir, { recursive: false });
9
+ const upPath = join(migrationDir, "up.sql");
10
+ const downPath = join(migrationDir, "down.sql");
11
+ await writeFile(upPath, ensureTrailingNewline(result.up), "utf8");
12
+ await writeFile(downPath, ensureTrailingNewline(result.down), "utf8");
13
+ return { dir: migrationDir, upPath, downPath };
14
+ }
15
+ function formatTimestamp(d) {
16
+ const pad = (n) => n.toString().padStart(2, "0");
17
+ return (d.getUTCFullYear().toString() +
18
+ pad(d.getUTCMonth() + 1) +
19
+ pad(d.getUTCDate()) +
20
+ pad(d.getUTCHours()) +
21
+ pad(d.getUTCMinutes()) +
22
+ pad(d.getUTCSeconds()));
23
+ }
24
+ function sanitizeSlug(raw) {
25
+ return raw
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, "-")
28
+ .replace(/^-+|-+$/g, "")
29
+ .substring(0, 60); // cap length for sane filesystem use
30
+ }
31
+ function ensureTrailingNewline(s) {
32
+ return s.endsWith("\n") ? s : s + "\n";
33
+ }
34
+ //# sourceMappingURL=write-migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-migration.js","sourceRoot":"","sources":["../src/write-migration.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAqBjC,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,MAAuC,EACvC,IAA2B;IAE3B,MAAM,EAAE,GAAG,eAAe,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,CAAC;IACnD,MAAM,IAAI,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrC,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;IAErD,4EAA4E;IAC5E,MAAM,KAAK,CAAC,YAAY,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;IAEhD,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;IAC5C,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAChD,MAAM,SAAS,CAAC,MAAM,EAAE,qBAAqB,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC;IAClE,MAAM,SAAS,CAAC,QAAQ,EAAE,qBAAqB,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;IAEtE,OAAO,EAAE,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;AACjD,CAAC;AAED,SAAS,eAAe,CAAC,CAAO;IAC9B,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACzD,OAAO,CACL,CAAC,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE;QAC7B,GAAG,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;QACxB,GAAG,CAAC,CAAC,CAAC,UAAU,EAAE,CAAC;QACnB,GAAG,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;QACpB,GAAG,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;QACtB,GAAG,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC,CACvB,CAAC;AACJ,CAAC;AAED,SAAS,YAAY,CAAC,GAAW;IAC/B,OAAO,GAAG;SACP,WAAW,EAAE;SACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;SACvB,SAAS,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAgB,qCAAqC;AAC3E,CAAC;AAED,SAAS,qBAAqB,CAAC,CAAS;IACtC,OAAO,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;AACzC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@metaobjectsdev/migrate-ts",
3
+ "version": "0.5.0-rc.1",
4
+ "description": "Schema migration tooling for MetaObjects: diff metadata vs DB and emit SQL for Postgres and SQLite.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "bun": "./src/index.ts",
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "src", "README.md", "LICENSE"],
16
+ "scripts": {
17
+ "build": "tsc -p .",
18
+ "typecheck": "tsc -p tsconfig.typecheck.json"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "author": "Doug Mealing <doug@dougmealing.com>",
22
+ "homepage": "https://metaobjects.dev",
23
+ "bugs": {
24
+ "url": "https://github.com/metaobjectsdev/metaobjects/issues"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/metaobjectsdev/metaobjects.git",
29
+ "directory": "server/typescript/packages/migrate-ts"
30
+ },
31
+ "keywords": ["metaobjects", "migrate", "postgres", "sqlite", "schema-diff"],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@metaobjectsdev/metadata": "0.5.0"
37
+ },
38
+ "peerDependencies": {
39
+ "kysely": ">=0.27.0"
40
+ },
41
+ "devDependencies": {
42
+ "@libsql/kysely-libsql": "^0.4.1",
43
+ "@types/pg": "^8.20.0",
44
+ "bun-types": "latest",
45
+ "kysely": "^0.27.0",
46
+ "pg": "^8.20.0",
47
+ "pg-mem": "^3.0.4",
48
+ "typescript": "^5.6.0"
49
+ }
50
+ }
@@ -0,0 +1,294 @@
1
+ import type {
2
+ SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
3
+ Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback,
4
+ } from "../types.js";
5
+ import type { SqlType } from "../sql-type.js";
6
+ import { sqlTypeEquals } from "../sql-type.js";
7
+ import { applyStatus } from "./status.js";
8
+ import { detectColumnRenames, detectTableRenames } from "./rename-heuristic.js";
9
+ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
10
+
11
+ export interface DiffArgs {
12
+ expected: SchemaSnapshot;
13
+ actual: SchemaSnapshot;
14
+ allow?: AllowOptions;
15
+ onAmbiguous?: AmbiguousCallback;
16
+ /**
17
+ * Table-name patterns to ignore on both sides of the diff. Tables matching
18
+ * any pattern are excluded from comparison — neither create-table nor
19
+ * drop-table changes are emitted for them, and they're omitted from index/
20
+ * fk passes. Supports exact names and `*` glob wildcards.
21
+ *
22
+ * Defaults to [`__drizzle_migrations`] when omitted so the Drizzle migration-
23
+ * tracking table doesn't surface as a drop. Pass `[]` explicitly to disable
24
+ * the default. Pass additional patterns to extend.
25
+ */
26
+ ignoreTables?: string[];
27
+ }
28
+
29
+ const ALLOWED: ChangeStatus = { state: "allowed" };
30
+
31
+ /**
32
+ * Default ignore-table patterns. Catches migration-tracking and replication
33
+ * sidecar tables that downstream tools (Drizzle, litestream) create automatically.
34
+ */
35
+ const DEFAULT_IGNORE_TABLES: string[] = [
36
+ "__drizzle_migrations",
37
+ "_litestream_*",
38
+ ];
39
+
40
+ /**
41
+ * Normalize undefined schema to "public" (Postgres default) for comparison purposes.
42
+ * Allows snapshots from buildExpectedSchema (often undefined) to compare equal to
43
+ * snapshots from introspect (always populated for Postgres).
44
+ *
45
+ * For SQLite (no schema concept), every table has schema=undefined, so this maps
46
+ * all tables to the same "public." prefix — harmless and preserves existing behavior.
47
+ */
48
+ function tableIdentity(table: { name: string; schema?: string }): string {
49
+ return (table.schema ?? DEFAULT_DB_SCHEMA_POSTGRES) + "." + table.name;
50
+ }
51
+
52
+ /**
53
+ * Build the optional-schema spread used when constructing Change records.
54
+ * Required because `exactOptionalPropertyTypes: true` rejects explicit `undefined`
55
+ * for an optional field — we either include the key or we don't.
56
+ */
57
+ function schemaSpread(schema: string | undefined): { schema?: string } {
58
+ return schema !== undefined ? { schema } : {};
59
+ }
60
+
61
+ function tableMatchesPattern(name: string, pattern: string): boolean {
62
+ if (pattern.includes("*")) {
63
+ const regex = new RegExp(
64
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
65
+ );
66
+ return regex.test(name);
67
+ }
68
+ return name === pattern;
69
+ }
70
+
71
+ function shouldIgnoreTable(name: string, patterns: string[]): boolean {
72
+ return patterns.some((p) => tableMatchesPattern(name, p));
73
+ }
74
+
75
+ /**
76
+ * Compares an expected schema (from metadata) against an actual schema (from introspection)
77
+ * and produces the change list to bring actual → expected. Always returns a Promise.
78
+ *
79
+ * Per spec §6.
80
+ *
81
+ * Accepts either the full DiffArgs object, or positional (expected, actual[, opts]) for
82
+ * convenience in tests and simple callers.
83
+ */
84
+ export async function diff(args: DiffArgs): Promise<DiffResult>;
85
+ export async function diff(expected: SchemaSnapshot, actual: SchemaSnapshot, opts?: Omit<DiffArgs, "expected" | "actual">): Promise<DiffResult>;
86
+ export async function diff(
87
+ argsOrExpected: DiffArgs | SchemaSnapshot,
88
+ actualMaybe?: SchemaSnapshot,
89
+ optsMaybe?: Omit<DiffArgs, "expected" | "actual">,
90
+ ): Promise<DiffResult> {
91
+ // Normalize args.
92
+ const args: DiffArgs = isDiffArgs(argsOrExpected)
93
+ ? argsOrExpected
94
+ : { expected: argsOrExpected, actual: actualMaybe!, ...(optsMaybe ?? {}) };
95
+
96
+ const changes: Change[] = [];
97
+
98
+ const ignorePatterns = args.ignoreTables ?? DEFAULT_IGNORE_TABLES;
99
+ // Key tables on (schema, name) identity — same table name in different schemas
100
+ // are distinct entities. tableIdentity normalizes undefined → "public".
101
+ const expectedTables = new Map(
102
+ args.expected.tables
103
+ .filter((t) => !shouldIgnoreTable(t.name, ignorePatterns))
104
+ .map((t) => [tableIdentity(t), t] as const),
105
+ );
106
+ const actualTables = new Map(
107
+ args.actual.tables
108
+ .filter((t) => !shouldIgnoreTable(t.name, ignorePatterns))
109
+ .map((t) => [tableIdentity(t), t] as const),
110
+ );
111
+
112
+ // Pass 1: tables present in expected but not actual → create-table + add-index + add-fk
113
+ // Indexes and FKs are separate SQL statements (not part of CREATE TABLE), so they
114
+ // must be emitted as individual changes even for brand-new tables.
115
+ for (const [id, table] of expectedTables) {
116
+ if (!actualTables.has(id)) {
117
+ changes.push({ kind: "create-table", table, ...schemaSpread(table.schema), status: ALLOWED });
118
+ for (const index of table.indexes) {
119
+ changes.push({
120
+ kind: "add-index", table: table.name, ...schemaSpread(table.schema),
121
+ index, status: ALLOWED,
122
+ });
123
+ }
124
+ for (const fk of table.foreignKeys) {
125
+ changes.push({
126
+ kind: "add-fk", table: table.name, ...schemaSpread(table.schema),
127
+ fk, status: ALLOWED,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ // Pass 1b: tables present in actual but not expected → drop-table
133
+ // Attach _columns side-channel so detectTableRenames can compare column sets.
134
+ for (const [id, t] of actualTables) {
135
+ if (!expectedTables.has(id)) {
136
+ const dropChange: Change & { _columns?: ColumnDescriptor[] } = {
137
+ kind: "drop-table", table: t.name, ...schemaSpread(t.schema), status: ALLOWED,
138
+ };
139
+ dropChange._columns = t.columns;
140
+ changes.push(dropChange);
141
+ }
142
+ }
143
+
144
+ // Pass 2: tables in both → compare columns/indexes/FKs
145
+ for (const [id, expectedTable] of expectedTables) {
146
+ const actualTable = actualTables.get(id);
147
+ if (!actualTable) continue;
148
+ diffTableColumns(expectedTable, actualTable, changes);
149
+ diffTableIndexes(expectedTable, actualTable, changes);
150
+ diffTableForeignKeys(expectedTable, actualTable, changes);
151
+ }
152
+
153
+ // Pass 3: detect table renames BEFORE column renames — so a renamed table's
154
+ // columns are not scanned as orphaned drop/add pairs.
155
+ await detectTableRenames(changes, args.onAmbiguous);
156
+ await detectColumnRenames(changes, args.onAmbiguous);
157
+
158
+ // Strip the rename-detection side-channel fields before status assignment / return.
159
+ for (const c of changes) {
160
+ type Aug = Change & { _sqlType?: unknown; _nullable?: unknown; _columns?: unknown };
161
+ delete (c as Aug)._sqlType;
162
+ delete (c as Aug)._nullable;
163
+ delete (c as Aug)._columns;
164
+ }
165
+
166
+ applyStatus(changes, args.allow ?? {});
167
+ return { changes, blocked: changes.filter((c) => c.status.state === "blocked") };
168
+ }
169
+
170
+ function isDiffArgs(x: DiffArgs | SchemaSnapshot): x is DiffArgs {
171
+ return "expected" in x && "actual" in x;
172
+ }
173
+
174
+ function diffTableColumns(
175
+ expected: TableDescriptor,
176
+ actual: TableDescriptor,
177
+ changes: Change[],
178
+ ): void {
179
+ const table = expected.name;
180
+ const sx = schemaSpread(expected.schema);
181
+ const expectedCols = new Map(expected.columns.map((c) => [c.name, c]));
182
+ const actualCols = new Map(actual.columns.map((c) => [c.name, c]));
183
+
184
+ for (const [name, ec] of expectedCols) {
185
+ const ac = actualCols.get(name);
186
+ if (!ac) {
187
+ changes.push({ kind: "add-column", table, ...sx, column: ec, status: ALLOWED });
188
+ continue;
189
+ }
190
+ // Compare type, nullable, default — emit per-aspect change.
191
+ if (!sqlTypeEquals(ec.sqlType, ac.sqlType)) {
192
+ changes.push({
193
+ kind: "change-column-type", table, ...sx, column: name,
194
+ from: ac.sqlType, to: ec.sqlType, status: ALLOWED,
195
+ });
196
+ }
197
+ if (ec.nullable !== ac.nullable) {
198
+ changes.push({
199
+ kind: "change-column-nullable", table, ...sx, column: name,
200
+ from: ac.nullable, to: ec.nullable, status: ALLOWED,
201
+ });
202
+ }
203
+ if (!columnDefaultsEqual(ec.default, ac.default)) {
204
+ const change: Change = {
205
+ kind: "change-column-default", table, ...sx, column: name,
206
+ status: ALLOWED,
207
+ ...(ac.default !== undefined ? { from: ac.default } : {}),
208
+ ...(ec.default !== undefined ? { to: ec.default } : {}),
209
+ };
210
+ changes.push(change);
211
+ }
212
+ }
213
+ for (const [name, ac] of actualCols) {
214
+ if (!expectedCols.has(name)) {
215
+ const dropChange: Change & { _sqlType?: SqlType; _nullable?: boolean } = {
216
+ kind: "drop-column", table, ...sx, column: name, status: ALLOWED,
217
+ };
218
+ dropChange._sqlType = ac.sqlType;
219
+ dropChange._nullable = ac.nullable;
220
+ changes.push(dropChange);
221
+ }
222
+ }
223
+ }
224
+
225
+ function diffTableIndexes(
226
+ expected: TableDescriptor,
227
+ actual: TableDescriptor,
228
+ changes: Change[],
229
+ ): void {
230
+ const table = expected.name;
231
+ const sx = schemaSpread(expected.schema);
232
+ const expectedIdx = new Map(expected.indexes.map((i) => [i.name, i]));
233
+ const actualIdx = new Map(actual.indexes.map((i) => [i.name, i]));
234
+ for (const [name, ix] of expectedIdx) {
235
+ const a = actualIdx.get(name);
236
+ if (!a) {
237
+ changes.push({ kind: "add-index", table, ...sx, index: ix, status: ALLOWED });
238
+ } else if (!indexEquals(ix, a)) {
239
+ // Index shape changed: drop + add (atomic from caller's perspective).
240
+ changes.push({ kind: "drop-index", table, ...sx, index: name, status: ALLOWED });
241
+ changes.push({ kind: "add-index", table, ...sx, index: ix, status: ALLOWED });
242
+ }
243
+ }
244
+ for (const [name] of actualIdx) {
245
+ if (!expectedIdx.has(name)) {
246
+ changes.push({ kind: "drop-index", table, ...sx, index: name, status: ALLOWED });
247
+ }
248
+ }
249
+ }
250
+
251
+ function diffTableForeignKeys(
252
+ expected: TableDescriptor,
253
+ actual: TableDescriptor,
254
+ changes: Change[],
255
+ ): void {
256
+ const table = expected.name;
257
+ const sx = schemaSpread(expected.schema);
258
+ const expectedFk = new Map(expected.foreignKeys.map((f) => [f.name, f]));
259
+ const actualFk = new Map(actual.foreignKeys.map((f) => [f.name, f]));
260
+ for (const [name, fk] of expectedFk) {
261
+ const a = actualFk.get(name);
262
+ if (!a) {
263
+ changes.push({ kind: "add-fk", table, ...sx, fk, status: ALLOWED });
264
+ } else if (!fkEquals(fk, a)) {
265
+ changes.push({ kind: "drop-fk", table, ...sx, fk: name, status: ALLOWED });
266
+ changes.push({ kind: "add-fk", table, ...sx, fk, status: ALLOWED });
267
+ }
268
+ }
269
+ for (const [name] of actualFk) {
270
+ if (!expectedFk.has(name)) {
271
+ changes.push({ kind: "drop-fk", table, ...sx, fk: name, status: ALLOWED });
272
+ }
273
+ }
274
+ }
275
+
276
+ function columnDefaultsEqual(a: ColumnDescriptor["default"], b: ColumnDescriptor["default"]): boolean {
277
+ if (a === undefined && b === undefined) return true;
278
+ if (a === undefined || b === undefined) return false;
279
+ return a.kind === b.kind && a.value === b.value;
280
+ }
281
+
282
+ function indexEquals(a: IndexDescriptor, b: IndexDescriptor): boolean {
283
+ if (a.unique !== b.unique) return false;
284
+ if (a.columns.length !== b.columns.length) return false;
285
+ return a.columns.every((c, i) => c === b.columns[i]);
286
+ }
287
+
288
+ function fkEquals(a: FkDescriptor, b: FkDescriptor): boolean {
289
+ if (a.refTable !== b.refTable) return false;
290
+ if (a.onDelete !== b.onDelete || a.onUpdate !== b.onUpdate) return false;
291
+ if (a.columns.length !== b.columns.length || a.refColumns.length !== b.refColumns.length) return false;
292
+ return a.columns.every((c, i) => c === b.columns[i])
293
+ && a.refColumns.every((c, i) => c === b.refColumns[i]);
294
+ }