Skip to content

Commit cf68d5e

Browse files
dmealingclaude
andcommitted
fix(#208): checkpoint code-review fixes — offline DROP suppression + own-source parity + dedup
Addresses the /code-review high findings on the TS reference (Tasks 4-6): - [1, correctness] The default OFFLINE `meta migrate` generate path (planOffline) never threaded the @Unmanaged name set, so an @Unmanaged table captured into the snapshot by a `baseline --from-db` was proposed for DROP. planOffline now threads collectUnmanagedNames(args.metadata) into its diff internally (parity with the online + verify paths). Regression test added. - [own-source parity] expected-schema's @Unmanaged writable-source detection now uses ownChildren() to match collectUnmanagedNames — so the "skip the descriptor" half and the "suppress the drop" half agree exactly. This converts a theoretical split-brain (a table not created yet still proposed for destructive drop, for an @Unmanaged source inherited via extends) into consistent treat-as-managed. collectUnmanagedNames now takes MetaData (not MetaRoot) so planOffline can call it without a cast. - [6, cleanup] The DDL-ownership classification block was duplicated verbatim across the projection and write-through loops in build-projection-views. Factored into a shared classifyReadOnlySource() returning skip | sql | derive, so the ownership rules can never diverge between the two host kinds (the emit tail was already shared). Deferred/intended (documented in Task 11, NOT bugs): an @SQL view's dependsOn is derived from extends-anchors only (D7 / §11 YAGNI — the @dependsOn escape is deferred until an adopter hits an undeclared-dependency alter); an @Unmanaged view is not drop/recreated around a managed dependency's column ALTER (the tool has no body for an external view — it cannot recreate it) and an FK into an @Unmanaged table has no ordering guarantee vs the external tool (both documented adopter caveats, spec §7). migrate-ts 588 pass; codegen-ts 120 projection; cli 375; view-lifecycle-pg 16/16; typechecks clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 5c8dc42 commit cf68d5e

5 files changed

Lines changed: 116 additions & 55 deletions

File tree

server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -93,44 +93,21 @@ export function buildProjectionViews(
9393

9494
const out: ExpectedView[] = [];
9595
for (const projection of root.objects().filter(isProjection)) {
96-
// Only PLAIN-VIEW projections produce managed CREATE VIEW DDL. The other
97-
// read-only kinds must be skipped, not fed to extractViewSpec:
98-
// - storedProc / tableFunction (FR-015) are CALLABLES, not views. They are
99-
// base-less (no extends-bound identity), so extractViewSpec THROWS for
100-
// them — and the CLI calls this function unconditionally, so one proc
101-
// projection used to crash `meta migrate` outright.
102-
// - materializedView cannot be managed by the migrate pipeline today:
103-
// there is no CREATE MATERIALIZED VIEW emit, and PG introspection cannot
104-
// even see matviews (information_schema.views excludes them), so a
105-
// "managed" matview would re-propose create-view on every run and the
106-
// apply would collide with the existing object. Worse, feeding it
107-
// through here silently created a PLAIN view under the matview's name.
108-
// Matviews are hand-managed, like the documented custom-SQL-view
109-
// exception: migrate neither creates nor drops them.
110-
// - a STANDALONE read-model — a plain view projection declaring its own
111-
// columns, with no origin.* children — is hand-authored SQL too. Codegen
112-
// already treats it that way on purpose (see projection-decl.ts: "lets a
113-
// standalone read-only view-entity — explicit columns, no `extends` —
114-
// generate its read model … standalone views hand-author their SQL").
115-
// The SCHEMA path never got that memo: extractViewSpec THREW on it
116-
// ("cannot derive the base entity"), and because the CLI calls this
117-
// function unconditionally, ONE such projection aborted `meta migrate`
118-
// for the ENTIRE model — every other entity included. Same crash class as
119-
// the proc projections above. Skip it instead.
120-
// ADR-0039: own — mirrors isProjection/viewName's own-source classification.
121-
const readOnlySource = projection.ownChildren().find(
122-
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
123-
);
124-
if (readOnlySource === undefined) continue;
125-
// #208 suppression rule (§6) — classify DDL ownership BEFORE viewIsDerived, so an
126-
// escape-valve view carrying extends-bound identity/fields (pure shape / row
96+
// #208 §6 — classify DDL ownership BEFORE viewIsDerived (see classifyReadOnlySource),
97+
// so an escape-valve view carrying extends-bound identity/fields (pure shape / row
12798
// identity) is never mis-synthesized into a wrong base-table passthrough SELECT.
128-
if (readOnlySource.isUnmanaged) continue; // external: Flyway/hand-migration owns it (§7)
129-
if (readOnlySource.sqlBody !== undefined) { // supplied: the author owns the body
130-
emitSqlView(projection, readOnlySource, root, joinTables, out);
99+
const cls = classifyReadOnlySource(projection);
100+
if (cls.kind === "skip") continue;
101+
if (cls.kind === "sql") {
102+
emitSqlView(projection, cls.source, root, joinTables, out);
131103
continue;
132104
}
133-
if (readOnlySource.effectiveKind !== SOURCE_KIND_VIEW) continue;
105+
// A plain-view projection with no extends anchor and no origin.* is a STANDALONE
106+
// read-model that hand-authors its own SQL — viewIsDerived returns false and we skip
107+
// it (feeding it to extractViewSpec throws "cannot derive the base entity", and the
108+
// CLI calls this unconditionally, so ONE such projection would abort `meta migrate`
109+
// for the whole model). Codegen already treats this shape as intended in
110+
// projection-decl.ts ("standalone views hand-author their SQL").
134111
if (!viewIsDerived(projection)) continue;
135112
emitViewFor(projection, root, joinTables, dialect, columnNamingStrategy, out);
136113
}
@@ -145,23 +122,55 @@ export function buildProjectionViews(
145122
// `view` read source synthesizes CREATE VIEW DDL — a matview/proc/tableFunction
146123
// read source is hand-managed, exactly as for projections above.
147124
for (const entity of root.objects().filter(isWriteThrough)) {
148-
const readOnlySource = entity.ownChildren().find(
149-
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
150-
);
151-
if (readOnlySource === undefined) continue;
152-
// #208 suppression rule (§6) — same DDL-ownership classification as the projection
153-
// loop above: an @unmanaged replica view is skipped, an @sql one is verbatim.
154-
if (readOnlySource.isUnmanaged) continue;
155-
if (readOnlySource.sqlBody !== undefined) {
156-
emitSqlView(entity, readOnlySource, root, joinTables, out);
125+
// #208 §6 — same DDL-ownership classification as the projection loop. Unlike a
126+
// projection there is no viewIsDerived gate: a write-through entity IS the base and
127+
// always has a derived view to emit (extractViewSpec detects the write-through host).
128+
const cls = classifyReadOnlySource(entity);
129+
if (cls.kind === "skip") continue;
130+
if (cls.kind === "sql") {
131+
emitSqlView(entity, cls.source, root, joinTables, out);
157132
continue;
158133
}
159-
if (readOnlySource.effectiveKind !== SOURCE_KIND_VIEW) continue;
160134
emitViewFor(entity, root, joinTables, dialect, columnNamingStrategy, out);
161135
}
162136
return out;
163137
}
164138

139+
/**
140+
* #208 §6 — classify a host's read-only source by DDL OWNERSHIP, BEFORE any derivation
141+
* decision. Shared by the projection and write-through loops so the ownership rules can
142+
* never diverge between the two host kinds (the emit TAIL is already shared via emitViewFor).
143+
*
144+
* - no read-only source, OR @unmanaged (external), OR a non-`view` read-only kind →
145+
* "skip". The non-view read-only kinds are hand-managed and MUST NOT reach
146+
* extractViewSpec:
147+
* · storedProc / tableFunction (FR-015) are CALLABLES, base-less → extractViewSpec
148+
* throws (and the CLI calls this unconditionally, so one proc crashed migrate);
149+
* · materializedView has no CREATE-MATVIEW emit and is invisible to
150+
* information_schema.views, so a "managed" matview re-proposes create every run;
151+
* · @unmanaged: Flyway / a hand-migration owns the DDL (§7).
152+
* - @sql read source → "sql": the author owns a verbatim body (emitSqlView).
153+
* - a plain `view` read source → "derive": a tool-synthesized body (the caller applies
154+
* its own viewIsDerived / standalone-read-model gate).
155+
*
156+
* ADR-0039: own — mirrors isProjection/viewName's own-source classification.
157+
*/
158+
type ReadOnlySourceClass =
159+
| { kind: "skip" }
160+
| { kind: "sql"; source: MetaSource }
161+
| { kind: "derive"; source: MetaSource };
162+
163+
function classifyReadOnlySource(host: MetaObject): ReadOnlySourceClass {
164+
const source = host.ownChildren().find(
165+
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
166+
);
167+
if (source === undefined) return { kind: "skip" };
168+
if (source.isUnmanaged) return { kind: "skip" }; // external — Flyway/hand-migration owns it
169+
if (source.sqlBody !== undefined) return { kind: "sql", source }; // author-supplied body
170+
if (source.effectiveKind !== SOURCE_KIND_VIEW) return { kind: "skip" }; // matview/proc/tableFunction
171+
return { kind: "derive", source };
172+
}
173+
165174
/** Extract + emit one host's (projection or write-through entity) view body and
166175
* push it onto `out`. The single tail shared by both walks in buildProjectionViews. */
167176
function emitViewFor(

server/typescript/packages/migrate-ts/src/expected-schema.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,11 @@ export function buildExpectedSchema(
153153
if (hasReadOnlySource && !hasWritableSource) continue;
154154
const tableName = resolveTableName(child);
155155
// #208 §7 — an @unmanaged writable (table) source: emit no descriptor, but keep the
156-
// entity in entityToTable so an inbound FK resolves the physical name.
157-
const writableSource = child.children().find(
156+
// entity in entityToTable so an inbound FK resolves the physical name. OWN-source
157+
// detection (not resolving) to match collectUnmanagedNames, so the skip here and the
158+
// act-side drop-suppression there agree exactly — no split-brain where a table is not
159+
// created yet is still proposed for drop.
160+
const writableSource = child.ownChildren().find(
158161
(c): c is MetaSource => c instanceof MetaSource && c.isWritable(),
159162
);
160163
if (writableSource?.isUnmanaged) {

server/typescript/packages/migrate-ts/src/snapshot/plan.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata";
33
import { buildExpectedSchema } from "../expected-schema.js";
44
import { diff, type DiffArgs } from "../diff/index.js";
5+
import { collectUnmanagedNames } from "../unmanaged.js";
56
import type { Dialect, DiffResult, SchemaSnapshot } from "../types.js";
67
import type { ExpectedViewInput } from "../expected-schema.js";
78

@@ -37,6 +38,10 @@ export async function planOffline(args: PlanOfflineArgs): Promise<PlanOfflineRes
3738
expected: nextSnapshot,
3839
actual: args.snapshot,
3940
dialect: args.dialect,
41+
// #208 §7 — exclude declared-@unmanaged objects from the actual (snapshot) side too,
42+
// so the OFFLINE generate path never proposes DROP for an external table that a
43+
// `baseline --from-db` captured into the snapshot (parity with the online/verify paths).
44+
unmanagedNames: collectUnmanagedNames(args.metadata),
4045
...(args.allow ? { allow: args.allow } : {}),
4146
...(args.onAmbiguous ? { onAmbiguous: args.onAmbiguous } : {}),
4247
...(args.ignoreTables ? { ignoreTables: args.ignoreTables } : {}),

server/typescript/packages/migrate-ts/src/unmanaged.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,28 @@ import {
1111
MetaSource,
1212
resolveTableSchema,
1313
DEFAULT_DB_SCHEMA_POSTGRES,
14-
type MetaRoot,
14+
TYPE_OBJECT,
15+
type MetaData,
1516
} from "@metaobjectsdev/metadata";
1617

1718
/**
1819
* The qualified physical names (`schema.name`, schema defaulting to Postgres `public`)
1920
* of every DB object declared `@unmanaged`. The format matches `tableIdentity` /
2021
* `viewIdentity` in the diff, so one set silences both drop-table and drop-view.
2122
*
22-
* Walks each object's OWN sources: `@unmanaged` is per-source (ADR-0007 — physical
23-
* concerns live on the source child), so a write-through entity can mark only its
24-
* read-view source `@unmanaged` while its table stays managed. Own-source iteration
25-
* also keeps `source.physicalName`'s owner-fallback resolving against the declaring
26-
* object rather than an `extends` super.
23+
* Walks each object's OWN sources — the SAME own-source basis buildExpectedSchema Pass 1
24+
* uses to skip an `@unmanaged` table's descriptor, so the two halves of the diff agree
25+
* (an object skipped from `expected` is also excluded from `actual`): no split-brain
26+
* where a table is not created yet is still proposed for drop. `@unmanaged` is
27+
* per-source (ADR-0007 — physical concerns live on the source child), so a write-through
28+
* entity can mark only its read-view source `@unmanaged` while its table stays managed.
29+
* Own-source iteration also keeps `source.physicalName`'s owner-fallback resolving
30+
* against the declaring object rather than an `extends` super.
2731
*/
28-
export function collectUnmanagedNames(root: MetaRoot): string[] {
32+
export function collectUnmanagedNames(root: MetaData): string[] {
2933
const out: string[] = [];
30-
for (const obj of root.objects()) {
34+
for (const obj of root.children()) {
35+
if (obj.type !== TYPE_OBJECT) continue;
3136
for (const src of obj.ownChildren()) {
3237
if (!(src instanceof MetaSource) || !src.isUnmanaged) continue;
3338
const schema = resolveTableSchema(obj) ?? DEFAULT_DB_SCHEMA_POSTGRES;

server/typescript/packages/migrate-ts/test/snapshot/plan.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,45 @@ describe("planOffline", () => {
4646
});
4747
});
4848

49+
describe("planOffline — #208 @unmanaged (offline DROP suppression)", () => {
50+
const legacyMeta = (unmanaged: boolean) =>
51+
JSON.stringify({
52+
"metadata.root": {
53+
children: [
54+
{
55+
"object.entity": {
56+
name: "Legacy",
57+
children: [
58+
{ "field.long": { name: "id" } },
59+
{
60+
"source.rdb": {
61+
name: "src",
62+
"@table": "legacy_accounts",
63+
...(unmanaged ? { "@unmanaged": true } : {}),
64+
},
65+
},
66+
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
67+
],
68+
},
69+
},
70+
],
71+
},
72+
});
73+
74+
test("does NOT propose drop-table for an @unmanaged table captured in the snapshot (a from-db baseline)", async () => {
75+
// A `baseline --from-db` captured the Flyway-owned table into the committed snapshot...
76+
const snapshot = baselineFromMetadata(await loadJson(legacyMeta(false)), "postgres");
77+
expect(snapshot.tables.map((t) => t.name)).toContain("legacy_accounts");
78+
79+
// ...and the metadata now declares that table @unmanaged. The offline generate path
80+
// must stay silent for it — not propose DROP for an externally-owned table.
81+
const metadata = await loadJson(legacyMeta(true));
82+
const { diff } = await planOffline({ metadata, dialect: "postgres", snapshot });
83+
expect(diff.changes.some((c) => c.kind === "drop-table" && c.table === "legacy_accounts")).toBe(false);
84+
expect(diff.changes).toHaveLength(0);
85+
});
86+
});
87+
4988
describe("baselineFromMetadata", () => {
5089
test("equals buildExpectedSchema for the dialect", async () => {
5190
const metadata = await loadJson(META);

0 commit comments

Comments
 (0)