Skip to content

Commit c65e0a8

Browse files
dmealingclaude
andcommitted
fix(cli/migrate): skip view recreate when DB body matches metadata
computeProjectionMigrations always emitted DROP+CREATE for every projection view because it never compared against the live DB. Result: every migration churned all projection views even when nothing about them had changed. Read existing view CREATE SQL from sqlite_master.sql (or pg_views. definition synthesized as "CREATE VIEW <n> AS <def>") and compare against the emitted CREATE SQL after whitespace normalization. Skip the view's migration entry when they match. Introspection failure is non-fatal — we fall back to the prior over- eager behavior so a borked introspect can't break migrate. mikes-website verification: - Converged DB → "No schema changes." - workout_events-only drift → one table recreate, zero view migrations, zero pre-drops (no view depends on workout_events). - programs-only drift → table recreate + v_program_summary + v_purchase_summary recreates (both depend on programs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2fd8430 commit c65e0a8

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

typescript/packages/cli/src/commands/migrate.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,17 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
219219
// metaobjects.config.ts absent or invalid — use default snake_case
220220
}
221221

222+
// Pull existing view CREATE SQL from the DB so unchanged views can be
223+
// skipped (no DROP+CREATE noise when the body hasn't changed).
224+
const existingViewSql = await readExistingViewSql(kysely.db, kysely.dialect);
225+
222226
// Compute view migrations (projections) independently of table changes.
223227
const viewResult = computeProjectionMigrations({
224228
metadata,
225229
dialect: kysely.dialect,
226230
allowBreaking: false,
227231
columnNamingStrategy,
232+
existingViewSql,
228233
});
229234
if (viewResult.errors.length > 0) {
230235
for (const err of viewResult.errors) log.error(err);
@@ -343,3 +348,43 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
343348
log.info(output);
344349
return exitCode;
345350
}
351+
352+
/**
353+
* Read existing view CREATE SQL from the DB. Returns an empty map on any
354+
* introspection failure — the worst case is over-eager DROP+CREATE
355+
* (the original behaviour), not data loss.
356+
*/
357+
async function readExistingViewSql(
358+
// biome-ignore lint/suspicious/noExplicitAny: kysely raw query, dialect-dispatched
359+
db: any,
360+
dialect: "sqlite" | "postgres",
361+
): Promise<ReadonlyMap<string, string>> {
362+
const result = new Map<string, string>();
363+
try {
364+
if (dialect === "sqlite") {
365+
const { sql } = await import("kysely");
366+
const rows = await sql<{ name: string; sql: string }>`
367+
SELECT name, sql FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%'
368+
`.execute(db);
369+
for (const r of rows.rows) {
370+
if (r.name && r.sql) result.set(r.name, r.sql);
371+
}
372+
} else {
373+
const { sql } = await import("kysely");
374+
const rows = await sql<{ name: string; def: string }>`
375+
SELECT viewname AS name, definition AS def
376+
FROM pg_views
377+
WHERE schemaname = 'public'
378+
`.execute(db);
379+
for (const r of rows.rows) {
380+
// Postgres pg_views.definition returns only the SELECT body, not the
381+
// full "CREATE VIEW ... AS ...". Synthesize so comparison is apples-
382+
// to-apples with what emitViewDdl produces.
383+
if (r.name && r.def) result.set(r.name, `CREATE VIEW ${r.name} AS ${r.def}`);
384+
}
385+
}
386+
} catch {
387+
// ignore — empty map means over-eager recreate, same as before this fix
388+
}
389+
return result;
390+
}

typescript/packages/cli/src/lib/projection-migrations.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ export interface ProjectionMigrationsOpts {
2323
readonly allowBreaking?: boolean;
2424
/** Column naming strategy forwarded to extractViewSpec. Defaults to "snake_case". */
2525
readonly columnNamingStrategy?: "snake_case" | "literal" | "kebab-case";
26+
/**
27+
* Existing view SQL keyed by view name (from sqlite_master.sql or
28+
* pg_views.definition). When provided, projections whose emitted CREATE
29+
* SQL matches the existing definition (whitespace-normalized) are skipped
30+
* — no DROP+CREATE noise for unchanged views.
31+
*/
32+
readonly existingViewSql?: ReadonlyMap<string, string>;
33+
}
34+
35+
/** Collapse whitespace + strip trailing ";" for textual view-SQL comparison. */
36+
function normalizeViewSql(sql: string): string {
37+
return sql.replace(/\s+/g, " ").replace(/;\s*$/, "").trim();
2638
}
2739

2840
/**
@@ -77,13 +89,20 @@ export function computeProjectionMigrations(
7789
joinTables,
7890
});
7991

92+
// Skip if the existing DB view's CREATE SQL matches what we'd emit.
93+
// Avoids the "every migration re-creates every view" noise when nothing
94+
// about the view's body actually changed.
95+
const existing = opts.existingViewSql?.get(spec.viewName);
96+
if (existing !== undefined && normalizeViewSql(existing) === normalizeViewSql(createSql)) {
97+
continue;
98+
}
99+
80100
views.push({
81101
viewName: spec.viewName,
82102
// prevShape intentionally absent — treated as "safe-append" by
83103
// computeViewMigrations (source-aware-diff.ts line 32). On Postgres
84104
// this rewrites the emitted "CREATE VIEW" to "CREATE OR REPLACE VIEW",
85-
// so re-running migrate is idempotent. Future: introspect existing views
86-
// via pg_views / sqlite_master for true safe-replace/breaking detection.
105+
// so re-running migrate is idempotent.
87106
nextShape: {
88107
columns: spec.selectSpec.columns.map((c) => c.dbColAlias),
89108
},

0 commit comments

Comments
 (0)