You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit cacbdac
Browse filesBrowse the repository at this point in the historyBrowse files
`replace-view` fired on EVERY `meta migrate`, for EVERY projection view, forever, on a
schema with zero metadata changes — and `verify --db` was permanently red for any
project with a projection, which trains adopters to ignore the drift gate.
Root cause: Postgres does not store view SQL. It stores the parse tree, and
pg_get_viewdef() DEPARSES it back in Postgres's own style — lowercased functions,
LEFT OUTER JOIN rewritten to LEFT JOIN, parenthesized FROM items and ON predicates,
redundant aliases dropped. The diff compared that against the text we emitted, so the
comparison could never succeed. No textual normalizer can bridge a deparser.
Nothing caught it because nothing ever ran the migrate pipeline TWICE against a real
database with a view in it: the view tests hand-built BOTH sides of the comparison (so
they matched by construction — a state unreachable against real PG), and the round-trip
gate had no view scenarios at all. SQLite was never affected: sqlite_master stores the
verbatim text we wrote.
THE FIX — compare fingerprints, never the deparser.
A sha256 of the body WE generate is stamped into the view's COMMENT ON VIEW at emit
time and read back at introspect time, so both sides of the comparison come from our
own emitter. view-sql-compare.ts is now the SQLite-only comparator.
NON-DESTRUCTIVE REPLACE (the part that protects downstream apps).
Postgres permits CREATE OR REPLACE VIEW only when the existing output columns are a
PREFIX of the new ones. Projection columns come out in DECLARATION order, so a field
APPENDED to a projection lands last and the view updates in place — dependent views,
grants and the view's OID all survive. Inserting/reordering/removing/renaming forces a
drop+create, and the diff now decides that at PLAN time (an illegal OR REPLACE is
rejected at APPLY time, aborting a live migration with no warning). Every unknown
fails SAFE to the gated drop+create; wrong-but-confident is the one outcome forbidden.
LOUD CASCADES.
A DROP VIEW ... CASCADE destroys dependent objects — including views and matviews
owned by OTHER applications, which this tool does not manage and cannot restore.
Dependents are now enumerated (pg_depend -> pg_rewrite; a view's dependency lives on
its _RETURN rule, and its self-edge must be excluded or the walk never terminates),
transitively closed, and a drop that would destroy an external dependent is BLOCKED —
even with --allow drop-view. It requires the new --allow drop-view-cascade, and the
emitted SQL carries a WARNING banner naming every object it destroys, so a reviewer
sees it in the committed migration. Dependents are collected UNSCOPED: a downstream
app's view lives in its own schema, which is exactly the schema our scoping filters
out, and exactly the object a cascade must not destroy silently.
ADOPT GATE (fail closed).
A view with no fingerprint is either hand-written or predates stamping, and on Postgres
those are INDISTINGUISHABLE. Overwriting the first destroys SQL nothing can restore, so
it is blocked pending the new --allow adopt-view. Costs each environment one flagged
run after upgrading; in exchange a hand-written view sitting where a projection expects
one is now VISIBLE to `verify --db` as drift instead of silently invisible to it.
RESTORABLE DOWN MIGRATIONS (a free win).
pg_get_viewdef is useless for comparison but is perfectly valid SQL, so the very
deparser that caused the bug now serves as the restore payload: drop-view/replace-view
down arms recreate the previous view instead of emitting
"-- WARNING: down migration cannot restore the original view definition".
GATES — all against a REAL Postgres, all applying then RE-DIFFING:
- fixtures/persistence-conformance/migrations/noop-converged-canonical.yaml — apply the
whole canonical schema, then diff the same metadata against the DB it just produced.
Must emit ZERO SQL. Also catches the uuid-PK rebuild class (verified: reverting the
uuid guard makes it fail).
- integration-tests/test/view-lifecycle-pg.test.ts — converge; converge again; append ->
replace with a downstream dependent still standing; reorder -> drop+create; external
dependent -> blocked, and named; with cascade -> banner + dependent destroyed;
unstamped -> adopt gate; adopt -> stamped and converged; down -> restored.
Also fixes the migration-scenario runner, which called diff() with no dialect — a shape
production never produces, which silently disarmed both CHECK diffing and the view path.
Snapshot format 3 (ViewDescriptor gained fingerprint/columns/dependents); an older reader
must hard-fail rather than silently fall back to the broken text comparison.
Design: docs/superpowers/specs/2026-07-13-view-migration-lifecycle-design.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd5NWZ1rKau63vJzrBSpLb
Copy file name to clipboardExpand all lines: docs/features/migrations-and-drift.md
+45Lines changed: 45 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -139,6 +139,50 @@ class MetadataExposedConfig(private val dataSource: DataSource) {
139
139
}
140
140
```
141
141
142
+
## Migrating views (projections)
143
+
144
+
A projection's `CREATE VIEW` is generated from its `origin.*` children, so `meta migrate`
145
+
owns the view. Three things are worth knowing.
146
+
147
+
**Append projection fields; don't insert them.** Postgres can update a view in place
148
+
(`CREATE OR REPLACE VIEW`) only when the existing output columns are unchanged and any
149
+
new ones are added at the **end**. A view's columns come out in projection *declaration*
150
+
order, so:
151
+
152
+
- adding a field **at the end** of a projection → non-destructive replace. Dependent
153
+
views, grants, and the view's identity all survive.
154
+
- inserting a field **in the middle**, reordering, renaming, or removing one → the view
155
+
must be **dropped and recreated**, which is destructive to anything that depends on it
156
+
and is therefore gated (`--allow drop-view`).
157
+
158
+
Body-only changes — a different join path, an `origin.aggregate @filter`, a changed
159
+
aggregate that keeps the same result type — are always non-destructive.
160
+
161
+
**A cascading drop is blocked, loudly.** If dropping a view would destroy dependent
162
+
objects — another application's view, a materialized view — `meta migrate` blocks and
163
+
names every one of them. Proceeding requires `--allow drop-view,drop-view-cascade`, and
164
+
the emitted migration carries a `WARNING: CASCADE DROP` banner listing what it destroys.
165
+
MetaObjects does not manage those objects and cannot restore them. `--allow drop-view`
166
+
**alone never cascades.**
167
+
168
+
**One-time upgrade step (`--allow adopt-view`).** Managed views carry a MetaObjects
169
+
fingerprint in their `COMMENT ON VIEW`; that fingerprint — not the view's SQL text — is
170
+
how migrate knows whether a view is current. (It cannot use the text: Postgres does not
171
+
store view SQL, it stores a parse tree and regenerates the SQL in its own style, so what
172
+
you wrote never comes back.) A view with **no** fingerprint is either hand-written or was
173
+
created before fingerprinting existed, and those are indistinguishable — so migrate fails
174
+
closed rather than overwrite somebody's hand-written SQL:
175
+
176
+
```
177
+
meta migrate --allow adopt-view # once per environment, after upgrading
178
+
```
179
+
180
+
That stamps the existing views. Afterwards they converge silently. This is also what
181
+
closes the loop on the doctrine in
182
+
[downstream-metadata-decisions.md](downstream-metadata-decisions.md): a hand-written view
183
+
sitting where a projection expects one is now **visible to `meta verify --db`** as drift,
184
+
instead of being silently invisible to it.
185
+
142
186
## Verified by
143
187
144
188
The following conformance fixtures gate this feature's behavior across ports:
@@ -148,6 +192,7 @@ The following conformance fixtures gate this feature's behavior across ports:
148
192
-[`bootstrap-canonical-from-empty.yaml`](../../fixtures/persistence-conformance/migrations/bootstrap-canonical-from-empty.yaml) — full-CREATE bootstrap from an empty database
149
193
-[`add-nullable-column.yaml`](../../fixtures/persistence-conformance/migrations/add-nullable-column.yaml) — incremental `ALTER TABLE … ADD COLUMN` for a new nullable field
150
194
-[`drop-table-blocked-without-allow.yaml`](../../fixtures/persistence-conformance/migrations/drop-table-blocked-without-allow.yaml) — destructive operations require an explicit allow-flag
195
+
-[`noop-converged-canonical.yaml`](../../fixtures/persistence-conformance/migrations/noop-converged-canonical.yaml) — **the idempotence gate**: apply the whole canonical schema, then diff the same metadata against the database it just produced. A converged schema must emit **zero** SQL. This is what catches any asymmetry between what `emit` writes, what `introspect` reads back, and what the expected schema models — the class of bug that makes `meta migrate` re-propose (and on SQLite, destructively rebuild) unchanged tables forever.
151
196
152
197
**Template drift (`fixtures/verify-conformance/`)** — the `Renderer.verify` engine
153
198
asserts that every variable, section, partial, and required-tag in a template
0 commit comments