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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ here.**

## [Unreleased]

### Fixed

- `meta verify --docs` now reports a generated page in the docs root that no current object
produces (for example the `<Object>.md` page of an object that was removed) as drift. It was
silent about every such page outside `agent/`, so an adopter kept ten orphan pages with the
gate green. Ownership rests on the `@generated` marker, so hand-written files in the docs root
and the `api/` subtree are still left alone. No other port ships a docs-verify surface, so
none shares the gap.
- The `hand-rolled-aggregate` advisory now names `object.report` for grouped, served or reported
figures, and says an `object.projection` with `origin.aggregate` still fits a per-row figure.

## [1.1.0] — 2026-10-10

_npm `1.1.0` (all 14 `@metaobjectsdev/*` packages in lockstep), PyPI `1.1.0`, NuGet `1.1.0` and
Expand Down
15 changes: 9 additions & 6 deletions docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ vocabulary everywhere, each port implementing the modes it supports.
| `verify --db` | **Schema drift** — does the live database (or snapshot) match the metadata? (migrate engine, ADR-0015) | yes |
| `verify --codegen` | **Codegen drift** — regenerate from metadata into a temp dir and fail if it differs from the committed generated output. Catches "metadata changed but `meta gen` wasn't re-run". A hand-edited generated file is NOT drift — `meta gen` three-way-merges hand edits by design, so the gate compares the *generated contribution* against the committed `.gen-state/.hashes.json` rather than the file byte-for-byte. It still LISTS every generated file carrying a hand edit (count + paths, and how to see the edit) as a notice that does not change the exit code; `--forbid-hand-edits` makes any hand edit fail the build, for teams that want generated code untouchable (Node `meta` only). | no |
| `verify --templates` | **Template/prompt drift** — `Renderer.verify` checks each `template.*` node's `{{field}}` references against its payload VO (FR-004). | no |
| `verify --docs` | **Docs drift** — run `meta docs` into a temp dir and fail if a committed page differs, if a page a fresh run emits was never committed AND is not git-ignored, or if a generated page under `agent/` is committed that a fresh run no longer emits. Catches "the model moved and nobody re-ran `meta docs`". It CALLS the docs command rather than reimplementing it, so the gate and the door cannot become two answers to what the docs are. **Node `meta` only** (`meta docs` is the TS door). | no |
| `verify --docs` | **Docs drift** — run `meta docs` into a temp dir and fail if a committed page differs, if a page a fresh run emits was never committed AND is not git-ignored, or if a generated page (marked with `@generated`) in the docs root or under `agent/` is committed that a fresh run no longer emits. Catches "the model moved and nobody re-ran `meta docs`". It CALLS the docs command rather than reimplementing it, so the gate and the door cannot become two answers to what the docs are. **Node `meta` only** (`meta docs` is the TS door). | no |
| `verify --deps` | **Dependency drift** (FR-023) — re-resolves each declared dependency's `path` and compares its installed artifact's hash against `.metaobjects/deps.lock.json`, same comparison as `meta deps check`. Needs the publisher's `path` reachable, so it is never part of the bare-`verify` default. A `drifted` or `unresolved` dependency fails with `ERR_DEPENDENCY_UPSTREAM_DRIFT`. **Node `meta` only.** See [`metadata-dependencies.md`](metadata-dependencies.md). | no |

Rules of the contract:
Expand All @@ -75,11 +75,14 @@ Rules of the contract:
A byte difference IS drift: a docs page is read, never imported, so there is no
three-way merge to honour.

It reports a committed file as EXTRA only under `agent/`, and only when the file
opens with the `@generated` marker — the one tree it can prove it wrote.
It reports a committed file as EXTRA when the file opens with the `@generated`
marker. Ownership is proved by marker in two locations: the per-object model pages
in the docs root (e.g. `<Object>.md`), and everything under `agent/`. Hand-written
files in the root carry no marker and are left alone. `api/` stays out of
jurisdiction, since it is written by the adopter on a multi-port project.
`docs.outDir` defaults to `./docs`, which in a real repository is full of
hand-written documentation MetaObjects did not write, and with no manifest to prove
ownership the gate has no standing to convict one elsewhere.
hand-written documentation MetaObjects did not write, and with no marker those are
not checked.

**A page the project GIT-IGNORES is exempt.** `docs.outDir` is a directory, not a
namespace MetaObjects owns — the jurisdiction rule `--codegen` got in 0.24.3 — but
Expand Down Expand Up @@ -496,7 +499,7 @@ the construct that replaces it:

| Hand-rolled pattern | Suggested construct |
|---|---|
| an aggregate computed by hand (SQL `AVG`/`SUM`, a summing `.reduce(...)`) | `origin.aggregate` (on an `object.projection`) |
| an aggregate computed by hand (SQL `AVG`/`SUM`, a summing `.reduce(...)`) | For grouped, served or reported figures: `object.report` (dimensions + measures, read-only list route). For a per-row figure on one entity: `object.projection` with `origin.aggregate` and a generated query. |
| money as a float / hand-rolled minor units (a money-named field with `* 100`, `/ 100`, `.toFixed(2)`, `parseFloat`) | `field.currency` |
| a fixed value set enforced by a SQL `CHECK (... IN (...))` | `field.enum` |

Expand Down
6 changes: 4 additions & 2 deletions server/typescript/packages/cli/src/lib/anti-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,10 @@ function matchLine(line: string, isSql: boolean):

const ADVICE: Record<AntiPatternFinding["rule"], string> = {
"hand-rolled-aggregate":
"you're computing an aggregate by hand — MetaObjects derives it: declare an " +
"object.projection with an origin.aggregate child and call its generated query",
"you're computing an aggregate by hand — MetaObjects derives it: for grouped, " +
"served or reported figures declare an object.report (dimensions + measures, a " +
"read-only list route); an object.projection with an origin.aggregate child still " +
"fits a per-row figure on one entity read through its generated query",
"money-float":
"money handled as a float / hand-rolled minor units — MetaObjects has " +
"field.currency (integer minor units stored, formatted client-side)",
Expand Down
13 changes: 10 additions & 3 deletions server/typescript/packages/cli/src/lib/docs-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@
// `agent/schema.md` describing the previous schema passed the gate on exactly the
// change it most needs to flag.
//
// The residual cost is stated rather than hidden: outside `agent/`, a page for an
// entity that was DELETED stays committed and this gate stays green.
// The docs ROOT is owned the same way, by marker alone: the per-object model pages
// (`<Object>.md`) live there, and a page for an object that was REMOVED is exactly a
// committed file carrying our marker that no fresh run emits. Hand-written files in
// the root carry no marker and are left alone. `api/` stays out of jurisdiction.

import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, lstatSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -116,6 +118,11 @@ function listFiles(dir: string): string[] {
*/
const OWNED_PREFIXES = ["agent/"] as const;

/** True for a page directly in the docs root — where the per-object model pages are
* written. Ownership there rests on the marker alone, since the root is shared with
* hand-written documentation (which carries no marker). */
const isDocsRoot = (normalized: string): boolean => !normalized.includes("/");

/** The marker every generated docs page opens with — the on-disk ownership proof. */
const GENERATED_MARKER = "@generated";

Expand All @@ -127,7 +134,7 @@ const GENERATED_MARKER = "@generated";
*/
function isOurs(docsDir: string, rel: string): boolean {
const normalized = toPosix(rel);
if (!OWNED_PREFIXES.some((p) => normalized.startsWith(p))) return false;
if (!isDocsRoot(normalized) && !OWNED_PREFIXES.some((p) => normalized.startsWith(p))) return false;
try {
return readFileSync(join(docsDir, rel), "utf8")
.split("\n", 3)
Expand Down
4 changes: 4 additions & 0 deletions server/typescript/packages/cli/test/anti-patterns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ describe("scanSourceForAntiPatterns", () => {
expect(f.construct).toBe("origin.aggregate");
expect(f.file).toBe("src/routes/recipes.ts");
expect(f.message).toContain("meta types origin.aggregate");
// 1.1: a served, read-only aggregate is usually an object.report; the advice names
// it and says when a projection still fits.
expect(f.message).toContain("object.report");
expect(f.message).toContain("object.projection");
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ describe("meta verify --docs", () => {
}
});

test("FAILS when the page of a REMOVED object is still committed in the docs root", async () => {
const dir = project();
try {
await generateDocs(dir);
// An object was removed from the model and `meta docs` was re-run: nothing deletes
// the old `<Object>.md`, and the gate used to pass over it. Simulated by copying a
// generated model page to a name no run emits.
const stale = readFileSync(join(dir, DOCS_ROOT, "Subscriber.md"), "utf8");
writeFileSync(join(dir, DOCS_ROOT, "RemovedStats.md"), stale);
expect(await run(["verify", "--cwd", dir, "--docs"])).toBe(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("a dangling symlink in the docs tree does not take the gate down", async () => {
const dir = project();
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
// Source metadata: Brewer (Brewer)
// Extend in your own module (e.g. Brewer.extra.ts) — nothing imports it for you.
import { eq } from "drizzle-orm";
import type { z } from "zod";

import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
type Db = BaseSQLiteDatabase<
"sync" | "async",
unknown,
Record<string, unknown>
>;

import {
type Brewer,
BrewerInsertPreservingSchema,
BrewerInsertSchema,
type BrewerPatch,
brewers,
BrewerUpdateSchema,
} from "./Brewer.js";
export async function findBrewerById(
db: Db,
id: number,
): Promise<Brewer | null> {
const [brewer] = await db
.select()
.from(brewers)
.where(eq(brewers.id, id))
.limit(1);
return brewer ?? null;
}
export async function listBrewers(
db: Db,
opts?: { limit?: number; offset?: number },
): Promise<Brewer[]> {
let q = db.select().from(brewers).$dynamic();
if (opts?.limit !== undefined) {
q = q.limit(opts.limit);
}
if (opts?.offset !== undefined) {
q = q.offset(opts.offset);
}
return q;
}
export async function createBrewer(
db: Db,
data: z.input<typeof BrewerInsertSchema>,
): Promise<Brewer> {
const validated = BrewerInsertSchema.parse(data);
const [brewer] = await db.insert(brewers).values(validated).returning();
return brewer!;
}
export async function insertPreservingBrewer(
db: Db,
data: z.input<typeof BrewerInsertPreservingSchema>,
): Promise<Brewer> {
const validated = BrewerInsertPreservingSchema.parse(data);
const [brewer] = await db.insert(brewers).values(validated).returning();
return brewer!;
}
export async function updateBrewer(
db: Db,
id: number,
patch: BrewerPatch,
): Promise<Brewer | null> {
const validated = BrewerUpdateSchema.parse(patch);
// PATCH-5: an empty patch is a no-op — return the current row rather than let
// Drizzle throw on an empty SET clause.
if (Object.keys(validated).length === 0) {
return findBrewerById(db, id);
}
const [brewer] = await db
.update(brewers)
.set(validated)
.where(eq(brewers.id, id))
.returning();
return brewer ?? null;
}
export async function deleteBrewerById(db: Db, id: number): Promise<boolean> {
// Use .returning() unconditionally — supported on SQLite ≥3.35 (covers D1, libsql/Turso)
// and Postgres. Result is an array of deleted rows; presence implies success.
const deleted = await db
.delete(brewers)
.where(eq(brewers.id, id))
.returning();
return deleted.length > 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
// Source metadata: Brewer (Brewer)
// Extend in your own module (e.g. Brewer.extra.ts) — nothing imports it for you.
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { z } from "zod";

export const brewers = sqliteTable("brewers", {
id: integer("id").primaryKey({ autoIncrement: true }),
displayName: text("display_name").notNull(),
email: text("email").notNull(),
createdAt: text("created_at")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export type Brewer = InferSelectModel<typeof brewers>;
export type BrewerInsert = InferInsertModel<typeof brewers>;
export type BrewerUpdate = Partial<BrewerInsert>;
/** A field.timestamp instant in UTC (`YYYY-MM-DDTHH:MM:SS[.fff]Z`): a value already spelled so is kept
* as sent, an offset is applied, a zoneless value is UTC and a date alone is midnight UTC.
* SQLite/D1 compare timestamps as text, so instants are stored in UTC. Generated. */
function utcIsoTimestamp(v: string): string {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(v)) {
return v;
}
const m =
/^(\d{4}-\d{2}-\d{2})(?:[Tt ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:([Zz])|([+-]\d{2}):?(\d{2})?)?)?$/.exec(
v,
);
if (m === null) {
return v;
}
const t = Date.parse(
`${m[1]}T${m[2] ?? "00:00"}:${m[3] ?? "00"}.${(m[4] ?? "").slice(0, 3).padEnd(3, "0")}${
m[6] === undefined ? "Z" : `${m[6]}:${m[7] ?? "00"}`
}`,
);
return Number.isNaN(t)
? v
: new Date(t).toISOString().replace(/\.?0+Z$/, "Z");
}

export const BrewerInsertSchema = z.object({
displayName: z.string().min(1).max(80),
email: z.string().min(1),
createdAt: z
.string()
.optional()
.transform(() => new Date().toISOString()),
});

export const BrewerUpdateSchema = z.object({
displayName: z.string().min(1).max(80).optional(),
email: z.string().min(1).optional(),
});

/** Typed create shape for Brewer: the insert schema's INPUT (pre-transform) type. A
* renamed/dropped/misspelt field is a compile error at every `createBrewer` call site;
* the schema still validates at runtime. */
export type BrewerCreate = z.input<typeof BrewerInsertSchema>;

/** Typed patch shape for Brewer: every settable field, optional (FR-035 PATCH). A
* renamed/dropped field is a compile error at every `updateBrewer` call site. */
export type BrewerPatch = z.input<typeof BrewerUpdateSchema>;

/** Insert-shape for import / restore / replication of Brewer: identical to
* BrewerInsertSchema, but the @autoSet timestamp columns are written VERBATIM
* (no create-time now() stamp) so the caller's original values are preserved. */
export const BrewerInsertPreservingSchema = z.object({
displayName: z.string().min(1).max(80),
email: z.string().min(1),
createdAt: z
.string()
.regex(
/^(?:\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8]))|(?:\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)(?:[Tt ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:[Zz]|[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?)?)?$/,
"must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])",
)
.transform(utcIsoTimestamp),
});

/** Typed input of `insertPreservingBrewer` — BrewerInsertPreservingSchema's pre-transform shape. */
export type BrewerCreatePreserving = z.input<
typeof BrewerInsertPreservingSchema
>;
/**
* Metadata constants for Brewer.
*
* Use these instead of magic strings so TS catches typos and refactors stay
* coherent. Each non-dollar-prefixed key is a per-field object carrying
* name, label, view, an htmlType where the field's view maps to one, and the
* RHF-shaped validation rules derived from the field's validator children.
*
* placeholder and helpText are NOT emitted — no provider registers an attribute
* for them, so nothing here could derive one. (htmlType is emitted, but derived
* from the view subtype; the @htmlType override was removed for the same reason.)
* useEntityForm still honours placeholder if you add it: this file is generated,
* hand edits inside it survive regeneration through the three-way merge, and the
* consumers read this const rather than the metadata. That is the intended way
* to set it.
*
* Typical usage with the metaobjects React form helper:
*
* import { useEntityForm } from '@metaobjectsdev/react';
* const form = useEntityForm(Brewer, BrewerInsertSchema);
* <input {...form.input.id} />
*/
export const Brewer = {
$entity: "Brewer",
$table: "brewers",
$path: "/brewers",
id: { name: "id", label: "Id", view: "number", htmlType: "number" },
displayName: {
name: "displayName",
label: "Display Name",
view: "text",
htmlType: "text",
rules: {
required: "Display Name is required",
maxLength: { value: 80, message: "Must be 80 characters or fewer" },
},
},
email: {
name: "email",
label: "Email",
view: "text",
htmlType: "text",
rules: { required: "Email is required" },
},
createdAt: {
name: "createdAt",
label: "Created At",
view: "date",
htmlType: "datetime-local",
rules: { required: "Created At is required" },
},
} as const;
export type BrewerFilter = {
limit?: number;
offset?: number;
sort?: string;
or?: BrewerFilter[];
and?: BrewerFilter[];
};
Loading
Loading