Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,30 @@ until you regenerate.

### Fixed

- **`meta verify --db` no longer reports a matching view as drift, and says what differs when a
view does not.** An adopter on 1.1.0-rc.2 rebuilt a SQLite database from the committed chain:
every report and projection view's stored SQL was byte-identical to the metadata's, yet
`meta verify --db` and `meta migrate --from-db` listed each one as `- view` / `+ view` and
`verify` exited 1, with nothing in the text or `--format json` saying what differed. The cause
was not the view comparison. When a migration alters a table (a column on every dialect, and an
FK or CHECK on SQLite and D1, which rebuild the table, #243), the diff drops and recreates every
view that reads it, and that pair looked the same as a view whose definition changed. Every
view change now carries a `reason` (`ViewChangeReason`, exported from
`@metaobjectsdev/migrate-ts` with `isViewRecreateOnly` and `withoutViewRecreates`), and a
recreate whose definition matches is `unchanged`. `verify --db`, the D1 and committed-snapshot
drift gates, `computeDriftFromActual` and `classifyDrift` leave those out, so the table change
is reported alone. `meta migrate` still emits the pair, because the SQL needs it, and adds a
note naming the views that match ("recreated only because the migration alters a table they
read") to its text output and to a new `notes` array in `--format json`. A view that does
differ is reported with why: SQLite and D1 show the first differing excerpt of the definition
text (`definition text differs: metadata «…» vs database «…»`), Postgres says the fingerprint
does not match, and an unstamped Postgres view says it cannot be compared. `verify --format
json` gains a `schemaDrift` section listing each schema difference and its explanation. The
emitted migration SQL does not change. One gate is tightened on the way: an unstamped Postgres
view over an altered table used to be recreated without `--allow adopt-view`; it now needs the
flag, as it does when no table changes. Gated in `migrate-ts` unit and drift tests, the CLI's
`verify-db-view-recreate` test, and new `integration-tests` lanes on a real SQLite (with the D1
diff) and a real Postgres.
- **TypeScript: a report's decimal fields reach the wire as strings on SQLite, as on Postgres
(FR-044).** A ratio is typed `decimal`, the TypeScript read schema types a decimal as
`string`, and SQLite has no decimal: the view computes a `REAL`, which the driver hands the
Expand Down
11 changes: 8 additions & 3 deletions docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,14 +550,19 @@ meta gen --format json → { gen[], summary, help[], antiPatterns: { status
meta verify --format json → { verify[], exitCode, summary, help[],
antiPatterns: { status, total, rows[] },
requirements: { status, total, rows[] },
requirementCounts?, notRepresented[] }
requirementCounts?, schemaDrift?, notRepresented[] }
```

A pass that did **not** run says so (`status: "skipped"` with a `note` giving the
reason) rather than reporting an empty list — "found nothing" and "never looked"
are different answers. `meta verify`'s payload carries each gate's pass/fail
verdict; the per-gate drift **detail** stays on stderr as text, and the payload's
own `notRepresented[]` says so.
verdict. The schema gate's differences are in `schemaDrift` (`changes[]`, one
`{ kind, object, detail }` per difference with the same explanation the text prints,
plus the ledger `findings[]`); every other gate's drift **detail** stays on stderr as
text, and the payload's own `notRepresented[]` says so. A view that matches the
metadata and is recreated only because the migration alters a table it reads is not
drift, so it appears in neither. `meta migrate` still emits that drop/create pair and
names the views in a note (`notes[]` in its structured output).

In a structured run every narration line moves to **stderr**, so stdout is one
parseable document. `--format` is honored by `gen`, `verify` and `migrate`; any
Expand Down
2 changes: 1 addition & 1 deletion docs/features/migrations-and-drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ There are **7 drift sources**, and the toolchain has a guard for each.
|---|---|---|
| **Code-vs-DB** | Codegen — the generated SQL DDL is emitted from the same metadata as the entity / table code. | Build time |
| **Code-vs-API-doc** | Cross-port codegen from the same metadata. | Build time |
| **DB-vs-metadata** | `meta verify --db` (TS CLI) — introspects the live DB and fails if it has drifted from metadata. Includes modeled projection **view bodies** (a changed `CREATE VIEW` is `replace-view` drift); a hand-authored *unmodeled* view is unmanaged and never flagged. A schema concern owned by the Node toolchain regardless of server language; on the JVM ports the runtime auto-create/validator path was removed (ADR-0015) and the `metaobjects:verify` Maven goal is not available. Cloudflare D1 has no client wire protocol, so it can't go through `--db`'s Kysely-driver introspection — use `meta verify --dialect d1 [--d1 <binding>] [--remote]` instead (the same wrangler-shelled-out path `meta migrate --dialect d1` uses); `--remote` is required to check the *deployed* database, not the local `wrangler dev` shadow copy. Pointing `--db file:` at wrangler's local D1 state directory (`.wrangler/state/**/d1/**`) still runs, but only verifies that local copy — `verify` warns when it detects this. | CI on every PR |
| **DB-vs-metadata** | `meta verify --db` (TS CLI) — introspects the live DB and fails if it has drifted from metadata. Includes modeled projection **view bodies** (a changed `CREATE VIEW` is `replace-view` drift, reported with what differs: the first differing excerpt of the text on SQLite/D1, a fingerprint mismatch on Postgres). A view `meta migrate` drops and recreates only because the migration alters a table it reads, with its definition unchanged, is not drift and is not reported; a hand-authored *unmodeled* view is unmanaged and never flagged. A schema concern owned by the Node toolchain regardless of server language; on the JVM ports the runtime auto-create/validator path was removed (ADR-0015) and the `metaobjects:verify` Maven goal is not available. Cloudflare D1 has no client wire protocol, so it can't go through `--db`'s Kysely-driver introspection — use `meta verify --dialect d1 [--d1 <binding>] [--remote]` instead (the same wrangler-shelled-out path `meta migrate --dialect d1` uses); `--remote` is required to check the *deployed* database, not the local `wrangler dev` shadow copy. Pointing `--db file:` at wrangler's local D1 state directory (`.wrangler/state/**/d1/**`) still runs, but only verifies that local copy — `verify` warns when it detects this. | CI on every PR |
| **Migration-vs-metadata** | The Node `meta migrate` emits migrations FROM metadata diffs — they cannot drift from metadata by construction. Schema migrations for **every** port are owned by this Node toolchain (`@metaobjectsdev/cli migrate`, ADR-0015); the C# and Python migrate surfaces were removed. | Build time |
| **Generated-edited** | `@generated` headers in emitted code + three-way merge that preserves hand-edits inside non-generated regions. | Code review |
| **Prompt-vs-payload** | FR-004 `Renderer.verify` parses `{{...}}` references in templates and checks each one exists on the payload VO. | Build time + runtime |
Expand Down
32 changes: 27 additions & 5 deletions server/typescript/packages/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
type WranglerRunner,
} from "../lib/wrangler.js";
import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
import { tokensToAllowOptions, blockedEntriesFor, blockedHintLines } from "../lib/allow.js";
import { tokensToAllowOptions, blockedEntriesFor, blockedHintLines, viewRecreateNote } from "../lib/allow.js";
import { reportLoadError } from "../lib/load-error.js";
import { scanForReferentialActionConflicts } from "../lib/referential-action-advisory.js";
import type { MetaData } from "@metaobjectsdev/metadata";
Expand Down Expand Up @@ -253,6 +253,17 @@ function migrateResultDefaults(dryRun: boolean): Pick<MigrateResultShape, "block
return { blocked: [], ambiguous: [], writtenPaths: [], dryRun };
}

/** `viewRecreateNote` as the `notes` list a result carries: empty when there is nothing to say. */
function viewRecreateNotes(changes: Change[]): string[] {
const note = viewRecreateNote(changes);
return note === undefined ? [] : [note];
}

/** A result's notes as prose lines, for the paths that report through `finishMigrate`. */
function narratedNotes(notes: readonly string[]): string[] {
return notes.map((n) => `migrate: ${n}`);
}

/** The `-- UP -- / -- DOWN --` preview a dry run prints in text format. */
function sqlPreview(sql: { up: string; down: string }): string {
return `-- UP --\n${sql.up}\n\n-- DOWN --\n${sql.down}`;
Expand Down Expand Up @@ -739,6 +750,7 @@ export async function migrateCommand(
let writtenPaths: string[] = [];
/** A dry run's SQL: printed in text format, carried in the document otherwise. */
let dryRunSql: { up: string; down: string } | undefined;
const notes: string[] = [];
let appliedNames: string[] = [];
let applyFailed = false;
let blocked: BlockedEntry[] = [];
Expand Down Expand Up @@ -896,6 +908,7 @@ export async function migrateCommand(
if (diffResult.changes.length === 0) {
// no-op — output will say "No schema changes"
} else {
notes.push(...viewRecreateNotes(diffResult.changes));
let emitted: EmitResult | undefined;
try {
emitted = emit(diffResult.changes, {
Expand Down Expand Up @@ -1051,6 +1064,7 @@ export async function migrateCommand(
applied: appliedNames,
applyFailed,
warnings: hazardWarnings,
notes,
...(dryRunSql !== undefined ? { sql: dryRunSql } : {}),
};
const output =
Expand Down Expand Up @@ -1425,12 +1439,14 @@ export async function runOfflineGenerate(
const { diff: diffResult, nextSnapshot, expected: governedExpected } = plan;
logOutOfScope(plan.outOfScope, plan.importedOutOfScope ?? [], fmt);

const offlineNotes = viewRecreateNotes(diffResult.changes);
const offlineResult = (extra: Partial<MigrateResultShape>): MigrateResultShape => ({
dialect: offlineDialect,
displayUrl: "",
changeCounts: summarizeChanges(diffResult.changes),
...migrateResultDefaults(config.dryRun),
format: config.format,
notes: offlineNotes,
...extra,
});
if (diffResult.blocked.length > 0) {
Expand Down Expand Up @@ -1459,7 +1475,11 @@ export async function runOfflineGenerate(

if (config.dryRun) {
const sql = { up: emitResult.up, down: emitResult.down };
finishMigrate(fmt, [sqlPreview(sql)], migrateResultToData(offlineResult({ sql, warnings: offlineWarnings })));
finishMigrate(
fmt,
[...narratedNotes(offlineNotes), sqlPreview(sql)],
migrateResultToData(offlineResult({ sql, warnings: offlineWarnings })),
);
return 0;
}

Expand All @@ -1482,7 +1502,7 @@ export async function runOfflineGenerate(
await writeSnapshot(path, nextSnapshot);
finishMigrate(
fmt,
[`migrate: wrote ${res.upPath}`, `migrate: wrote ${res.downPath}`],
[`migrate: wrote ${res.upPath}`, `migrate: wrote ${res.downPath}`, ...narratedNotes(offlineNotes)],
migrateResultToData(offlineResult({ writtenPaths: [res.upPath, res.downPath], warnings: offlineWarnings })),
);
return 0;
Expand Down Expand Up @@ -1700,6 +1720,7 @@ async function runD1Migrate(
}

const changeCounts = summarizeChanges(diffResult.changes);
const d1Notes = viewRecreateNotes(diffResult.changes);
warnDataHazards(diffResult.hazards);

// Views are emitted by the one schema-diff path: renderD1 = renderSqlite (which
Expand Down Expand Up @@ -1748,7 +1769,7 @@ async function runD1Migrate(

if (config.dryRun) {
const sql = { up: combinedUp, down: combinedDown };
finishMigrate(fmt, [sqlPreview(sql)], migrateResultToData(d1Result({ sql })));
finishMigrate(fmt, [...narratedNotes(d1Notes), sqlPreview(sql)], migrateResultToData(d1Result({ sql, notes: d1Notes })));
return 0;
}

Expand All @@ -1762,8 +1783,9 @@ async function runD1Migrate(
`migrate: wrote ${writeResult.upPath}`,
`migrate: wrote ${writeResult.downPath}`,
...Object.entries(changeCounts).map(([kind, count]) => ` ${kind}: ${count}`),
...narratedNotes(d1Notes),
],
migrateResultToData(d1Result({ writtenPaths: [writeResult.upPath, writeResult.downPath] })),
migrateResultToData(d1Result({ writtenPaths: [writeResult.upPath, writeResult.downPath], notes: d1Notes })),
);

// 7. Optional --apply: run `wrangler d1 migrations apply`.
Expand Down
48 changes: 43 additions & 5 deletions server/typescript/packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
verifyReplay,
introspect,
diff,
withoutViewRecreates,
readSnapshot,
snapshotPath,
type SchemaSnapshot,
Expand Down Expand Up @@ -382,6 +383,9 @@ export async function verifyCommand(
// Set when the schema gate could not reach or read the database: a failure, but not
// drift, and the payload must not report it as drift.
let schemaRunError: string | undefined;
// The schema gate's findings, for the structured payload: what differs, not only that
// something does. Left undefined when the gate did not compare anything.
let schemaDrift: SchemaDriftSection | undefined;
const schemaExit = await runSchemaVerify();
const codegenExit = runCodegen ? await runCodegenVerify() : 0;
const docsExit = runDocs ? await runDocsVerify() : 0;
Expand Down Expand Up @@ -467,6 +471,7 @@ export async function verifyCommand(
names: nameSection,
deprecations: deprecationSection,
fields: fieldSection,
...(schemaDrift !== undefined ? { schemaDrift } : {}),
errors: schemaRunError !== undefined ? [{ gate: "schema", error: schemaRunError }] : [],
}),
fmt,
Expand Down Expand Up @@ -1478,7 +1483,9 @@ export async function verifyCommand(
// MERGED with the out-of-scope set, and a second key would silently drop that half.
dialect,
});
if (result.changes.length === 0) return [];
// A view recreated only around a table change matches the snapshot (D3) — not a difference.
const changes = withoutViewRecreates(result.changes);
if (changes.length === 0) return [];

return [
// `meta migrate --from-db` is NOT the repair: it writes a snapshot only when it has
Expand All @@ -1488,10 +1495,10 @@ export async function verifyCommand(
// been told everything is in sync. `baseline --from-db` rewrites it unconditionally,
// which is the whole point of the subcommand.
`the committed schema snapshot disagrees with ${displayUrl} ` +
`(${result.changes.length} difference(s)) — the next 'meta migrate' would emit DDL from it ` +
`(${changes.length} difference(s)) — the next 'meta migrate' would emit DDL from it ` +
`and fail at apply. Re-derive it with ` +
`'meta migrate baseline --from-db --db <url> --dialect ${dialect}'.`,
...summarizeDrift(result.changes),
...summarizeDrift(changes),
];
}

Expand All @@ -1515,6 +1522,7 @@ export async function verifyCommand(
}

const changes = driftResult.changes;
schemaDrift = { changes: changes.map(toSchemaDriftRow), findings: ledgerDrift };
if (changes.length === 0 && ledgerDrift.length === 0) {
say(`meta verify — schema in sync with ${displayUrl}.`);
return 0;
Expand Down Expand Up @@ -1822,6 +1830,28 @@ function summarizeDrift(changes: Change[]): string[] {
});
}

/** One schema difference in the structured payload — the same text the stderr summary prints. */
interface SchemaDriftRow {
kind: Change["kind"];
object: string;
detail: string;
}

/**
* The schema gate's findings in the structured payload. An adopter whose `verify --db` failed
* on views could not tell from `--format json` what differed (D3): the payload carried the
* verdict only. `changes` is the metadata↔database comparison; `findings` are the
* migration-ledger and committed-snapshot lines, which are text by construction.
*/
interface SchemaDriftSection {
changes: SchemaDriftRow[];
findings: string[];
}

function toSchemaDriftRow(c: Change): SchemaDriftRow {
return { kind: c.kind, object: DRIFT_PRESENTATION[c.kind].noun, detail: describeChange(c) };
}

// ---------------------------------------------------------------------------
// structured output (--format toon|json)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1948,6 +1978,8 @@ function buildVerifyPayload(input: {
names: AdvisorySection<AdvisoryDiagnosticRow>;
deprecations: AdvisorySection<AdvisoryDiagnosticRow>;
fields: AdvisorySection<AdvisoryDiagnosticRow>;
/** What the schema gate found, when it compared anything. */
schemaDrift?: SchemaDriftSection;
/** Gates that could not run at all (an unreachable database) — failures, not drift. */
errors?: readonly { gate: string; error: string }[];
}): Record<string, unknown> {
Expand Down Expand Up @@ -1988,7 +2020,12 @@ function buildVerifyPayload(input: {

const help: string[] = errors.map((e) =>
`the ${e.gate} gate could not run, which is not drift: ${e.error} — fix the connection and re-run`);
if (drifted.length > 0) {
if (drifted.some((g) => g.gate === "schema") && input.schemaDrift !== undefined) {
help.push(
`the schema gate's differences are in schemaDrift — changes[] (metadata vs database, one row per change) and findings[] (migration ledger and committed snapshot)`,
);
}
if (drifted.some((g) => g.gate !== "schema")) {
help.push(
`the failing gate's drift DETAIL is printed as text on stderr — this payload carries the verdict only`,
);
Expand Down Expand Up @@ -2041,11 +2078,12 @@ function buildVerifyPayload(input: {
deprecations: input.deprecations,
fields: input.fields,
...(input.requirementCounts !== undefined ? { requirementCounts: input.requirementCounts } : {}),
...(input.schemaDrift !== undefined ? { schemaDrift: input.schemaDrift } : {}),
// The honest boundary. Everything named here is REACHABLE — it is printed as
// text on stderr — but it is not in this document, and a reader must not have
// to discover that by its absence.
notRepresented: [
"per-gate drift detail (which template variable drifted, which schema change, which generated file differs, which migration failed to replay) — printed as text on stderr; this payload carries each gate's pass/fail verdict",
"per-gate drift detail for every gate but schema (which template variable drifted, which generated file differs, which migration failed to replay) — printed as text on stderr; this payload carries each gate's pass/fail verdict, and the schema gate's differences in schemaDrift",
"the loader's own warnings and the agent-context/manifest advisories — printed as text on stderr",
],
};
Expand Down
Loading
Loading