Skip to content

Commit 973279c

Browse files
dmealingclaude
andcommitted
feat(#208): @SQL view — golden-DDL + D4 hard-error + real-PG adopt-view round-trip
Task 5 tests for the @SQL migrate branch (the verbatim builder + D4 hard-error production code landed with the suppression rule in 62e77df; @SQL rides the existing fingerprint/adopt-view/drop pipeline, so there is no new migrate machinery). - codegen-ts: @SQL on a non-view kind (materializedView) hard-errors at migrate lowering (D4) — legal-in-vocabulary, but v1 TS lowering is @kind: view only. - integration-tests (real Postgres): a hand-written @SQL body emits VERBATIM inside CREATE VIEW + a fingerprint COMMENT stamp; the body actually runs (a seeded active/archived pair filters correctly); a second migrate is a NO-OP despite the opaque (columns-unknown) body — the fingerprint matches. Plus the one-time adoption ceremony: a pre-existing UNSTAMPED view at the @SQL name is BLOCKED pending allow.adoptView, then `migrate --allow adopt-view` stamps it and it converges. TS-only (ADR-0015). codegen-ts 8/8 projection tests; view-lifecycle-pg 14/14; migrate-ts 587 pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 62e77df commit 973279c

2 files changed

Lines changed: 80 additions & 0 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,16 @@ describe("buildProjectionViews — #208 @sql / @unmanaged DDL-ownership escape v
252252
const views = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" });
253253
expect(views.find((vv) => vv.name === "v_node_tree")).toBeUndefined();
254254
});
255+
256+
test("@sql on a non-view kind (materializedView) hard-errors at migrate lowering (D4)", async () => {
257+
// @sql is legal-in-vocabulary on any read-only kind (5-port registration is stable),
258+
// but v1 TS migrate lowering accepts it only on @kind: view — matview needs genuinely
259+
// new introspection (pg_matviews). Actionable hard error, not a silent skip.
260+
const root = await load(
261+
model({ "@kind": "materializedView", "@materializedView": "mv_node_tree", "@sql": RECURSIVE_BODY }),
262+
);
263+
expect(() => buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "snake_case" })).toThrow(
264+
/not yet migrate-managed on @kind: "materializedView"/,
265+
);
266+
});
255267
});

server/typescript/packages/integration-tests/test/view-lifecycle-pg.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ const PASSTHROUGH_STATUS = `{ "field.string": { "name": "status", "children": [
7373
const AGG_WEEK_COUNT = `{ "field.long": { "name": "weekCount", "children": [
7474
{ "origin.aggregate": { "@agg": "count", "@of": "Week.id", "@via": "Program.weeks" } } ] } }`;
7575

76+
// #208 — an @sql projection view: a hand-written body the tool REGISTERS + fingerprints
77+
// + drift-checks but never parses. The body is verbatim SQL over `programs`; the fields
78+
// are pure extends-bound shape (no origin.* — that would be two sources of truth). This
79+
// rides the exact fingerprint/adopt-view pipeline the synthesized body already uses.
80+
const SQL_VIEW_BODY = `SELECT id AS id, title AS title FROM programs WHERE status = 'active'`;
81+
function metaSql(): string {
82+
return `{
83+
"metadata.root": {
84+
"package": "acme",
85+
"children": [
86+
{ "object.entity": { "name": "Program", "children": [
87+
{ "field.long": { "name": "id" } },
88+
{ "field.string": { "name": "title", "@required": true } },
89+
{ "field.string": { "name": "status", "@required": true } },
90+
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
91+
] } },
92+
{ "object.projection": { "name": "ActivePrograms", "children": [
93+
{ "source.rdb": { "@kind": "view", "@view": "v_active_programs", "@sql": ${JSON.stringify(SQL_VIEW_BODY)} } },
94+
{ "identity.primary": { "name": "id", "extends": "Program.id", "@fields": "id" } },
95+
{ "field.long": { "name": "id", "extends": "Program.id" } },
96+
{ "field.string": { "name": "title", "extends": "Program.title" } }
97+
] } }
98+
]
99+
}
100+
}`;
101+
}
102+
76103
let pg: RunningPg;
77104
let k: Kysely<Record<string, unknown>>;
78105

@@ -468,4 +495,45 @@ describe("view lifecycle — real Postgres", () => {
468495
await applyRaw(down);
469496
expect(await viewCols("v_program_summary")).toEqual(["id", "title", "weekCount"]);
470497
});
498+
499+
// -------------------------------------------------------------------------
500+
// #208 — @sql DDL-ownership escape valve: a hand-written body, tool-managed lifecycle.
501+
// -------------------------------------------------------------------------
502+
503+
test("#208 @sql: a hand-written body emits VERBATIM + a fingerprint stamp, then a second migrate is a NO-OP", async () => {
504+
const { expected, up } = await migrate(metaSql());
505+
506+
// The body is the author's verbatim SQL, wrapped in CREATE VIEW — NOT a synthesized
507+
// base-table passthrough SELECT — and stamped so drift-check has something to compare.
508+
expect(up).toContain(`CREATE VIEW "v_active_programs" AS`);
509+
expect(up).toContain(SQL_VIEW_BODY);
510+
expect(up).toMatch(/COMMENT ON VIEW "v_active_programs" IS 'metaobjects:v1:sha256:[0-9a-f]{64}'/);
511+
expect(await viewComment("v_active_programs")).toMatch(/^metaobjects:v1:sha256:[0-9a-f]{64}$/);
512+
513+
// The hand body actually runs: a seeded active/inactive pair filters to the active one.
514+
await sql.raw(`INSERT INTO "programs" ("id","title","status") VALUES (1,'Live','active'),(2,'Old','archived')`).execute(k);
515+
const rows = await sql.raw(`SELECT id, title FROM "v_active_programs" ORDER BY id`).execute(k);
516+
expect(rows.rows).toEqual([{ id: "1", title: "Live" }]);
517+
518+
// THE gate: opaque body (columns unknown) still converges — the fingerprint matches.
519+
await assertConverged(expected);
520+
});
521+
522+
test("#208 @sql: a pre-existing UNSTAMPED view at the name is BLOCKED pending adopt-view, then adopts + converges", async () => {
523+
const m = metaSql();
524+
await migrate(m);
525+
// Strip the stamp: now indistinguishable from a hand-written view Postgres deparsed.
526+
await sql.raw(`COMMENT ON VIEW "v_active_programs" IS NULL`).execute(k);
527+
528+
const expected = await expectedFor(m);
529+
const result = await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres" });
530+
const blocked = result.blocked.find((c) => c.kind === "replace-view");
531+
expect(blocked).toBeDefined();
532+
expect(blocked!.status.blockedReason).toContain("allow.adoptView");
533+
534+
// The one-time adoption ceremony: `migrate --allow adopt-view` stamps it, then silent.
535+
const { expected: exp2 } = await migrate(m, { adoptView: true });
536+
expect(await viewComment("v_active_programs")).toMatch(/^metaobjects:v1:sha256:[0-9a-f]{64}$/);
537+
await assertConverged(exp2);
538+
});
471539
});

0 commit comments

Comments
 (0)