@metaobjectsdev/cli 0.8.1 → 0.9.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.
Files changed (43) hide show
  1. package/README.md +40 -1
  2. package/dist/src/commands/migrate.d.ts +16 -0
  3. package/dist/src/commands/migrate.d.ts.map +1 -1
  4. package/dist/src/commands/migrate.js +252 -39
  5. package/dist/src/commands/migrate.js.map +1 -1
  6. package/dist/src/commands/verify.d.ts.map +1 -1
  7. package/dist/src/commands/verify.js +162 -53
  8. package/dist/src/commands/verify.js.map +1 -1
  9. package/dist/src/index.d.ts.map +1 -1
  10. package/dist/src/index.js +6 -0
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/lib/allow.d.ts +10 -0
  13. package/dist/src/lib/allow.d.ts.map +1 -0
  14. package/dist/src/lib/allow.js +50 -0
  15. package/dist/src/lib/allow.js.map +1 -0
  16. package/dist/src/lib/args.d.ts +22 -4
  17. package/dist/src/lib/args.d.ts.map +1 -1
  18. package/dist/src/lib/args.js +47 -11
  19. package/dist/src/lib/args.js.map +1 -1
  20. package/dist/src/lib/bun-sqlite-dialect.d.ts +13 -0
  21. package/dist/src/lib/bun-sqlite-dialect.d.ts.map +1 -0
  22. package/dist/src/lib/bun-sqlite-dialect.js +119 -0
  23. package/dist/src/lib/bun-sqlite-dialect.js.map +1 -0
  24. package/dist/src/lib/config.d.ts +11 -0
  25. package/dist/src/lib/config.d.ts.map +1 -1
  26. package/dist/src/lib/config.js +4 -0
  27. package/dist/src/lib/config.js.map +1 -1
  28. package/dist/src/lib/kysely.d.ts.map +1 -1
  29. package/dist/src/lib/kysely.js +21 -7
  30. package/dist/src/lib/kysely.js.map +1 -1
  31. package/dist/src/lib/projection-migrations.d.ts.map +1 -1
  32. package/dist/src/lib/projection-migrations.js +2 -6
  33. package/dist/src/lib/projection-migrations.js.map +1 -1
  34. package/package.json +11 -10
  35. package/src/commands/migrate.ts +277 -42
  36. package/src/commands/verify.ts +172 -61
  37. package/src/index.ts +6 -0
  38. package/src/lib/allow.ts +54 -0
  39. package/src/lib/args.ts +77 -15
  40. package/src/lib/bun-sqlite-dialect.ts +146 -0
  41. package/src/lib/config.ts +15 -0
  42. package/src/lib/kysely.ts +23 -10
  43. package/src/lib/projection-migrations.ts +2 -6
package/src/index.ts CHANGED
@@ -58,6 +58,12 @@ EXPORT FLAGS:
58
58
 
59
59
  VERIFY FLAGS:
60
60
  --prompts <dir> Directory of provider-resolved template text (default: prompts)
61
+ --db <url> Live DB URL — enables the schema-drift gate (exit 1 on drift).
62
+ Supports: file:, libsql:, postgres:, postgresql:. Omit to skip.
63
+ --dialect sqlite|postgres Optional override (auto-detected from --db URL scheme)
64
+ --allow <csv> Accepted for parity with 'migrate'; does NOT affect the
65
+ verify drift gate (the gate fails on ANY detected change)
66
+ --skip-schema Skip the schema-drift gate even when --db is present
61
67
 
62
68
  PROMPT-SNAPSHOT FLAGS:
63
69
  --check Compare against committed snapshots; exit 1 on drift (CI gate)
@@ -0,0 +1,54 @@
1
+ // Shared destructive-change-permission parsing + change description, used by
2
+ // both `meta migrate` and `meta verify --db`. Keeping a single copy avoids the
3
+ // two commands drifting on which `--allow` tokens exist or how a change reads.
4
+
5
+ import type { AllowOptions, Change } from "@metaobjectsdev/migrate-ts";
6
+
7
+ // Map CLI allow tokens → migrate-ts AllowOptions field names.
8
+ const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
9
+ "drop-column": "dropColumn",
10
+ "drop-table": "dropTable",
11
+ "type-change": "typeChange",
12
+ "drop-index": "dropIndex",
13
+ "drop-fk": "dropFk",
14
+ "nullable-to-not-null": "nullableToNotNull",
15
+ };
16
+
17
+ /** Translate parsed `--allow` tokens into the migrate-ts `AllowOptions` shape. */
18
+ export function tokensToAllowOptions(tokens: string[]): AllowOptions {
19
+ const opts: AllowOptions = {};
20
+ for (const tok of tokens) {
21
+ const field = ALLOW_TOKEN_MAP[tok];
22
+ if (field !== undefined) {
23
+ opts[field] = true;
24
+ }
25
+ }
26
+ return opts;
27
+ }
28
+
29
+ /**
30
+ * One-line, human-readable detail for a single change (table/column/index/fk/
31
+ * view). This is the shared core: `migrate` prints it as-is; `verify` prefixes
32
+ * a +/-/~ glyph and a noun (`table`/`column`/…) on top.
33
+ */
34
+ export function describeChange(c: Change): string {
35
+ switch (c.kind) {
36
+ case "create-table": return c.table.name;
37
+ case "drop-table": return c.table;
38
+ case "rename-table": return `${c.from} → ${c.to}`;
39
+ case "add-column": return `${c.table}.${c.column.name}`;
40
+ case "drop-column": return `${c.table}.${c.column}`;
41
+ case "rename-column": return `${c.table}.${c.from} → ${c.table}.${c.to}`;
42
+ case "change-column-type": return `${c.table}.${c.column} (${c.from.kind} → ${c.to.kind})`;
43
+ case "change-column-nullable": return `${c.table}.${c.column} (${c.from ? "NULL" : "NOT NULL"} → ${c.to ? "NULL" : "NOT NULL"})`;
44
+ case "change-column-default": return `${c.table}.${c.column}`;
45
+ case "add-index": return `${c.table} idx ${c.index.name}`;
46
+ case "drop-index": return `${c.table} idx ${c.index}`;
47
+ case "add-fk": return `${c.table} fk ${c.fk.name}`;
48
+ case "drop-fk": return `${c.table} fk ${c.fk}`;
49
+ case "create-view": return c.view.name;
50
+ case "replace-view": return c.view.name;
51
+ case "drop-view": return c.view;
52
+ default: return JSON.stringify(c);
53
+ }
54
+ }
package/src/lib/args.ts CHANGED
@@ -92,6 +92,23 @@ export function parseExportArgs(argv: string[]): ExportFlags {
92
92
  };
93
93
  }
94
94
 
95
+ // ---------------------------------------------------------------------------
96
+ // shared DB-connection vocab (used by both verify --db and migrate)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ const DIALECTS = ["sqlite", "postgres", "d1"] as const;
100
+ type Dialect = (typeof DIALECTS)[number];
101
+
102
+ const ALLOW_TOKENS = [
103
+ "drop-column",
104
+ "drop-table",
105
+ "type-change",
106
+ "drop-index",
107
+ "drop-fk",
108
+ "nullable-to-not-null",
109
+ ] as const;
110
+ type AllowToken = (typeof ALLOW_TOKENS)[number];
111
+
95
112
  // ---------------------------------------------------------------------------
96
113
  // verify flags
97
114
  // ---------------------------------------------------------------------------
@@ -99,6 +116,14 @@ export function parseExportArgs(argv: string[]): ExportFlags {
99
116
  export interface VerifyFlags {
100
117
  /** Directory (relative to cwd) holding provider-resolved template text. */
101
118
  prompts: string | undefined;
119
+ /** Live DB connection URL; when present, enables the schema-drift gate. */
120
+ db: string | undefined;
121
+ /** Optional dialect override (auto-detected from --db URL scheme otherwise). */
122
+ dialect: Dialect | undefined;
123
+ /** Destructive-change permissions; only affects how drift is described. */
124
+ allow: AllowToken[];
125
+ /** Skip the schema-drift gate even when --db is present. */
126
+ skipSchema: boolean;
102
127
  }
103
128
 
104
129
  export function parseVerifyArgs(argv: string[]): VerifyFlags {
@@ -106,12 +131,38 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
106
131
  args: argv,
107
132
  options: {
108
133
  prompts: { type: "string" },
134
+ db: { type: "string" },
135
+ dialect: { type: "string" },
136
+ allow: { type: "string" },
137
+ "skip-schema": { type: "boolean", default: false },
109
138
  },
110
139
  strict: true,
111
140
  allowPositionals: false,
112
141
  });
142
+
143
+ const dialect = values.dialect as string | undefined;
144
+ if (dialect !== undefined && !DIALECTS.includes(dialect as Dialect)) {
145
+ throw new Error(`invalid --dialect '${dialect}'; expected: ${DIALECTS.join(", ")}`);
146
+ }
147
+
148
+ const allowRaw = (values.allow as string | undefined) ?? "";
149
+ const allowTokens = allowRaw.length === 0
150
+ ? []
151
+ : allowRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
152
+ for (const tok of allowTokens) {
153
+ if (!ALLOW_TOKENS.includes(tok as AllowToken)) {
154
+ throw new Error(
155
+ `invalid --allow token '${tok}'; expected one of: ${ALLOW_TOKENS.join(", ")}`,
156
+ );
157
+ }
158
+ }
159
+
113
160
  return {
114
161
  prompts: values.prompts,
162
+ db: values.db as string | undefined,
163
+ dialect: dialect as Dialect | undefined,
164
+ allow: allowTokens as AllowToken[],
165
+ skipSchema: !!values["skip-schema"],
115
166
  };
116
167
  }
117
168
 
@@ -146,19 +197,6 @@ export function parsePromptSnapshotArgs(argv: string[]): PromptSnapshotFlags {
146
197
  // migrate flags
147
198
  // ---------------------------------------------------------------------------
148
199
 
149
- const DIALECTS = ["sqlite", "postgres", "d1"] as const;
150
- type Dialect = (typeof DIALECTS)[number];
151
-
152
- const ALLOW_TOKENS = [
153
- "drop-column",
154
- "drop-table",
155
- "type-change",
156
- "drop-index",
157
- "drop-fk",
158
- "nullable-to-not-null",
159
- ] as const;
160
- type AllowToken = (typeof ALLOW_TOKENS)[number];
161
-
162
200
  const ON_AMBIGUOUS = ["abort", "rename", "drop-add"] as const;
163
201
  type OnAmbiguous = (typeof ON_AMBIGUOUS)[number];
164
202
 
@@ -174,11 +212,21 @@ export interface MigrateFlags {
174
212
  d1Binding: string | undefined;
175
213
  remote: boolean;
176
214
  apply: boolean;
215
+ /**
216
+ * Roll back all applied migrations NEWER than this target (the target itself
217
+ * is retained), running each migration's down.sql in reverse order. Mutually
218
+ * exclusive with --apply. postgres/sqlite only (not d1).
219
+ */
220
+ rollback: string | undefined;
177
221
  yes: boolean;
222
+ /** Use live-DB introspection instead of the committed snapshot (legacy/adoption). */
223
+ fromDb: boolean;
224
+ /** `migrate baseline` subcommand: seed the snapshot, emit no migration. */
225
+ baseline: boolean;
178
226
  }
179
227
 
180
228
  export function parseMigrateArgs(argv: string[]): MigrateFlags {
181
- const { values } = parseArgs({
229
+ const { values, positionals } = parseArgs({
182
230
  args: argv,
183
231
  options: {
184
232
  "db": { type: "string" },
@@ -188,15 +236,26 @@ export function parseMigrateArgs(argv: string[]): MigrateFlags {
188
236
  "allow": { type: "string" },
189
237
  "on-ambiguous": { type: "string" },
190
238
  "dry-run": { type: "boolean", default: false },
239
+ "from-db": { type: "boolean", default: false },
191
240
  "d1": { type: "string" },
192
241
  "remote": { type: "boolean", default: false },
193
242
  "apply": { type: "boolean", default: false },
243
+ "rollback": { type: "string" },
194
244
  "yes": { type: "boolean", default: false },
195
245
  },
196
246
  strict: true,
197
- allowPositionals: false,
247
+ allowPositionals: true,
198
248
  });
199
249
 
250
+ const baseline = positionals[0] === "baseline";
251
+ if (positionals.length > 0 && !baseline) {
252
+ throw new Error(`unknown migrate subcommand '${positionals[0]}'; expected 'baseline' or no subcommand`);
253
+ }
254
+
255
+ if (values.rollback !== undefined && values.apply === true) {
256
+ throw new Error(`--rollback and --apply are mutually exclusive`);
257
+ }
258
+
200
259
  const dialect = values.dialect as string | undefined;
201
260
  if (dialect !== undefined && !DIALECTS.includes(dialect as Dialect)) {
202
261
  throw new Error(`invalid --dialect '${dialect}'; expected: ${DIALECTS.join(", ")}`);
@@ -230,6 +289,9 @@ export function parseMigrateArgs(argv: string[]): MigrateFlags {
230
289
  d1Binding: values.d1 as string | undefined,
231
290
  remote: !!values.remote,
232
291
  apply: !!values.apply,
292
+ rollback: values.rollback as string | undefined,
233
293
  yes: !!values.yes,
294
+ fromDb: !!values["from-db"],
295
+ baseline,
234
296
  };
235
297
  }
@@ -0,0 +1,146 @@
1
+ // A Kysely SQLite dialect backed by Bun's built-in `bun:sqlite`.
2
+ //
3
+ // WHY: the standalone `meta` binary is produced with `bun build --compile`,
4
+ // which embeds the Bun runtime but CANNOT bundle native node addons. The
5
+ // default sqlite driver (`@libsql/kysely-libsql`) loads a platform-native
6
+ // `.node` addon (`@libsql/linux-x64-gnu` etc.) via a runtime require that the
7
+ // single-file binary's virtual filesystem can't resolve — so libsql fails
8
+ // inside the compiled binary with `Cannot find module '@libsql/linux-x64-gnu'`.
9
+ //
10
+ // `bun:sqlite` ships *inside* the Bun runtime that `--compile` embeds, so it
11
+ // needs no on-disk addon. This dialect is therefore the sqlite driver the
12
+ // standalone binary uses. The npm/Node distribution keeps using libsql (this
13
+ // module is only loaded when running under Bun — see buildKyselyFromUrl).
14
+ //
15
+ // The `bun:sqlite` import is dynamic + typed loosely so the tsc → Node build
16
+ // (which has no `bun:sqlite` module) never tries to resolve it statically.
17
+
18
+ import {
19
+ type DatabaseConnection,
20
+ type Dialect,
21
+ type Driver,
22
+ type QueryResult,
23
+ SqliteAdapter,
24
+ SqliteIntrospector,
25
+ SqliteQueryCompiler,
26
+ type CompiledQuery,
27
+ type DatabaseIntrospector,
28
+ type Kysely,
29
+ type DialectAdapter,
30
+ type QueryCompiler,
31
+ } from "kysely";
32
+
33
+ /** Minimal structural view of a `bun:sqlite` Database — only what we use. */
34
+ interface BunSqliteDatabase {
35
+ query(sql: string): BunSqliteStatement;
36
+ run(sql: string): void;
37
+ close(): void;
38
+ }
39
+ interface BunSqliteStatement {
40
+ all(...params: readonly unknown[]): unknown[];
41
+ run(...params: readonly unknown[]): { changes: number; lastInsertRowid: number | bigint };
42
+ }
43
+
44
+ /**
45
+ * Open a `bun:sqlite` database for a `file:`/`libsql:` URL (or a bare path).
46
+ * Throws when not running under Bun (no `bun:sqlite` module).
47
+ */
48
+ async function openBunSqlite(url: string): Promise<BunSqliteDatabase> {
49
+ // `bun:sqlite` is a Bun built-in; the dynamic specifier keeps Node/tsc from
50
+ // resolving it. The cast is necessary because there's no ambient type here.
51
+ const mod = (await import("bun:sqlite")) as unknown as {
52
+ Database: new (filename: string) => BunSqliteDatabase;
53
+ };
54
+ // Strip a file:/libsql: scheme to a plain filesystem path; `:memory:` stays.
55
+ let filename = url;
56
+ const schemeMatch = /^(file|libsql):\/\/(.*)$/i.exec(url) ?? /^(file|libsql):(.*)$/i.exec(url);
57
+ if (schemeMatch) filename = schemeMatch[2] ?? "";
58
+ if (filename === "") filename = ":memory:";
59
+ return new mod.Database(filename);
60
+ }
61
+
62
+ class BunSqliteConnection implements DatabaseConnection {
63
+ constructor(private readonly db: BunSqliteDatabase) {}
64
+
65
+ async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
66
+ const { sql, parameters } = compiledQuery;
67
+ const stmt = this.db.query(sql);
68
+ const params = parameters as readonly unknown[];
69
+ // SELECT-shaped statements return rows; everything else reports counts.
70
+ const isReturningRows = /^\s*(select|with|pragma)\b/i.test(sql);
71
+ if (isReturningRows) {
72
+ const rows = stmt.all(...params) as R[];
73
+ return { rows };
74
+ }
75
+ const info = stmt.run(...params);
76
+ return {
77
+ numAffectedRows: BigInt(info.changes),
78
+ insertId: BigInt(info.lastInsertRowid),
79
+ rows: [],
80
+ };
81
+ }
82
+
83
+ // Async generator that never yields by design — streaming is unsupported.
84
+ async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
85
+ throw new Error("bun:sqlite dialect does not support streaming");
86
+ }
87
+ }
88
+
89
+ class BunSqliteDriver implements Driver {
90
+ private db: BunSqliteDatabase | undefined;
91
+ private connection: BunSqliteConnection | undefined;
92
+
93
+ constructor(private readonly url: string) {}
94
+
95
+ async init(): Promise<void> {
96
+ this.db = await openBunSqlite(this.url);
97
+ this.connection = new BunSqliteConnection(this.db);
98
+ }
99
+
100
+ async acquireConnection(): Promise<DatabaseConnection> {
101
+ if (this.connection === undefined) throw new Error("bun:sqlite driver not initialized");
102
+ return this.connection;
103
+ }
104
+
105
+ async beginTransaction(conn: DatabaseConnection): Promise<void> {
106
+ await conn.executeQuery({ sql: "begin", parameters: [], query: { kind: "RawNode" } as never });
107
+ }
108
+ async commitTransaction(conn: DatabaseConnection): Promise<void> {
109
+ await conn.executeQuery({ sql: "commit", parameters: [], query: { kind: "RawNode" } as never });
110
+ }
111
+ async rollbackTransaction(conn: DatabaseConnection): Promise<void> {
112
+ await conn.executeQuery({ sql: "rollback", parameters: [], query: { kind: "RawNode" } as never });
113
+ }
114
+
115
+ async releaseConnection(): Promise<void> {
116
+ /* single shared connection — nothing to release */
117
+ }
118
+
119
+ async destroy(): Promise<void> {
120
+ this.db?.close();
121
+ this.db = undefined;
122
+ this.connection = undefined;
123
+ }
124
+ }
125
+
126
+ /** Kysely Dialect backed by `bun:sqlite`. */
127
+ export class BunSqliteDialect implements Dialect {
128
+ constructor(private readonly url: string) {}
129
+ createDriver(): Driver {
130
+ return new BunSqliteDriver(this.url);
131
+ }
132
+ createQueryCompiler(): QueryCompiler {
133
+ return new SqliteQueryCompiler();
134
+ }
135
+ createAdapter(): DialectAdapter {
136
+ return new SqliteAdapter();
137
+ }
138
+ createIntrospector(db: Kysely<Record<string, unknown>>): DatabaseIntrospector {
139
+ return new SqliteIntrospector(db);
140
+ }
141
+ }
142
+
143
+ /** True when running under the Bun runtime (where `bun:sqlite` is available). */
144
+ export function isBun(): boolean {
145
+ return typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
146
+ }
package/src/lib/config.ts CHANGED
@@ -42,7 +42,18 @@ export interface ResolvedMigrateConfig {
42
42
  allow: string[];
43
43
  slug: string | undefined;
44
44
  dryRun: boolean;
45
+ /** Run pending migration files against the DB (postgres/sqlite, ledger-backed). */
46
+ apply: boolean;
47
+ /**
48
+ * Roll back all applied migrations newer than this target (target retained),
49
+ * postgres/sqlite only. Mutually exclusive with apply.
50
+ */
51
+ rollback: string | undefined;
45
52
  yes: boolean;
53
+ /** Use live-DB introspection instead of the committed snapshot. */
54
+ fromDb: boolean;
55
+ /** Seed the snapshot and exit (no migration). */
56
+ baseline: boolean;
46
57
  d1: ResolvedD1Config;
47
58
  }
48
59
 
@@ -87,7 +98,11 @@ export async function resolveMigrateConfig(
87
98
  : (cfgBlock.allow ?? MIGRATE_DEFAULTS.allow),
88
99
  slug: flags.slug,
89
100
  dryRun: flags.dryRun,
101
+ apply: flags.apply,
102
+ rollback: flags.rollback,
90
103
  yes: flags.yes,
104
+ fromDb: flags.fromDb,
105
+ baseline: flags.baseline,
91
106
  d1: {
92
107
  binding: flags.d1Binding ?? d1Block.binding,
93
108
  remote: flags.remote || (d1Block.remote ?? false),
package/src/lib/kysely.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Kysely } from "kysely";
2
+ import { BunSqliteDialect, isBun } from "./bun-sqlite-dialect.js";
2
3
 
3
4
  export type Dialect = "sqlite" | "postgres" | "d1";
4
5
 
@@ -63,17 +64,29 @@ export async function buildKyselyFromUrl(
63
64
  }
64
65
 
65
66
  if (dialect === "sqlite") {
66
- type LibsqlDialectCtor = new (opts: { url: string }) => ConstructorParameters<typeof Kysely<Record<string, unknown>>>[0]["dialect"];
67
- let LibsqlDialect: LibsqlDialectCtor;
68
- try {
69
- const mod = await import("@libsql/kysely-libsql");
70
- LibsqlDialect = mod.LibsqlDialect as unknown as LibsqlDialectCtor;
71
- } catch {
72
- throw new Error(
73
- `dialect 'sqlite' requires '@libsql/kysely-libsql'; install it: 'bun add @libsql/kysely-libsql'`,
74
- );
67
+ // Under Bun (notably the `bun build --compile` standalone binary), use the
68
+ // built-in `bun:sqlite` driver. It ships inside the embedded Bun runtime,
69
+ // so — unlike `@libsql/kysely-libsql`, whose platform-native `.node` addon
70
+ // can't be bundled into a single-file binary — it works in the compiled
71
+ // `meta` binary with no on-disk dependency. The Node/npm distribution falls
72
+ // through to libsql below.
73
+ let sqliteDialect: ConstructorParameters<typeof Kysely<Record<string, unknown>>>[0]["dialect"];
74
+ if (isBun()) {
75
+ sqliteDialect = new BunSqliteDialect(url);
76
+ } else {
77
+ type LibsqlDialectCtor = new (opts: { url: string }) => ConstructorParameters<typeof Kysely<Record<string, unknown>>>[0]["dialect"];
78
+ let LibsqlDialect: LibsqlDialectCtor;
79
+ try {
80
+ const mod = await import("@libsql/kysely-libsql");
81
+ LibsqlDialect = mod.LibsqlDialect as unknown as LibsqlDialectCtor;
82
+ } catch {
83
+ throw new Error(
84
+ `dialect 'sqlite' requires '@libsql/kysely-libsql'; install it: 'bun add @libsql/kysely-libsql'`,
85
+ );
86
+ }
87
+ sqliteDialect = new LibsqlDialect({ url });
75
88
  }
76
- const db = new Kysely<Record<string, unknown>>({ dialect: new LibsqlDialect({ url }) });
89
+ const db = new Kysely<Record<string, unknown>>({ dialect: sqliteDialect });
77
90
  let closed = false;
78
91
  return {
79
92
  db,
@@ -10,6 +10,7 @@ import {
10
10
  } from "@metaobjectsdev/codegen-ts";
11
11
  import {
12
12
  computeViewMigrations,
13
+ viewSqlEquals,
13
14
  type ViewMigrationInput,
14
15
  type ViewMigrationsResult,
15
16
  } from "@metaobjectsdev/migrate-ts";
@@ -33,11 +34,6 @@ export interface ProjectionMigrationsOpts {
33
34
  readonly existingViewSql?: ReadonlyMap<string, string>;
34
35
  }
35
36
 
36
- /** Collapse whitespace + strip trailing ";" for textual view-SQL comparison. */
37
- function normalizeViewSql(sql: string): string {
38
- return sql.replace(/\s+/g, " ").replace(/;\s*$/, "").trim();
39
- }
40
-
41
37
  /**
42
38
  * Walk all projection entities in metadata, extract their ViewSpec, emit CREATE
43
39
  * VIEW DDL, and compute view migration SQL via computeViewMigrations.
@@ -96,7 +92,7 @@ export function computeProjectionMigrations(
96
92
  // Avoids the "every migration re-creates every view" noise when nothing
97
93
  // about the view's body actually changed.
98
94
  const existing = opts.existingViewSql?.get(spec.viewName);
99
- if (existing !== undefined && normalizeViewSql(existing) === normalizeViewSql(createSql)) {
95
+ if (existing !== undefined && viewSqlEquals(existing, createSql)) {
100
96
  continue;
101
97
  }
102
98