diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aed4c193..1250cd229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.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 diff --git a/docs/features/cli.md b/docs/features/cli.md index 07fdcf5e6..a5038b14c 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -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: @@ -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. `.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 @@ -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` | diff --git a/server/typescript/packages/cli/src/lib/anti-patterns.ts b/server/typescript/packages/cli/src/lib/anti-patterns.ts index 2a9470be5..a9179e183 100644 --- a/server/typescript/packages/cli/src/lib/anti-patterns.ts +++ b/server/typescript/packages/cli/src/lib/anti-patterns.ts @@ -202,8 +202,10 @@ function matchLine(line: string, isSql: boolean): const ADVICE: Record = { "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)", diff --git a/server/typescript/packages/cli/src/lib/docs-drift.ts b/server/typescript/packages/cli/src/lib/docs-drift.ts index 1b3e002c8..9f9343840 100644 --- a/server/typescript/packages/cli/src/lib/docs-drift.ts +++ b/server/typescript/packages/cli/src/lib/docs-drift.ts @@ -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 +// (`.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"; @@ -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"; @@ -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) diff --git a/server/typescript/packages/cli/test/anti-patterns.test.ts b/server/typescript/packages/cli/test/anti-patterns.test.ts index 404238272..cf3753e7e 100644 --- a/server/typescript/packages/cli/test/anti-patterns.test.ts +++ b/server/typescript/packages/cli/test/anti-patterns.test.ts @@ -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 }); } diff --git a/server/typescript/packages/cli/test/integration/verify-docs-drift.test.ts b/server/typescript/packages/cli/test/integration/verify-docs-drift.test.ts index be3192b9f..26cf11f85 100644 --- a/server/typescript/packages/cli/test/integration/verify-docs-drift.test.ts +++ b/server/typescript/packages/cli/test/integration/verify-docs-drift.test.ts @@ -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 `.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 { diff --git a/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Brewer.queries.ts b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Brewer.queries.ts new file mode 100644 index 000000000..9d45e61b9 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Brewer.queries.ts @@ -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 +>; + +import { + type Brewer, + BrewerInsertPreservingSchema, + BrewerInsertSchema, + type BrewerPatch, + brewers, + BrewerUpdateSchema, +} from "./Brewer.js"; +export async function findBrewerById( + db: Db, + id: number, +): Promise { + 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 { + 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, +): Promise { + 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, +): Promise { + 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 { + 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 { + // 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; +} diff --git a/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Brewer.ts b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Brewer.ts new file mode 100644 index 000000000..71819b99d --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Brewer.ts @@ -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; +export type BrewerInsert = InferInsertModel; +export type BrewerUpdate = Partial; +/** 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; + +/** 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; + +/** 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); + * + */ +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[]; +}; diff --git a/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Carrier.ts b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Carrier.ts new file mode 100644 index 000000000..50441447e --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Carrier.ts @@ -0,0 +1,98 @@ +// @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: Carrier (Carrier) +// Extend in your own module (e.g. Carrier.extra.ts) — nothing imports it for you. +import { z } from "zod"; + +export interface Carrier { + id?: number | undefined; + partyType?: CarrierPartyType | null | undefined; + label: string; + fleetSize?: number | null | undefined; +} +export type CarrierPartyType = "Carrier"; +export const CarrierPartyTypeEnum = z.enum(["Carrier"]); +export const CarrierSchema = z.object({ + id: z.number().int(), + partyType: z.literal("Carrier"), + label: z.string().min(1), + fleetSize: z.number().int().nullable(), +}); +export const CarrierInsertSchema = z.object({ + partyType: z.literal("Carrier"), + label: z.string().min(1), + fleetSize: z.number().int().optional(), +}); + +export const CarrierUpdateSchema = z.object({ + label: z.string().min(1).optional(), + fleetSize: z.number().int().optional().nullable(), +}); + +/** Typed create shape for Carrier: the insert schema's INPUT (pre-transform) type. A + * renamed/dropped/misspelt field is a compile error at every `createCarrier` call site; + * the schema still validates at runtime. */ +export type CarrierCreate = z.input; + +/** Typed patch shape for Carrier: every settable field, optional (FR-035 PATCH). A + * renamed/dropped field is a compile error at every `updateCarrier` call site. */ +export type CarrierPatch = z.input; +/** + * Metadata constants for Carrier. + * + * 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(Carrier, CarrierInsertSchema); + * + */ +export const Carrier = { + $entity: "Carrier", + $table: "parties", + $path: "/parties/carrier", + id: { name: "id", label: "Id", view: "number", htmlType: "number" }, + partyType: { + name: "partyType", + label: "Party Type", + view: "dropdown", + options: ["Carrier"] as const, + }, + label: { + name: "label", + label: "Label", + view: "text", + htmlType: "text", + rules: { required: "Label is required" }, + }, + fleetSize: { + name: "fleetSize", + label: "Fleet Size", + view: "number", + htmlType: "number", + }, +} as const; +import type { FilterAllowlist } from "@metaobjectsdev/runtime-ts/drizzle-fastify"; + +export const CarrierFilterAllowlist = {} as const satisfies FilterAllowlist; +import type { SortAllowlist } from "@metaobjectsdev/runtime-ts/drizzle-fastify"; + +export const CarrierSortAllowlist = {} as const satisfies SortAllowlist; +export type CarrierFilter = { + limit?: number; + offset?: number; + sort?: string; + or?: CarrierFilter[]; + and?: CarrierFilter[]; +}; diff --git a/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Party.queries.ts b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Party.queries.ts new file mode 100644 index 000000000..62a1de1fc --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Party.queries.ts @@ -0,0 +1,106 @@ +// @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: Party (Party) — TPH discriminator base +// Extend in your own module (e.g. Party.extra.ts) — nothing imports it for you. +import { and, eq } from "drizzle-orm"; +import type { z } from "zod"; +import { type Carrier, CarrierInsertSchema, CarrierSchema } from "./Carrier.js"; +import { parseParty, parties, type Party } from "./Party.js"; + +import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; +type Db = BaseSQLiteDatabase< + "sync" | "async", + unknown, + Record +>; +export async function findPartyById(db: Db, id: number): Promise { + const [row] = await db + .select() + .from(parties) + .where(eq(parties.id, id)) + .limit(1); + return row ? parseParty(row) : null; +} + +export async function listParties( + db: Db, + opts?: { limit?: number; offset?: number }, +): Promise { + let q = db.select().from(parties).$dynamic(); + if (opts?.limit !== undefined) { + q = q.limit(opts.limit); + } + if (opts?.offset !== undefined) { + q = q.offset(opts.offset); + } + const rows = await q; + return rows.map((r) => parseParty(r)); +} +export async function listCarriers( + db: Db, + opts?: { limit?: number; offset?: number }, +): Promise { + let q = db + .select() + .from(parties) + .where(eq(parties.partyType, "Carrier")) + .$dynamic(); + if (opts?.limit !== undefined) { + q = q.limit(opts.limit); + } + if (opts?.offset !== undefined) { + q = q.offset(opts.offset); + } + const rows = await q; + return rows.map((r) => CarrierSchema.parse(r)); +} + +export async function findCarrierById( + db: Db, + id: number, +): Promise { + const [row] = await db + .select() + .from(parties) + .where(and(eq(parties.id, id), eq(parties.partyType, "Carrier"))) + .limit(1); + return row ? CarrierSchema.parse(row) : null; +} + +export async function createCarrier( + db: Db, + data: z.input, +): Promise { + const validated = CarrierInsertSchema.parse(data); + const [row] = await db + .insert(parties) + .values({ ...validated, partyType: "Carrier" }) + .returning(); + return CarrierSchema.parse(row!); +} + +export async function updateCarrierById( + db: Db, + id: number, + data: Partial>, +): Promise { + const validated = CarrierInsertSchema.partial().parse(data) as Record< + string, + unknown + >; + // The discriminator is immutable — a Carrier can never become another subtype. + const { ["partyType"]: _disc, ...safe } = validated; + const [row] = await db + .update(parties) + .set(safe) + .where(and(eq(parties.id, id), eq(parties.partyType, "Carrier"))) + .returning(); + return row ? CarrierSchema.parse(row) : null; +} + +export async function deleteCarrierById(db: Db, id: number): Promise { + const deleted = await db + .delete(parties) + .where(and(eq(parties.id, id), eq(parties.partyType, "Carrier"))) + .returning(); + return deleted.length > 0; +} diff --git a/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Party.ts b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Party.ts new file mode 100644 index 000000000..8d4e05c5a --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/Party.ts @@ -0,0 +1,108 @@ +// @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: Party (Party) +// Extend in your own module (e.g. Party.extra.ts) — nothing imports it for you. +import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; +import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { z } from "zod"; +import { type Carrier, CarrierSchema } from "./Carrier.js"; + +export const parties = sqliteTable( + "parties", + { + id: integer("id").primaryKey({ autoIncrement: true }), + partyType: text("party_type", { enum: ["Carrier"] as const }), + label: text("label").notNull(), + fleetSize: integer("fleet_size"), + }, + (table) => [check("parties_party_type_chk", sql`party_type IN ('Carrier')`)], +); +export type PartyRow = InferSelectModel; +export type PartyInsert = InferInsertModel; +export type PartyUpdate = Partial; +export type PartyPartyType = "Carrier"; +export const PartyPartyTypeEnum = z.enum(["Carrier"]); +export const PartyInsertSchema = z.object({ + partyType: z.enum(["Carrier"]).optional(), + label: z.string().min(1), +}); + +export const PartyUpdateSchema = z.object({ + partyType: z.enum(["Carrier"]).optional().nullable(), + label: z.string().min(1).optional(), +}); + +/** Typed create shape for Party: the insert schema's INPUT (pre-transform) type. A + * renamed/dropped/misspelt field is a compile error at every `createParty` call site; + * the schema still validates at runtime. */ +export type PartyCreate = z.input; + +/** Typed patch shape for Party: every settable field, optional (FR-035 PATCH). A + * renamed/dropped field is a compile error at every `updateParty` call site. */ +export type PartyPatch = z.input; +/** + * Metadata constants for Party. + * + * 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(Party, PartyInsertSchema); + * + */ +export const Party = { + $entity: "Party", + $table: "parties", + $path: "/parties", + id: { name: "id", label: "Id", view: "number", htmlType: "number" }, + partyType: { + name: "partyType", + label: "Party Type", + view: "dropdown", + options: ["Carrier"] as const, + }, + label: { + name: "label", + label: "Label", + view: "text", + htmlType: "text", + rules: { required: "Label is required" }, + }, +} as const; +export type PartyFilter = { + limit?: number; + offset?: number; + sort?: string; + or?: PartyFilter[]; + and?: PartyFilter[]; +}; +export type Party = Carrier; + +/** True when value is a Carrier (discriminated by partyType === "Carrier"). */ +export function isCarrier(value: Party): value is Carrier { + return value.partyType === "Carrier"; +} + +/** + * Parse a row from the Party table, dispatching by the + * `partyType` discriminator value to the matching subtype's + * Zod schema. Throws on unknown discriminator values. + */ +export function parseParty(row: unknown): Party { + const head = z.object({ partyType: z.enum(["Carrier"]) }).parse(row); + switch (head.partyType) { + case "Carrier": + return CarrierSchema.parse(row); + } +} diff --git a/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/caller.ts b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/caller.ts new file mode 100644 index 000000000..14770a217 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tmp-typed-create-sqlite-NdCqpM/caller.ts @@ -0,0 +1,7 @@ +import { createBrewer, insertPreservingBrewer } from "./Brewer.queries"; +import { createCarrier, updateCarrierById } from "./Party.queries"; +declare const db: Parameters[0]; +declare const tphDb: Parameters[0]; +export async function run(): Promise { + await createBrewer(db, { email: "cy@example.com" }); +}