From d05a14a6224e834f733dad180e05f9dda95d284c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 01:51:52 -0500 Subject: [PATCH 1/2] fix: uuid input on Postgres routes, renamed-column view warning, quieter advisories Building one large app on the generated stack turned up six defects: - Generated routes on Postgres answered 500 for ordinary bad uuid input. `?search=` ILIKEd a uuid FK (no `uuid ~~* text` operator); a malformed uuid `:id` reached Postgres as a literal. The search now skips formatted string rules, `coerceIdForColumn` rejects a malformed uuid (400 invalid_id), and the generated Zod schemas check a field.uuid's shape (400 validation). A new integration test runs the stock `routesFile()` output against Postgres with uuid keys and a uuid FK. - `meta migrate` now warns when a view still names a column the migration renames, since the recreate then fails at apply. - The index-name examples are table-scoped and say names are schema-wide; the list-hook name rule is stated correctly everywhere. - `meta verify` advisories: comments are skipped, money-float needs a money-named operand, UI `.reduce` sums are not aggregates, unindexed FKs are one finding, and a structured run does not repeat findings on stderr. --- AGENTS.md | 2 +- CHANGELOG.md | 30 ++++ .../references/identities-and-indexes.md | 18 +- .../references/typescript.md | 2 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- docs/features/reporting.md | 3 +- docs/ports/typescript.md | 2 +- .../.metaobjects/.gen-state/.hashes.json | 134 +++++++++++--- .../runtime/drizzle-fastify/filter-parser.ts | 7 +- .../codegen/runtime/drizzle-fastify/util.ts | 13 +- .../codegen/templates/zod-validators.ts | 11 ++ .../advanced-modeling/src/generated/Lesson.ts | 15 +- .../src/generated/Program.ts | 15 +- .../src/generated/Purchase.ts | 15 +- .../runtime/drizzle-fastify/filter-parser.ts | 7 +- .../codegen/runtime/drizzle-fastify/util.ts | 13 +- .../codegen/templates/zod-validators.ts | 11 ++ examples/showcase/site-payload.json | 4 +- .../references/identities-and-indexes.md | 18 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/identities-and-indexes.md | 18 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/identities-and-indexes.md | 18 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/identities-and-indexes.md | 18 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../references/identities-and-indexes.md | 18 +- .../references/typescript.md | 2 +- .../skills/metaobjects-runtime-ui/SKILL.md | 5 +- .../packages/cli/src/commands/gen.ts | 6 +- .../packages/cli/src/commands/verify.ts | 20 ++- .../packages/cli/src/lib/advisory.ts | 19 +- .../typescript/packages/cli/src/lib/allow.ts | 14 +- .../packages/cli/src/lib/anti-patterns.ts | 85 +++++++-- .../packages/cli/src/lib/fk-index-advisory.ts | 29 +++ .../packages/cli/src/lib/schema-advisories.ts | 4 + .../test/advisory-structured-output.test.ts | 5 +- .../packages/cli/test/anti-patterns.test.ts | 41 +++++ .../verify-db-view-recreate.test.ts | 2 +- .../cli/test/verify-fk-index-lint.test.ts | 45 ++++- .../view-recreate-note-renamed-column.test.ts | 33 ++++ .../src/templates/zod-validators.ts | 11 ++ .../test/zod-validators-execution.test.ts | 23 ++- .../test/generated-uuid-routes-pg.test.ts | 166 ++++++++++++++++++ .../packages/migrate-ts/src/diff/index.ts | 2 + .../packages/migrate-ts/src/index.ts | 2 +- .../packages/migrate-ts/src/types.ts | 6 +- .../migrate-ts/src/view-change-reason.ts | 32 +++- .../unit/view-names-renamed-column.test.ts | 61 +++++++ .../src/drizzle-fastify/filter-parser.ts | 7 +- .../runtime-ts/src/drizzle-fastify/util.ts | 13 +- .../drizzle-fastify/filter-parser.test.ts | 14 ++ .../runtime-ts/test/uuid-input-pg.test.ts | 147 ++++++++++++++++ .../src/templates/zod-validators.ts | 11 ++ 54 files changed, 1092 insertions(+), 130 deletions(-) create mode 100644 server/typescript/packages/cli/test/view-recreate-note-renamed-column.test.ts create mode 100644 server/typescript/packages/integration-tests/test/generated-uuid-routes-pg.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/view-names-renamed-column.test.ts create mode 100644 server/typescript/packages/runtime-ts/test/uuid-input-pg.test.ts diff --git a/AGENTS.md b/AGENTS.md index bde4ffec93..01f80f2052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - YAML / verify corpora green across the ports that ship those layers. - **Codegen-compile gate** (all five ports; a GATE, not a corpus — it has no fixtures of its own and no row in the matrix). Every corpus above gates BEHAVIOUR; none asks whether the emitted code BUILDS, which is how four "generated code does not compile" defects shipped in 1.0.4 with the whole matrix green — `gen` exits 0 in all four cases and the adopter's build is the first thing that disagrees. Each port generates from `fixtures/persistence-conformance/canonical/meta.fitness.json` (reused deliberately: a second kitchen sink would drift from the one the other corpora already maintain) and compiles the emitted tree with its real compiler — `ts.createProgram` / Roslyn / `javac` / `KotlinCompilation` / (Python, having no static compiler) importing the generated package plus `ruff` F821. **Every port excludes its framework-bound route tier** (TS `routesFile`, C# `RoutesGenerator`, Java `SpringControllerGenerator`, Kotlin `KotlinSpringControllerGenerator`): those imports are not on an in-memory compile's classpath and stubbing them drowns the signal, so that tier is proven by the api-contract integration lane instead. One cross-port rule, not four local concessions. Found 5 further real defects on first run. Boundary detail: `docs/CONFORMANCE.md` → "Split coverage". -**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, `@spine` on a report to take rows from a to-one dimension entity, `@default` on a measure for empty aggregates, plus the relative-date filter value — registered and loader-validated in all five ports; a report that declares a read-only `source.rdb @kind: view` is lowered to a SQL view by `meta migrate` and read by every port, and served by a generated read-only list route in every port, gated by the api-contract `report/` sub-corpus; the TypeScript `cube-model` reference generator writes the vocabulary as Cube data-model files, gated by the `fixtures/cube-model/` corpus and a `cube` lane that loads the output into a real Cube; a served report gets the generated TanStack list hook (`useList`) but no grid or form, see [docs/features/reporting.md](docs/features/reporting.md) and [docs/features/cube-export.md](docs/features/cube-export.md)). +**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, `@spine` on a report to take rows from a to-one dimension entity, `@default` on a measure for empty aggregates, plus the relative-date filter value — registered and loader-validated in all five ports; a report that declares a read-only `source.rdb @kind: view` is lowered to a SQL view by `meta migrate` and read by every port, and served by a generated read-only list route in every port, gated by the api-contract `report/` sub-corpus; the TypeScript `cube-model` reference generator writes the vocabulary as Cube data-model files, gated by the `fixtures/cube-model/` corpus and a `cube` lane that loads the output into a real Cube; a served report gets the generated TanStack list hook (`use`, or `useList` when the plural is the name) but no grid or form, see [docs/features/reporting.md](docs/features/reporting.md) and [docs/features/cube-export.md](docs/features/cube-export.md)). **Release 1.1.0** (released; npm `1.1.0`, PyPI `1.1.0`, NuGet `1.1.0` and Maven Central `8.1.0`; the latest is 1.2.0 / 8.2.0). The reporting release (FR-044): declare dimensions, measures, segments and an `object.report`, and get a SQL view, a served read-only list route in every port, a generated TanStack list hook, and a Cube data model, with `@spine` for rows that have no facts and `@default` for an empty measure. Also carries the first slice of requirements (the gate in every port's `verify`), the derived-key parity fix across the ports, and the list-hook and nested-average fixes found by the first adopter. See the `CHANGELOG.md` 1.1.0 section for the full scope, pre-release RC history, and the diffs to expect when you upgrade (ejected generators and keyless projections). It also makes documentation a set of generators in every port (ADR-0034 Amendment 9): the docs commands are removed, and `docs/features/migrations/docs-are-generators.md` is the upgrade path. The last 1.0.x release, 1.0.13 (2026-10-03), was a PATCH on 1.0.x. diff --git a/CHANGELOG.md b/CHANGELOG.md index 918e4c2873..d8c5914af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -495,6 +495,12 @@ Each item below is described in full under Added, Changed or Fixed; this list is ### Documentation +- **Index names and list-hook names, corrected.** The authoring reference's index examples are + table-scoped (`userByEmail`, not `byEmail`) and say an index name is schema-wide, so a repeated + one fails `meta migrate` with `ERR_DUPLICATE_SQL_NAME`. The generated list hook is + `use`, or `useList` when the plural equals the name; the reporting page had it + backwards, and the codegen and runtime-UI skills named only one form. + - **Separate-repo model sharing (FR-023 Phase 2) close-out.** The feature page's Phase 2 section is now the shipped per-port matrix (consume, check, publish, runtime load, transports), the deferred list names what is still not built, and the verify, codegen, authoring, audit and capability-sheet skills @@ -518,6 +524,14 @@ Each item below is described in full under Added, Changed or Fixed; this list is ### Changed +- **Quieter `meta verify` advisories.** The anti-pattern scanner skips comment lines; it flags + minor-unit math (`* 100`, `/ 100`, `.toFixed(2)`, `parseFloat`) only when the operand is + money-named, so hours formatted to two places no longer read as money; and a `.reduce` counts + as a hand-rolled aggregate only when it accumulates with `+` outside `.tsx`/`.jsx` UI source. + Unindexed foreign keys are one finding naming every key and its `index.lookup`, not one per + key. With a `--format` payload on stdout, the text on stderr gives each section's header and a + pointer instead of repeating every finding. + - **The requirements reference pages install in every stack.** They used to install only when `meta init` saw a `requirement.*` node, which a new project has not written yet, so a project that declared requirements later had skills pointing at pages that were not there. `meta gen` @@ -663,6 +677,22 @@ Each item below is described in full under Added, Changed or Fixed; this list is ### Fixed +- **Generated routes on Postgres answer 400, not 500, for bad uuid input.** On an entity with a + uuid foreign key, `?search=` ran ILIKE against the uuid column (the allowlist carries it as + `subType: "string", format: "uuid"`), and Postgres has no `uuid ~~* text` operator. The search + now skips string rules that carry a `format`. A malformed uuid in `:id` reached Postgres as a + literal ("invalid input syntax for type uuid"); `coerceIdForColumn` now rejects it, so GET, + PATCH and DELETE answer 400 `{"error":"invalid_id"}` as a malformed numeric id already did. A + `field.uuid` in the generated insert and update Zod schemas now checks the 8-4-4-4-12 shape, so + a malformed uuid foreign key in a POST or PATCH body answers 400 `validation`. SQLite stores a + uuid as text, so none of this showed there; a new integration test runs the stock + `routesFile()` output against Postgres with uuid keys and a uuid foreign key. Owned copies + pick the fix up when re-ejected. +- **`meta migrate` warns when a renamed column is still named by a view.** A rename never + rewrites a view body, so a view whose `@sql` still names the old column is recreated as + declared and the migration fails at apply. The plan now names the view, the column and its new + name, and the "recreated only because" note no longer lists that view among the harmless ones. + - **An enum's `@default` is what an untouched record stores**. C#: a scalar enum started at `default(TEnum)`, not at `@default`, while the EF sentinel was `@default`, so EF wrote it: a string-stored or int-mapped enum stored its first member, a nullable one NULL, and diff --git a/agent-context/skills/metaobjects-authoring/references/identities-and-indexes.md b/agent-context/skills/metaobjects-authoring/references/identities-and-indexes.md index f21a5d0070..22b3ef1385 100644 --- a/agent-context/skills/metaobjects-authoring/references/identities-and-indexes.md +++ b/agent-context/skills/metaobjects-authoring/references/identities-and-indexes.md @@ -42,10 +42,16 @@ An `identity.secondary` can index an **expression** instead of plain columns: us index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index predicate). +**Name every `identity.secondary` and `index.lookup` after its table.** The name is used +verbatim as the database index name, and index names are unique across the whole schema, +not per table. Two entities that both declare `byName` load and generate cleanly, then +`meta migrate` stops with `ERR_DUPLICATE_SQL_NAME`. Prefix the entity (`clientByName`, +`tagByName`). + ```json { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } } -{ "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } -{ "identity.secondary": { "name": "byEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userByEmail", "@fields": ["email"] } } +{ "identity.secondary": { "name": "userByEmailCI", "@expr": "lower(email)" } } { "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } ``` @@ -73,10 +79,10 @@ The db provider contributes physical-tuning attrs alongside either form: `@order `btree`), and `@where` (partial-index predicate). ```json -{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } -{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } } -{ "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } } -{ "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } } +{ "index.lookup": { "name": "orderByCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } +{ "index.lookup": { "name": "orderByStatusCreatedAt", "@fields": ["status", "createdAt"] } } +{ "index.lookup": { "name": "userByEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userUniqLowerEmail", "@expr": "lower(email)" } } ``` `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`, diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index d0847d17ce..b854c51072 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -215,7 +215,7 @@ view binding, Zod read schema, row type, descriptor, filter and sort allowlists) `.queries.ts` (`list…` only, no by-id), `.routes.ts` (and `.routes.hono.ts` from `routesFileHono()`): GET list, `POST` answers 405, no `/:id`; `.names.ts`; and the barrel export. Every derived field with filter operators is filterable and sortable. The UI tier -writes the list hook and nothing else: `.hooks.ts` (`useList`, typed with the report's +writes the list hook and nothing else: `.hooks.ts` (`use` + the plural of the name (`useMemberUtilizations` for `MemberUtilization`), or `useList` when the name's plural is the name itself (`useSeriesList`), typed with the report's filter, no detail or mutation hook) and its `.meta.ts` descriptor, and `agent/ui.md` lists the report. The hook generator gates on `servesClientHooks`, which is true for a served report; the grid generators gate on `servesClientTier`, which is false, so there is no grid, diff --git a/agent-context/skills/metaobjects-runtime-ui/SKILL.md b/agent-context/skills/metaobjects-runtime-ui/SKILL.md index dd3184a870..6f0302b540 100644 --- a/agent-context/skills/metaobjects-runtime-ui/SKILL.md +++ b/agent-context/skills/metaobjects-runtime-ui/SKILL.md @@ -115,8 +115,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. A view-backed `object.report` has a generated list route at the same rule and, in -TypeScript, a generated list hook (`useList`); it has no grid, form or detail -hook. +TypeScript, a generated list hook named `use` + the plural of the report's name +(`useMemberUtilizations` for `MemberUtilization`; `useList` only when the plural is +the name itself, as in `useSeriesList`); it has no grid, form or detail hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 87e3edb98b..16c3da0f0f 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -617,8 +617,7 @@ encodings"), applied to the column types above: No port generates a by-id query, a `findById` on a repository seam, a create or update schema, a write method, a form or a grid for a report. Only TypeScript has a generated client -tier, so only TypeScript generates a hook: the list hook, `useList` (or `uses` when the -name is not already plural), typed with the report's row, its filter and its sort. It has no +tier, so only TypeScript generates a hook: the list hook, `use` + the plural of the name (`useMemberUtilizations` for `MemberUtilization`), or `useList` when the name's plural is the name itself (`useSeriesList`), typed with the report's row, its filter and its sort. It has no detail hook and no mutation hook, because the report has no item route and no write. The hook generator asks `servesClientHooks`, which is true for a served report; the grid generators ask `servesClientTier`, which is false for one. A hook generator you own was diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index d762566dc2..51e88478ff 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -403,7 +403,7 @@ like a keyless read-only projection. For a report `` the generators you have | `queriesFile()` | `.queries.ts` | `list` only. No by-id query | | `routesFile()` / `routesFileHono()` | `.routes.ts` / `.routes.hono.ts` | GET list; `POST` answers 405; no `/:id` route | | `namesFile()` | `.names.ts` | the view and column names | -| `tanstackQuery()` | `.hooks.ts`, `.meta.ts` | the list hook (`useList`, with the report's filter type) and its descriptor. No detail hook, no mutation | +| `tanstackQuery()` | `.hooks.ts`, `.meta.ts` | the list hook (`use`, or `useList` when the plural is the name; with the report's filter type) and its descriptor. No detail hook, no mutation | | `barrel()` | `index.ts` | one export | Every derived field with filter operators is filterable and sortable. A derived field that can diff --git a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json index 06ad488093..eb4f424ca5 100644 --- a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json +++ b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json @@ -1,28 +1,114 @@ { - "src/generated/Author.form.tsx": "0481f3ab0aa427581cead70d2c44fae1d709e49a0f004fcc070c50c55ede3308", - "src/generated/Author.queries.ts": "42cc1ab2fd442559e1e9c5c3f14978f306efcda5472f8b5370d3339753e5a36f", - "src/generated/Author.routes.ts": "3b0412b4501d02ba8716dae634522202714d467c5f9d577d8ea583868b1187d5", - "src/generated/Author.ts": "36f28e09c3633848bf3bdb45372e03ae8b036792dec05f503a6f0d3abc1bb1cc", - "src/generated/InstructorProfile.ts": "2643b88401edf303789461d7d05bafd7cb1979b0ff19c27958d156192265986a", - "src/generated/Lesson.form.tsx": "10435d259837450502de4261ec6220fd6fdeecdf14fb22e377d8fd6d149c172e", - "src/generated/Lesson.queries.ts": "bc8e9bff3cb70c2c47ecfd07619f90d3f26a3981aca8ce6828f75e2a80fb9273", - "src/generated/Lesson.routes.ts": "ab910b2a1cfe1d38a49ce5d54de149bdbdfcd5adb077363b913ce6bebd01de13", - "src/generated/Lesson.ts": "8368d2aaa7a4d451083eeb27f1e018938f9f605f5227ce6609c46b22e8f267d4", - "src/generated/Program.form.tsx": "42056db5d322bb03e0fc00a197357061854d5a4fb3a304a2e8e04c8fc3689b6f", - "src/generated/Program.queries.ts": "07bde6eda6ec265f3b9b61b73b379ed2a066a9b852ecbda2c6bf7ee5d5d079cf", - "src/generated/Program.routes.ts": "ac43f1836303aa693e26f346398f47330d18ac4ce6a60e03394dc40dee398cc7", - "src/generated/Program.ts": "6c9c347e4839fb6c5dd12694e4cb100ae6bef10cedad7b5236adcbb16a811cfe", - "src/generated/ProgramDescriptionOutput.output.ts": "f13b7b2108bf8507b6aeb1e819edd06eb2d689ed259dc04784d19d62bee9dfd2", + "src/generated/Author.form.tsx": { + "hash": "0481f3ab0aa427581cead70d2c44fae1d709e49a0f004fcc070c50c55ede3308", + "generatedBy": "form-file", + "sourceNode": "acme::learn::Author" + }, + "src/generated/Author.queries.ts": { + "hash": "42cc1ab2fd442559e1e9c5c3f14978f306efcda5472f8b5370d3339753e5a36f", + "generatedBy": "queries-file", + "sourceNode": "acme::learn::Author" + }, + "src/generated/Author.routes.ts": { + "hash": "3b0412b4501d02ba8716dae634522202714d467c5f9d577d8ea583868b1187d5", + "generatedBy": "routes-file", + "sourceNode": "acme::learn::Author" + }, + "src/generated/Author.ts": { + "hash": "36f28e09c3633848bf3bdb45372e03ae8b036792dec05f503a6f0d3abc1bb1cc", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::Author" + }, + "src/generated/InstructorProfile.ts": { + "hash": "2643b88401edf303789461d7d05bafd7cb1979b0ff19c27958d156192265986a", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::InstructorProfile" + }, + "src/generated/Lesson.form.tsx": { + "hash": "10435d259837450502de4261ec6220fd6fdeecdf14fb22e377d8fd6d149c172e", + "generatedBy": "form-file", + "sourceNode": "acme::learn::Lesson" + }, + "src/generated/Lesson.queries.ts": { + "hash": "bc8e9bff3cb70c2c47ecfd07619f90d3f26a3981aca8ce6828f75e2a80fb9273", + "generatedBy": "queries-file", + "sourceNode": "acme::learn::Lesson" + }, + "src/generated/Lesson.routes.ts": { + "hash": "ab910b2a1cfe1d38a49ce5d54de149bdbdfcd5adb077363b913ce6bebd01de13", + "generatedBy": "routes-file", + "sourceNode": "acme::learn::Lesson" + }, + "src/generated/Lesson.ts": { + "hash": "404b7d27b2ec7a6158c6915dbe289c09e36b8fafe8ed82b14871a3bbfa117db8", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::Lesson" + }, + "src/generated/Program.form.tsx": { + "hash": "42056db5d322bb03e0fc00a197357061854d5a4fb3a304a2e8e04c8fc3689b6f", + "generatedBy": "form-file", + "sourceNode": "acme::learn::Program" + }, + "src/generated/Program.queries.ts": { + "hash": "07bde6eda6ec265f3b9b61b73b379ed2a066a9b852ecbda2c6bf7ee5d5d079cf", + "generatedBy": "queries-file", + "sourceNode": "acme::learn::Program" + }, + "src/generated/Program.routes.ts": { + "hash": "ac43f1836303aa693e26f346398f47330d18ac4ce6a60e03394dc40dee398cc7", + "generatedBy": "routes-file", + "sourceNode": "acme::learn::Program" + }, + "src/generated/Program.ts": { + "hash": "a658aad738aa33578fa5e5091494137f0268ea497a6daf5c61422861cdf04806", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::Program" + }, "src/generated/ProgramDescriptionOutput.render.ts": "77691e7f4c7dd8658ad2842fb5ea68a4b79e5c5ec69bb7221c618db303f2eb9f", - "src/generated/ProgramDescriptionPayload.form.tsx": "a9dff101453450117aff3029b2c5259e7d0866667aaab722ba890beaaa20f95d", - "src/generated/ProgramDescriptionPayload.ts": "8df741a3271c6e6e8317041dd088f6b5de5f0f2e95c1b637528992c578e487d7", - "src/generated/ProgramSummary.queries.ts": "743e8ce74eee85bb821cf3d909957fae630085fb1f5a7567dd6bfdcae4364910", - "src/generated/ProgramSummary.routes.ts": "995e893a6b21e65d7bc031761c18bb7bb0c790abb31cb72546bf95573c980099", - "src/generated/ProgramSummary.ts": "181f48ed374dfdf2f5b3213e0064833f071fd7029ed9357a6eef764616c2429c", - "src/generated/Purchase.form.tsx": "cd3556f18246b84e661a423186127cd5f87b1ad955a3afe9cd78ba77fa824b2a", - "src/generated/Purchase.queries.ts": "ae7896ebdf3a57332e2c79dc9dd99fef9547054371547d6a469a752a7fc64ad6", - "src/generated/Purchase.routes.ts": "b1d1df2277754b63f137d5059603abc812a36795656099e7b9df4cf67928549c", - "src/generated/Purchase.ts": "e37c57f29eda8d7d4a13e8d58ab1695f0d07b8a804b6f5abadaba75adaa66484", - "src/generated/SyllabusSection.ts": "655f1fdd3c2f104fe48e8fb9a4147c3977a820f82e2b0a6ce1db8e4bd1b3ca2c", + "src/generated/ProgramDescriptionPayload.ts": { + "hash": "8df741a3271c6e6e8317041dd088f6b5de5f0f2e95c1b637528992c578e487d7", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::ProgramDescriptionPayload" + }, + "src/generated/ProgramSummary.queries.ts": { + "hash": "743e8ce74eee85bb821cf3d909957fae630085fb1f5a7567dd6bfdcae4364910", + "generatedBy": "queries-file", + "sourceNode": "acme::learn::ProgramSummary" + }, + "src/generated/ProgramSummary.routes.ts": { + "hash": "995e893a6b21e65d7bc031761c18bb7bb0c790abb31cb72546bf95573c980099", + "generatedBy": "routes-file", + "sourceNode": "acme::learn::ProgramSummary" + }, + "src/generated/ProgramSummary.ts": { + "hash": "181f48ed374dfdf2f5b3213e0064833f071fd7029ed9357a6eef764616c2429c", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::ProgramSummary" + }, + "src/generated/Purchase.form.tsx": { + "hash": "cd3556f18246b84e661a423186127cd5f87b1ad955a3afe9cd78ba77fa824b2a", + "generatedBy": "form-file", + "sourceNode": "acme::learn::Purchase" + }, + "src/generated/Purchase.queries.ts": { + "hash": "ae7896ebdf3a57332e2c79dc9dd99fef9547054371547d6a469a752a7fc64ad6", + "generatedBy": "queries-file", + "sourceNode": "acme::learn::Purchase" + }, + "src/generated/Purchase.routes.ts": { + "hash": "b1d1df2277754b63f137d5059603abc812a36795656099e7b9df4cf67928549c", + "generatedBy": "routes-file", + "sourceNode": "acme::learn::Purchase" + }, + "src/generated/Purchase.ts": { + "hash": "fa88def0af1a85b18a1d9d7bb99588f0d71273ce0ee4ce633615ab0724a6bf69", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::Purchase" + }, + "src/generated/SyllabusSection.ts": { + "hash": "655f1fdd3c2f104fe48e8fb9a4147c3977a820f82e2b0a6ce1db8e4bd1b3ca2c", + "generatedBy": "entity-file", + "sourceNode": "acme::learn::SyllabusSection" + }, "src/generated/index.ts": "b542ee705d68fe1695f930a14bd7fb54bf8e89f4f0688aaba58b7df2c2fb29db" } diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts index 644e9a9616..540b6626ab 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts @@ -130,7 +130,12 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult { if (typeof opts.query.search === "string" && opts.query.search !== "") { const term = `%${opts.query.search}%`; const stringCols = Object.entries(opts.allowlist) - .filter(([, rule]) => (rule as FilterFieldRule).subType === "string") + // A rule with a `format` (uuid, …) is a typed column on the wire as a string, not + // text: ILIKE on a Postgres uuid column is "operator does not exist" (a 500). + .filter(([, rule]) => { + const r = rule as FilterFieldRule; + return r.subType === "string" && r.format === undefined; + }) .map(([field]) => opts.table[field]); if (stringCols.length > 0) { // `?search` is a TS-only extension (docs/features/api-contract.md) and is diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/util.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/util.ts index a870252adc..d6aaea4bbb 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/util.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/util.ts @@ -2,6 +2,9 @@ * Shared utilities for drizzle-fastify route helpers. */ +import { FIELD_SUBTYPE_UUID } from "@metaobjectsdev/metadata"; +import { matchesFormat } from "./filter-value-format.js"; + /** * Coerce a path-param id to number when numeric, else keep the string key. * @@ -26,8 +29,8 @@ export function parseId(raw: string): number | string { * matches no row (libsql throws outright) — so a row that is plainly present in the list * response 404s on its own detail route. * - * Returns `undefined` only when the id is malformed FOR A NUMERIC column — the one case - * in which a bad id is actually knowable — so the caller can answer 400. With no column + * Returns `undefined` when the id is malformed for a numeric column or a Postgres uuid + * column — the cases in which a bad id is actually knowable — so the caller can answer 400. With no column * to inspect (a raw-SQL view with no declared columns) it falls back to `parseId`. */ export function coerceIdForColumn(colRef: unknown, raw: string): number | string | undefined { @@ -36,6 +39,12 @@ export function coerceIdForColumn(colRef: unknown, raw: string): number | string const n = Number(raw); return Number.isFinite(n) && raw.trim() !== "" ? n : undefined; } + // A Postgres uuid column rejects a malformed literal at execution time + // ("invalid input syntax for type uuid"), which surfaced as a 500. The id's shape + // is knowable here, so a malformed one answers 400 like a malformed numeric id. + if ((colRef as { columnType?: string }).columnType === "PgUUID" && !matchesFormat(FIELD_SUBTYPE_UUID, raw)) { + return undefined; + } if (dataType !== undefined) return raw; // string / uuid / bigint / … — compare as given return parseId(raw); } diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index ec3a5557b3..2e421a0819 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -926,6 +926,9 @@ export type ${patchName} = ${z}.input;${preservingBl const HOSTNAME_REGEX_LITERAL = "/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/"; +/** The wire shape of a `field.uuid` value: 8-4-4-4-12 hex, either case. */ +const UUID_REGEX_LITERAL = "/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/"; + /** Zod expression for a scalar value subtype (used for a field.map's @valueType). */ function zodScalarFor(subType: string): string { if (subType === FIELD_SUBTYPE_INT || subType === FIELD_SUBTYPE_LONG || subType === FIELD_SUBTYPE_CURRENCY) return "z.number().int()"; @@ -1152,6 +1155,14 @@ function zodFieldExpr( break; } case FIELD_SUBTYPE_UUID: + // The 8-4-4-4-12 hex shape, the same check the filter parser applies to a uuid + // operand. Without it a malformed value (`"nope"` in a uuid FK) reached Postgres and + // answered 500 ("invalid input syntax for type uuid") instead of a 400. Deliberately + // not Zod's `.uuid()`: Zod 4 also demands RFC version bits, which would refuse + // well-formed keys such as 00000000-0000-0000-0000-000000000001. The TPH read shape + // keeps a bare string, as it does for every other format check. + baseStr = readShape ? "z.string()" : `z.string().regex(${UUID_REGEX_LITERAL}, "Invalid uuid")`; + break; default: baseStr = "z.string()"; break; diff --git a/examples/advanced-modeling/src/generated/Lesson.ts b/examples/advanced-modeling/src/generated/Lesson.ts index 545606eae5..c337e0ca25 100644 --- a/examples/advanced-modeling/src/generated/Lesson.ts +++ b/examples/advanced-modeling/src/generated/Lesson.ts @@ -24,13 +24,24 @@ export type Lesson = InferSelectModel; export type LessonInsert = InferInsertModel; export type LessonUpdate = Partial; export const LessonInsertSchema = z.object({ - programId: z.string(), + programId: z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + "Invalid uuid", + ), title: z.string().min(1).max(200), position: z.number().int(), }); export const LessonUpdateSchema = z.object({ - programId: z.string().optional(), + programId: z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + "Invalid uuid", + ) + .optional(), title: z.string().min(1).max(200).optional(), position: z.number().int().optional(), }); diff --git a/examples/advanced-modeling/src/generated/Program.ts b/examples/advanced-modeling/src/generated/Program.ts index 6e1d89dd95..3479546516 100644 --- a/examples/advanced-modeling/src/generated/Program.ts +++ b/examples/advanced-modeling/src/generated/Program.ts @@ -58,7 +58,12 @@ export const ProgramInsertSchema = z.object({ summary: z.string().max(2000).optional(), priceCents: z.number().int().optional(), coverKey: z.string().max(80).optional(), - authorId: z.string(), + authorId: z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + "Invalid uuid", + ), syllabus: z.array(SyllabusSectionInsertSchema).optional(), instructorProfile: InstructorProfileInsertSchema.optional(), }); @@ -69,7 +74,13 @@ export const ProgramUpdateSchema = z.object({ summary: z.string().max(2000).optional().nullable(), priceCents: z.number().int().optional().nullable(), coverKey: z.string().max(80).optional().nullable(), - authorId: z.string().optional(), + authorId: z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + "Invalid uuid", + ) + .optional(), syllabus: z.array(SyllabusSectionInsertSchema).optional().nullable(), instructorProfile: InstructorProfileInsertSchema.optional().nullable(), }); diff --git a/examples/advanced-modeling/src/generated/Purchase.ts b/examples/advanced-modeling/src/generated/Purchase.ts index 8fd08e200d..5e1ecd53ac 100644 --- a/examples/advanced-modeling/src/generated/Purchase.ts +++ b/examples/advanced-modeling/src/generated/Purchase.ts @@ -67,7 +67,12 @@ function utcIsoTimestamp(v: string): string { } export const PurchaseInsertSchema = z.object({ - programId: z.string(), + programId: z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + "Invalid uuid", + ), amountCents: z.number().int().optional(), status: z.enum(["pending", "completed", "refunded"]).optional(), purchasedAt: z @@ -81,7 +86,13 @@ export const PurchaseInsertSchema = z.object({ }); export const PurchaseUpdateSchema = z.object({ - programId: z.string().optional(), + programId: z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + "Invalid uuid", + ) + .optional(), amountCents: z.number().int().optional().nullable(), status: z.enum(["pending", "completed", "refunded"]).optional().nullable(), purchasedAt: z diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts b/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts index 644e9a9616..540b6626ab 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts @@ -130,7 +130,12 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult { if (typeof opts.query.search === "string" && opts.query.search !== "") { const term = `%${opts.query.search}%`; const stringCols = Object.entries(opts.allowlist) - .filter(([, rule]) => (rule as FilterFieldRule).subType === "string") + // A rule with a `format` (uuid, …) is a typed column on the wire as a string, not + // text: ILIKE on a Postgres uuid column is "operator does not exist" (a 500). + .filter(([, rule]) => { + const r = rule as FilterFieldRule; + return r.subType === "string" && r.format === undefined; + }) .map(([field]) => opts.table[field]); if (stringCols.length > 0) { // `?search` is a TS-only extension (docs/features/api-contract.md) and is diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/util.ts b/examples/showcase/codegen/runtime/drizzle-fastify/util.ts index a870252adc..d6aaea4bbb 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/util.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/util.ts @@ -2,6 +2,9 @@ * Shared utilities for drizzle-fastify route helpers. */ +import { FIELD_SUBTYPE_UUID } from "@metaobjectsdev/metadata"; +import { matchesFormat } from "./filter-value-format.js"; + /** * Coerce a path-param id to number when numeric, else keep the string key. * @@ -26,8 +29,8 @@ export function parseId(raw: string): number | string { * matches no row (libsql throws outright) — so a row that is plainly present in the list * response 404s on its own detail route. * - * Returns `undefined` only when the id is malformed FOR A NUMERIC column — the one case - * in which a bad id is actually knowable — so the caller can answer 400. With no column + * Returns `undefined` when the id is malformed for a numeric column or a Postgres uuid + * column — the cases in which a bad id is actually knowable — so the caller can answer 400. With no column * to inspect (a raw-SQL view with no declared columns) it falls back to `parseId`. */ export function coerceIdForColumn(colRef: unknown, raw: string): number | string | undefined { @@ -36,6 +39,12 @@ export function coerceIdForColumn(colRef: unknown, raw: string): number | string const n = Number(raw); return Number.isFinite(n) && raw.trim() !== "" ? n : undefined; } + // A Postgres uuid column rejects a malformed literal at execution time + // ("invalid input syntax for type uuid"), which surfaced as a 500. The id's shape + // is knowable here, so a malformed one answers 400 like a malformed numeric id. + if ((colRef as { columnType?: string }).columnType === "PgUUID" && !matchesFormat(FIELD_SUBTYPE_UUID, raw)) { + return undefined; + } if (dataType !== undefined) return raw; // string / uuid / bigint / … — compare as given return parseId(raw); } diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index ec3a5557b3..2e421a0819 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -926,6 +926,9 @@ export type ${patchName} = ${z}.input;${preservingBl const HOSTNAME_REGEX_LITERAL = "/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/"; +/** The wire shape of a `field.uuid` value: 8-4-4-4-12 hex, either case. */ +const UUID_REGEX_LITERAL = "/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/"; + /** Zod expression for a scalar value subtype (used for a field.map's @valueType). */ function zodScalarFor(subType: string): string { if (subType === FIELD_SUBTYPE_INT || subType === FIELD_SUBTYPE_LONG || subType === FIELD_SUBTYPE_CURRENCY) return "z.number().int()"; @@ -1152,6 +1155,14 @@ function zodFieldExpr( break; } case FIELD_SUBTYPE_UUID: + // The 8-4-4-4-12 hex shape, the same check the filter parser applies to a uuid + // operand. Without it a malformed value (`"nope"` in a uuid FK) reached Postgres and + // answered 500 ("invalid input syntax for type uuid") instead of a 400. Deliberately + // not Zod's `.uuid()`: Zod 4 also demands RFC version bits, which would refuse + // well-formed keys such as 00000000-0000-0000-0000-000000000001. The TPH read shape + // keeps a bare string, as it does for every other format check. + baseStr = readShape ? "z.string()" : `z.string().regex(${UUID_REGEX_LITERAL}, "Invalid uuid")`; + break; default: baseStr = "z.string()"; break; diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 445b33ac1a..ecd37d439d 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -93,8 +93,8 @@ "deep-currency": { "lang": "ts", "inline": "…\n priceCents: {\n name: "priceCents",\n label: "Price Cents",\n view: "currency",\n currency: "USD",\n locale: "en-US",\n },\n…", - "full": "// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.\n// Source metadata: Program (Program)\n// Extend in your own module (e.g. Program.extra.ts) — nothing imports it for you.\nimport { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";\nimport {\n type AnyPgColumn,\n bigint,\n check,\n jsonb,\n pgTable,\n text,\n uuid,\n varchar,\n} from "drizzle-orm/pg-core";\nimport { z } from "zod";\nimport { authors } from "./Author";\nimport {\n type InstructorProfile,\n InstructorProfileInsertSchema,\n} from "./InstructorProfile";\nimport {\n type SyllabusSection,\n SyllabusSectionInsertSchema,\n} from "./SyllabusSection";\n\nexport const programs = pgTable(\n "programs",\n {\n id: uuid("id").primaryKey().defaultRandom(),\n title: varchar("title", { length: 200 }).notNull(),\n status: text("status", {\n enum: ["draft", "published", "archived"] as const,\n }),\n summary: varchar("summary", { length: 2000 }),\n priceCents: bigint("price_cents", { mode: "number" }),\n coverKey: varchar("cover_key", { length: 80 }),\n authorId: uuid("author_id")\n .notNull()\n .references((): AnyPgColumn => authors.id),\n syllabus: jsonb("syllabus").$type<SyllabusSection[]>(),\n instructorProfile: jsonb("instructor_profile").$type<InstructorProfile>(),\n },\n (table) => [\n check(\n "programs_status_chk",\n sql`status IN ('draft', 'published', 'archived')`,\n ),\n ],\n);\nexport type Program = InferSelectModel<typeof programs>;\nexport type ProgramInsert = InferInsertModel<typeof programs>;\nexport type ProgramUpdate = Partial<ProgramInsert>;\nexport type ProgramStatus = "draft" | "published" | "archived";\nexport const ProgramStatusEnum = z.enum(["draft", "published", "archived"]);\nexport const ProgramInsertSchema = z.object({\n title: z.string().min(1).max(200),\n status: z.enum(["draft", "published", "archived"]).optional(),\n summary: z.string().max(2000).optional(),\n priceCents: z.number().int().optional(),\n coverKey: z.string().max(80).optional(),\n authorId: z.string(),\n syllabus: z.array(SyllabusSectionInsertSchema).optional(),\n instructorProfile: InstructorProfileInsertSchema.optional(),\n});\n\nexport const ProgramUpdateSchema = z.object({\n title: z.string().min(1).max(200).optional(),\n status: z.enum(["draft", "published", "archived"]).optional().nullable(),\n summary: z.string().max(2000).optional().nullable(),\n priceCents: z.number().int().optional().nullable(),\n coverKey: z.string().max(80).optional().nullable(),\n authorId: z.string().optional(),\n syllabus: z.array(SyllabusSectionInsertSchema).optional().nullable(),\n instructorProfile: InstructorProfileInsertSchema.optional().nullable(),\n});\n\n/** Typed create shape for Program: the insert schema's INPUT (pre-transform) type. A\n * renamed/dropped/misspelt field is a compile error at every `createProgram` call site;\n * the schema still validates at runtime. */\nexport type ProgramCreate = z.input<typeof ProgramInsertSchema>;\n\n/** Typed patch shape for Program: every settable field, optional (FR-035 PATCH). A\n * renamed/dropped field is a compile error at every `updateProgram` call site. */\nexport type ProgramPatch = z.input<typeof ProgramUpdateSchema>;\n/**\n * Metadata constants for Program.\n *\n * Use these instead of magic strings so TS catches typos and refactors stay\n * coherent. Each non-dollar-prefixed key is a per-field object carrying\n * name, label, view, an htmlType where the field's view maps to one, and the\n * RHF-shaped validation rules derived from the field's validator children.\n *\n * placeholder and helpText are NOT emitted — no provider registers an attribute\n * for them, so nothing here could derive one. (htmlType is emitted, but derived\n * from the view subtype; the @htmlType override was removed for the same reason.)\n * useEntityForm still honours placeholder if you add it: this file is generated,\n * hand edits inside it survive regeneration through the three-way merge, and the\n * consumers read this const rather than the metadata. That is the intended way\n * to set it.\n *\n * Typical usage with the metaobjects React form helper:\n *\n * import { useEntityForm } from '@metaobjectsdev/react';\n * const form = useEntityForm(Program, ProgramInsertSchema);\n * <input {...form.input.id} />\n */\nexport const Program = {\n $entity: "Program",\n $table: "programs",\n $path: "/programs",\n id: { name: "id", label: "Id", view: "text", htmlType: "text" },\n title: {\n name: "title",\n label: "Title",\n view: "text",\n htmlType: "text",\n rules: {\n required: "Title is required",\n maxLength: { value: 200, message: "Must be 200 characters or fewer" },\n },\n },\n status: {\n name: "status",\n label: "Status",\n view: "dropdown",\n options: ["draft", "published", "archived"] as const,\n },\n summary: {\n name: "summary",\n label: "Summary",\n view: "textarea",\n rules: {\n maxLength: { value: 2000, message: "Must be 2000 characters or fewer" },\n },\n },\n priceCents: {\n name: "priceCents",\n label: "Price Cents",\n view: "currency",\n currency: "USD",\n locale: "en-US",\n },\n coverKey: {\n name: "coverKey",\n label: "Cover Key",\n view: "image",\n rules: {\n maxLength: { value: 80, message: "Must be 80 characters or fewer" },\n },\n },\n authorId: {\n name: "authorId",\n label: "Author Id",\n view: "text",\n htmlType: "text",\n rules: { required: "Author Id is required" },\n },\n syllabus: {\n name: "syllabus",\n label: "Syllabus",\n view: "text",\n htmlType: "text",\n },\n instructorProfile: {\n name: "instructorProfile",\n label: "Instructor Profile",\n view: "text",\n htmlType: "text",\n },\n} as const;\nimport type { FilterAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";\n\nexport const ProgramFilterAllowlist = {} as const satisfies FilterAllowlist;\nimport type { SortAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";\n\nexport const ProgramSortAllowlist = {} as const satisfies SortAllowlist;\nexport type ProgramFilter = {\n limit?: number;\n offset?: number;\n sort?: string;\n or?: ProgramFilter[];\n and?: ProgramFilter[];\n};\n", - "lineCount": 183 + "full": "// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.\n// Source metadata: Program (Program)\n// Extend in your own module (e.g. Program.extra.ts) — nothing imports it for you.\nimport { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";\nimport {\n type AnyPgColumn,\n bigint,\n check,\n jsonb,\n pgTable,\n text,\n uuid,\n varchar,\n} from "drizzle-orm/pg-core";\nimport { z } from "zod";\nimport { authors } from "./Author";\nimport {\n type InstructorProfile,\n InstructorProfileInsertSchema,\n} from "./InstructorProfile";\nimport {\n type SyllabusSection,\n SyllabusSectionInsertSchema,\n} from "./SyllabusSection";\n\nexport const programs = pgTable(\n "programs",\n {\n id: uuid("id").primaryKey().defaultRandom(),\n title: varchar("title", { length: 200 }).notNull(),\n status: text("status", {\n enum: ["draft", "published", "archived"] as const,\n }),\n summary: varchar("summary", { length: 2000 }),\n priceCents: bigint("price_cents", { mode: "number" }),\n coverKey: varchar("cover_key", { length: 80 }),\n authorId: uuid("author_id")\n .notNull()\n .references((): AnyPgColumn => authors.id),\n syllabus: jsonb("syllabus").$type<SyllabusSection[]>(),\n instructorProfile: jsonb("instructor_profile").$type<InstructorProfile>(),\n },\n (table) => [\n check(\n "programs_status_chk",\n sql`status IN ('draft', 'published', 'archived')`,\n ),\n ],\n);\nexport type Program = InferSelectModel<typeof programs>;\nexport type ProgramInsert = InferInsertModel<typeof programs>;\nexport type ProgramUpdate = Partial<ProgramInsert>;\nexport type ProgramStatus = "draft" | "published" | "archived";\nexport const ProgramStatusEnum = z.enum(["draft", "published", "archived"]);\nexport const ProgramInsertSchema = z.object({\n title: z.string().min(1).max(200),\n status: z.enum(["draft", "published", "archived"]).optional(),\n summary: z.string().max(2000).optional(),\n priceCents: z.number().int().optional(),\n coverKey: z.string().max(80).optional(),\n authorId: z\n .string()\n .regex(\n /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,\n "Invalid uuid",\n ),\n syllabus: z.array(SyllabusSectionInsertSchema).optional(),\n instructorProfile: InstructorProfileInsertSchema.optional(),\n});\n\nexport const ProgramUpdateSchema = z.object({\n title: z.string().min(1).max(200).optional(),\n status: z.enum(["draft", "published", "archived"]).optional().nullable(),\n summary: z.string().max(2000).optional().nullable(),\n priceCents: z.number().int().optional().nullable(),\n coverKey: z.string().max(80).optional().nullable(),\n authorId: z\n .string()\n .regex(\n /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,\n "Invalid uuid",\n )\n .optional(),\n syllabus: z.array(SyllabusSectionInsertSchema).optional().nullable(),\n instructorProfile: InstructorProfileInsertSchema.optional().nullable(),\n});\n\n/** Typed create shape for Program: the insert schema's INPUT (pre-transform) type. A\n * renamed/dropped/misspelt field is a compile error at every `createProgram` call site;\n * the schema still validates at runtime. */\nexport type ProgramCreate = z.input<typeof ProgramInsertSchema>;\n\n/** Typed patch shape for Program: every settable field, optional (FR-035 PATCH). A\n * renamed/dropped field is a compile error at every `updateProgram` call site. */\nexport type ProgramPatch = z.input<typeof ProgramUpdateSchema>;\n/**\n * Metadata constants for Program.\n *\n * Use these instead of magic strings so TS catches typos and refactors stay\n * coherent. Each non-dollar-prefixed key is a per-field object carrying\n * name, label, view, an htmlType where the field's view maps to one, and the\n * RHF-shaped validation rules derived from the field's validator children.\n *\n * placeholder and helpText are NOT emitted — no provider registers an attribute\n * for them, so nothing here could derive one. (htmlType is emitted, but derived\n * from the view subtype; the @htmlType override was removed for the same reason.)\n * useEntityForm still honours placeholder if you add it: this file is generated,\n * hand edits inside it survive regeneration through the three-way merge, and the\n * consumers read this const rather than the metadata. That is the intended way\n * to set it.\n *\n * Typical usage with the metaobjects React form helper:\n *\n * import { useEntityForm } from '@metaobjectsdev/react';\n * const form = useEntityForm(Program, ProgramInsertSchema);\n * <input {...form.input.id} />\n */\nexport const Program = {\n $entity: "Program",\n $table: "programs",\n $path: "/programs",\n id: { name: "id", label: "Id", view: "text", htmlType: "text" },\n title: {\n name: "title",\n label: "Title",\n view: "text",\n htmlType: "text",\n rules: {\n required: "Title is required",\n maxLength: { value: 200, message: "Must be 200 characters or fewer" },\n },\n },\n status: {\n name: "status",\n label: "Status",\n view: "dropdown",\n options: ["draft", "published", "archived"] as const,\n },\n summary: {\n name: "summary",\n label: "Summary",\n view: "textarea",\n rules: {\n maxLength: { value: 2000, message: "Must be 2000 characters or fewer" },\n },\n },\n priceCents: {\n name: "priceCents",\n label: "Price Cents",\n view: "currency",\n currency: "USD",\n locale: "en-US",\n },\n coverKey: {\n name: "coverKey",\n label: "Cover Key",\n view: "image",\n rules: {\n maxLength: { value: 80, message: "Must be 80 characters or fewer" },\n },\n },\n authorId: {\n name: "authorId",\n label: "Author Id",\n view: "text",\n htmlType: "text",\n rules: { required: "Author Id is required" },\n },\n syllabus: {\n name: "syllabus",\n label: "Syllabus",\n view: "text",\n htmlType: "text",\n },\n instructorProfile: {\n name: "instructorProfile",\n label: "Instructor Profile",\n view: "text",\n htmlType: "text",\n },\n} as const;\nimport type { FilterAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";\n\nexport const ProgramFilterAllowlist = {} as const satisfies FilterAllowlist;\nimport type { SortAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";\n\nexport const ProgramSortAllowlist = {} as const satisfies SortAllowlist;\nexport type ProgramFilter = {\n limit?: number;\n offset?: number;\n sort?: string;\n or?: ProgramFilter[];\n and?: ProgramFilter[];\n};\n", + "lineCount": 194 }, "deep-projection": { "lang": "ts", diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md index f21a5d0070..22b3ef1385 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md @@ -42,10 +42,16 @@ An `identity.secondary` can index an **expression** instead of plain columns: us index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index predicate). +**Name every `identity.secondary` and `index.lookup` after its table.** The name is used +verbatim as the database index name, and index names are unique across the whole schema, +not per table. Two entities that both declare `byName` load and generate cleanly, then +`meta migrate` stops with `ERR_DUPLICATE_SQL_NAME`. Prefix the entity (`clientByName`, +`tagByName`). + ```json { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } } -{ "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } -{ "identity.secondary": { "name": "byEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userByEmail", "@fields": ["email"] } } +{ "identity.secondary": { "name": "userByEmailCI", "@expr": "lower(email)" } } { "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } ``` @@ -73,10 +79,10 @@ The db provider contributes physical-tuning attrs alongside either form: `@order `btree`), and `@where` (partial-index predicate). ```json -{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } -{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } } -{ "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } } -{ "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } } +{ "index.lookup": { "name": "orderByCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } +{ "index.lookup": { "name": "orderByStatusCreatedAt", "@fields": ["status", "createdAt"] } } +{ "index.lookup": { "name": "userByEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userUniqLowerEmail", "@expr": "lower(email)" } } ``` `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`, diff --git a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index dd3184a870..6f0302b540 100644 --- a/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/csharp/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -115,8 +115,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. A view-backed `object.report` has a generated list route at the same rule and, in -TypeScript, a generated list hook (`useList`); it has no grid, form or detail -hook. +TypeScript, a generated list hook named `use` + the plural of the report's name +(`useMemberUtilizations` for `MemberUtilization`; `useList` only when the plural is +the name itself, as in `useSeriesList`); it has no grid, form or detail hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md index f21a5d0070..22b3ef1385 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md @@ -42,10 +42,16 @@ An `identity.secondary` can index an **expression** instead of plain columns: us index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index predicate). +**Name every `identity.secondary` and `index.lookup` after its table.** The name is used +verbatim as the database index name, and index names are unique across the whole schema, +not per table. Two entities that both declare `byName` load and generate cleanly, then +`meta migrate` stops with `ERR_DUPLICATE_SQL_NAME`. Prefix the entity (`clientByName`, +`tagByName`). + ```json { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } } -{ "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } -{ "identity.secondary": { "name": "byEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userByEmail", "@fields": ["email"] } } +{ "identity.secondary": { "name": "userByEmailCI", "@expr": "lower(email)" } } { "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } ``` @@ -73,10 +79,10 @@ The db provider contributes physical-tuning attrs alongside either form: `@order `btree`), and `@where` (partial-index predicate). ```json -{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } -{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } } -{ "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } } -{ "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } } +{ "index.lookup": { "name": "orderByCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } +{ "index.lookup": { "name": "orderByStatusCreatedAt", "@fields": ["status", "createdAt"] } } +{ "index.lookup": { "name": "userByEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userUniqLowerEmail", "@expr": "lower(email)" } } ``` `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index dd3184a870..6f0302b540 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -115,8 +115,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. A view-backed `object.report` has a generated list route at the same rule and, in -TypeScript, a generated list hook (`useList`); it has no grid, form or detail -hook. +TypeScript, a generated list hook named `use` + the plural of the report's name +(`useMemberUtilizations` for `MemberUtilization`; `useList` only when the plural is +the name itself, as in `useSeriesList`); it has no grid, form or detail hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md index f21a5d0070..22b3ef1385 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md @@ -42,10 +42,16 @@ An `identity.secondary` can index an **expression** instead of plain columns: us index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index predicate). +**Name every `identity.secondary` and `index.lookup` after its table.** The name is used +verbatim as the database index name, and index names are unique across the whole schema, +not per table. Two entities that both declare `byName` load and generate cleanly, then +`meta migrate` stops with `ERR_DUPLICATE_SQL_NAME`. Prefix the entity (`clientByName`, +`tagByName`). + ```json { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } } -{ "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } -{ "identity.secondary": { "name": "byEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userByEmail", "@fields": ["email"] } } +{ "identity.secondary": { "name": "userByEmailCI", "@expr": "lower(email)" } } { "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } ``` @@ -73,10 +79,10 @@ The db provider contributes physical-tuning attrs alongside either form: `@order `btree`), and `@where` (partial-index predicate). ```json -{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } -{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } } -{ "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } } -{ "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } } +{ "index.lookup": { "name": "orderByCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } +{ "index.lookup": { "name": "orderByStatusCreatedAt", "@fields": ["status", "createdAt"] } } +{ "index.lookup": { "name": "userByEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userUniqLowerEmail", "@expr": "lower(email)" } } ``` `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`, diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index dd3184a870..6f0302b540 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -115,8 +115,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. A view-backed `object.report` has a generated list route at the same rule and, in -TypeScript, a generated list hook (`useList`); it has no grid, form or detail -hook. +TypeScript, a generated list hook named `use` + the plural of the report's name +(`useMemberUtilizations` for `MemberUtilization`; `useList` only when the plural is +the name itself, as in `useSeriesList`); it has no grid, form or detail hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md index f21a5d0070..22b3ef1385 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md @@ -42,10 +42,16 @@ An `identity.secondary` can index an **expression** instead of plain columns: us index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index predicate). +**Name every `identity.secondary` and `index.lookup` after its table.** The name is used +verbatim as the database index name, and index names are unique across the whole schema, +not per table. Two entities that both declare `byName` load and generate cleanly, then +`meta migrate` stops with `ERR_DUPLICATE_SQL_NAME`. Prefix the entity (`clientByName`, +`tagByName`). + ```json { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } } -{ "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } -{ "identity.secondary": { "name": "byEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userByEmail", "@fields": ["email"] } } +{ "identity.secondary": { "name": "userByEmailCI", "@expr": "lower(email)" } } { "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } ``` @@ -73,10 +79,10 @@ The db provider contributes physical-tuning attrs alongside either form: `@order `btree`), and `@where` (partial-index predicate). ```json -{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } -{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } } -{ "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } } -{ "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } } +{ "index.lookup": { "name": "orderByCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } +{ "index.lookup": { "name": "orderByStatusCreatedAt", "@fields": ["status", "createdAt"] } } +{ "index.lookup": { "name": "userByEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userUniqLowerEmail", "@expr": "lower(email)" } } ``` `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`, diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index dd3184a870..6f0302b540 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -115,8 +115,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. A view-backed `object.report` has a generated list route at the same rule and, in -TypeScript, a generated list hook (`useList`); it has no grid, form or detail -hook. +TypeScript, a generated list hook named `use` + the plural of the report's name +(`useMemberUtilizations` for `MemberUtilization`; `useList` only when the plural is +the name itself, as in `useSeriesList`); it has no grid, form or detail hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md index f21a5d0070..22b3ef1385 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/identities-and-indexes.md @@ -42,10 +42,16 @@ An `identity.secondary` can index an **expression** instead of plain columns: us index method — `gin` / `gist` / `hash`; default `btree`) and `@where` (a partial-index predicate). +**Name every `identity.secondary` and `index.lookup` after its table.** The name is used +verbatim as the database index name, and index names are unique across the whole schema, +not per table. Two entities that both declare `byName` load and generate cleanly, then +`meta migrate` stops with `ERR_DUPLICATE_SQL_NAME`. Prefix the entity (`clientByName`, +`tagByName`). + ```json { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } } -{ "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } -{ "identity.secondary": { "name": "byEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userByEmail", "@fields": ["email"] } } +{ "identity.secondary": { "name": "userByEmailCI", "@expr": "lower(email)" } } { "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } ``` @@ -73,10 +79,10 @@ The db provider contributes physical-tuning attrs alongside either form: `@order `btree`), and `@where` (partial-index predicate). ```json -{ "index.lookup": { "name": "byCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } -{ "index.lookup": { "name": "byStatusCreatedAt", "@fields": ["status", "createdAt"] } } -{ "index.lookup": { "name": "byEmailCI", "@expr": "lower(email)" } } -{ "identity.secondary": { "name": "uniqLowerEmail", "@expr": "lower(email)" } } +{ "index.lookup": { "name": "orderByCreatedAt", "@fields": ["createdAt"], "@orders": ["desc"] } } +{ "index.lookup": { "name": "orderByStatusCreatedAt", "@fields": ["status", "createdAt"] } } +{ "index.lookup": { "name": "userByEmailCI", "@expr": "lower(email)" } } +{ "identity.secondary": { "name": "userUniqLowerEmail", "@expr": "lower(email)" } } ``` `index.lookup` is a sibling of `identity.*` — declare it as a direct child of an `object.entity`, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md index d0847d17ce..b854c51072 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -215,7 +215,7 @@ view binding, Zod read schema, row type, descriptor, filter and sort allowlists) `.queries.ts` (`list…` only, no by-id), `.routes.ts` (and `.routes.hono.ts` from `routesFileHono()`): GET list, `POST` answers 405, no `/:id`; `.names.ts`; and the barrel export. Every derived field with filter operators is filterable and sortable. The UI tier -writes the list hook and nothing else: `.hooks.ts` (`useList`, typed with the report's +writes the list hook and nothing else: `.hooks.ts` (`use` + the plural of the name (`useMemberUtilizations` for `MemberUtilization`), or `useList` when the name's plural is the name itself (`useSeriesList`), typed with the report's filter, no detail or mutation hook) and its `.meta.ts` descriptor, and `agent/ui.md` lists the report. The hook generator gates on `servesClientHooks`, which is true for a served report; the grid generators gate on `servesClientTier`, which is false, so there is no grid, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md index dd3184a870..6f0302b540 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/SKILL.md @@ -115,8 +115,9 @@ An `object.projection` uses the same rule, so `OrderSummary` is at their fetch URLs from the TypeScript `$path`, and every backend now mounts that same spelling, so a React/TanStack client works against any port's server. A view-backed `object.report` has a generated list route at the same rule and, in -TypeScript, a generated list hook (`useList`); it has no grid, form or detail -hook. +TypeScript, a generated list hook named `use` + the plural of the report's name +(`useMemberUtilizations` for `MemberUtilization`; `useList` only when the plural is +the name itself, as in `useSeriesList`); it has no grid, form or detail hook. **This is a change, and it was a breaking one.** Each port used to spell the segment differently and they agreed only on single regular words — which was diff --git a/server/typescript/packages/cli/src/commands/gen.ts b/server/typescript/packages/cli/src/commands/gen.ts index d953867578..16550c3926 100644 --- a/server/typescript/packages/cli/src/commands/gen.ts +++ b/server/typescript/packages/cli/src/commands/gen.ts @@ -13,7 +13,7 @@ import { requirementTestsAdvice } from "../lib/requirement-tests-advice.js"; import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js"; import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js"; import { - antiPatternRows, ranSection, skippedSection, warnCapped, + antiPatternRows, ranSection, skippedSection, warnAdvisory, type AdvisoryFindingRow, type AdvisorySection, } from "../lib/advisory.js"; import { loadMemory, resolveCollection } from "@metaobjectsdev/sdk"; @@ -400,13 +400,13 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat // The human-readable half of the advisory pass, printed after the file listing // where a reader expects it. Capped for a terminal; the structured payload above - // already carried every finding, and the tail line says so. + // already carried every finding, so a structured run prints only a pointer here. if (antiPatterns.total > 0) { log.warn( `\nmeta gen — ${antiPatterns.total} place(s) hand-roll what MetaObjects can model ` + `(advisory — declaring the construct lets codegen own it):`, ); - warnCapped(antiPatterns.rows.map((r) => ` ${r.message}`), flags.limit, { structured: fmt !== "text" }); + warnAdvisory(antiPatterns.rows.map((r) => ` ${r.message}`), flags.limit, { structured: fmt !== "text" }); } const hasFailure = files.some((f) => f.status === "conflict" || f.status === "refused"); diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index b8470125b0..3e03dd836d 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -21,11 +21,12 @@ import { type AdvisoryDiagnosticRow, type AdvisoryFindingRow, type AdvisorySection, libraryPrefixRows, unindexedFkRows, + warnAdvisory, referentialActionConflictRows, advisoryHelpLines, } from "../lib/advisory.js"; import { scanForUnprovenancedLibraryPrefix } from "../lib/library-prefix-advisory.js"; -import { scanForUnindexedForeignKeys, type UnindexedFkFinding } from "../lib/fk-index-advisory.js"; +import { scanForUnindexedForeignKeys, summarizeUnindexedFks, type UnindexedFkFinding } from "../lib/fk-index-advisory.js"; import { scanForReferentialActionConflicts, type ReferentialActionConflictFinding } from "../lib/referential-action-advisory.js"; import { warnIfAgentContextMissesRequirements, warnIfAgentContextStale } from "../lib/agent-context-staleness.js"; import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js"; @@ -1118,12 +1119,13 @@ export async function verifyCommand( // advisory only — a model the schema builder refuses is migrate's to report. } + const unindexedFkSummary = summarizeUnindexedFks(unindexedFks); antiPatternSection = ranSection([ ...antiPatternRows(findings), ...missingBaseUrlRows(baseUrl), ...removedPropRows(removedProps), ...libraryPrefixRows(libraryPrefix), - ...unindexedFkRows(unindexedFks), + ...unindexedFkRows(unindexedFkSummary === undefined ? [] : [unindexedFkSummary]), ...referentialActionConflictRows(actionConflicts), ]); if (findings.length > 0) { @@ -1131,42 +1133,42 @@ export async function verifyCommand( `meta verify — ${findings.length} place(s) hand-roll what MetaObjects can model ` + `(advisory — does not fail the build):`, ); - warnCapped(findings.map((f) => ` ${f.message}`), flags.limit, { structured }); + warnAdvisory(findings.map((f) => ` ${f.message}`), flags.limit, { structured }); } if (baseUrl.length > 0) { log.warn( `meta verify — ${baseUrl.length} entity-fetcher provider(s) mounted with no baseUrl ` + `while apiPrefix is "${forgeConfig?.apiPrefix ?? ""}" (advisory — does not fail the build):`, ); - warnCapped(baseUrl.map((f) => ` ${f.message}`), flags.limit, { structured }); + warnAdvisory(baseUrl.map((f) => ` ${f.message}`), flags.limit, { structured }); } if (removedProps.length > 0) { log.warn( `meta verify — ${removedProps.length} provider(s) still mounted with a prop 1.0 ` + `renamed away (advisory — does not fail the build, but the runtime will):`, ); - warnCapped(removedProps.map((f) => ` ${f.message}`), flags.limit, { structured }); + warnAdvisory(removedProps.map((f) => ` ${f.message}`), flags.limit, { structured }); } - if (unindexedFks.length > 0) { + if (unindexedFkSummary !== undefined) { log.warn( `meta verify — ${unindexedFks.length} foreign key(s) with no index on the referencing ` + `column(s) (advisory — does not fail the build):`, ); - warnCapped(unindexedFks.map((f) => ` ${f.message}`), flags.limit, { structured }); + warnAdvisory([` ${unindexedFkSummary.message}`], flags.limit, { structured }); } if (actionConflicts.length > 0) { log.warn( `meta verify — ${actionConflicts.length} foreign key(s) whose two sides declare relationships ` + `that disagree on its referential action (advisory — does not fail the build):`, ); - warnCapped(actionConflicts.map((f) => ` ${f.message}`), flags.limit, { structured }); + warnAdvisory(actionConflicts.map((f) => ` ${f.message}`), flags.limit, { structured }); } if (libraryPrefix.length > 0) { log.warn( `meta verify — ${libraryPrefix.length} node(s) declared under "metaobjects::" with ` + `no ejection provenance (advisory — does not fail the build):`, ); - warnCapped(libraryPrefix.map((f) => ` ${f.message}`), flags.limit, { structured }); + warnAdvisory(libraryPrefix.map((f) => ` ${f.message}`), flags.limit, { structured }); } } diff --git a/server/typescript/packages/cli/src/lib/advisory.ts b/server/typescript/packages/cli/src/lib/advisory.ts index bb97eb871c..da51667589 100644 --- a/server/typescript/packages/cli/src/lib/advisory.ts +++ b/server/typescript/packages/cli/src/lib/advisory.ts @@ -120,8 +120,8 @@ export const RULE_REFERENTIAL_ACTION_CONFLICT = "overridden-referential-action"; const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: (n: number) => string }> = [ { matches: (r) => r === RULE_UNINDEXED_FK, - line: (n) => - `${n} foreign key(s) with no covering index — see antiPatterns.rows[] (rule "${RULE_UNINDEXED_FK}"); each names the index.lookup to declare`, + line: () => + `foreign key(s) with no covering index — see antiPatterns.rows[] (rule "${RULE_UNINDEXED_FK}"), one row naming each key and the index.lookup to declare`, }, { matches: (r) => r === RULE_REFERENTIAL_ACTION_CONFLICT, @@ -291,3 +291,18 @@ export function warnCapped( : ` …and ${withheld} more. Raise the cap with --limit , or --limit ${ADVISORY_LIMIT_ALL}.`, ); } + +/** + * Print an advisory section's findings: `warnCapped` in text mode, and in structured mode + * only a pointer. Every advisory row is already in the payload on stdout, and printing each + * again on stderr doubled a large run's output (one adopter's `meta verify` ran to ~350 + * lines). The section's header line, printed by the caller, still says how many. + */ +export function warnAdvisory( + lines: readonly string[], + limit: number, + opts: { structured: boolean }, +): void { + if (!opts.structured) return warnCapped(lines, limit, opts); + if (lines.length > 0) log.warn(" (listed in the --format payload on stdout)"); +} \ No newline at end of file diff --git a/server/typescript/packages/cli/src/lib/allow.ts b/server/typescript/packages/cli/src/lib/allow.ts index 7726f26eab..1344659d39 100644 --- a/server/typescript/packages/cli/src/lib/allow.ts +++ b/server/typescript/packages/cli/src/lib/allow.ts @@ -2,7 +2,7 @@ // both `meta migrate` and `meta verify --db`. Keeping a single copy avoids the // two commands drifting on which `--allow` tokens exist or how a change reads. -import { allowOptionFor, describeSqlType, isViewRecreateOnly, suggestColumnRenames } from "@metaobjectsdev/migrate-ts"; +import { allowOptionFor, describeSqlType, isViewRecreateOnly, suggestColumnRenames, viewsNamingRenamedColumns } from "@metaobjectsdev/migrate-ts"; import type { AllowOptions, Change, ColumnRenameSuggestion, ViewChangeReason } from "@metaobjectsdev/migrate-ts"; import type { BlockedEntry } from "./output.js"; @@ -151,13 +151,19 @@ function tableList(tables: readonly string[]): string { /** * One line naming the views a migration drops and recreates only because it alters a table - * they read. Their definitions match the metadata, and the migration needs the pair, but + * they read. Their declared definitions are unchanged, and the migration needs the pair, but * without this line the DROP VIEW / CREATE VIEW in the SQL reads like a view change (D3). */ export function viewRecreateNote(changes: readonly Change[]): string | undefined { - const views = changes.flatMap((c) => c.kind === "create-view" && isViewRecreateOnly(c) ? [c.view.name] : []); + // A view whose body still names a column this migration renames does NOT match the change, + // whatever its fingerprint says: an `@sql` body is compared with itself. The advisory + // `viewsNamingRenamedColumns` reports it; this line must not vouch for it. + const stale = new Set(viewsNamingRenamedColumns(changes).flatMap((a) => + a.kind === "view-names-renamed-column" ? [a.view] : [])); + const views = changes.flatMap((c) => + c.kind === "create-view" && isViewRecreateOnly(c) && !stale.has(c.view.name) ? [c.view.name] : []); if (views.length === 0) return undefined; - return `${views.length} view(s) match the metadata and are recreated only because the migration ` + + return `${views.length} view(s) keep their declared definition and are recreated only because the migration ` + `alters a table they read: ${views.join(", ")}`; } diff --git a/server/typescript/packages/cli/src/lib/anti-patterns.ts b/server/typescript/packages/cli/src/lib/anti-patterns.ts index a9179e1836..92fefcfa86 100644 --- a/server/typescript/packages/cli/src/lib/anti-patterns.ts +++ b/server/typescript/packages/cli/src/lib/anti-patterns.ts @@ -174,23 +174,88 @@ function globToRegExp(glob: string): RegExp { // --- rules: each tests one source line, returns a finding shape or null ------- -const MONEY_WORD = /\b(?:price|amount|cost|total|subtotal|fee|balance|cents|dollars)\w*/i; -const MONEY_MATH = /(?:\*\s*100\b|\/\s*100\b|\.toFixed\s*\(\s*2\s*\)|parseFloat\s*\()/; +// Words that name money. Matched against the words of an OPERAND (split on camelCase and +// underscores), never the whole line: `total` is absent because a total is as often hours or +// a count as an amount, and `totalAmount` still matches through `amount`. +const MONEY_WORDS = new Set([ + "price", "prices", "amount", "amounts", "cost", "costs", "subtotal", "fee", "fees", + "balance", "balances", "cents", "dollars", +]); +// Minor-unit math, each form with the side its operand sits on. +const MONEY_MATH = /\*\s*100\b|\/\s*100\b|\.toFixed\s*\(\s*2\s*\)|parseFloat\s*\(/g; +// A line that is only a comment: prose about money or sums is not code doing it. +const TS_COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|\{\s*\/\*)/; +const SQL_COMMENT_LINE = /^\s*(?:--|\/\*|\*)/; +// UI source: a `.reduce` there sums the rows already on screen (a timesheet's row and column +// totals), which is presentation, not a figure the model should derive. +const UI_FILE = /\.(?:tsx|jsx)$/; + +// `.reduce((acc, …) => acc + …`: the body accumulates into its first parameter with `+`. +const SUM_REDUCE = /\.reduce\s*\(\s*\(?\s*([\w$]+)[^)=]*\)?\s*=>\s*\(?\s*\1\s*\+/; + +function wordsOf(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((w) => w.length > 0); +} + +/** The expression ending just before `end`: a balanced `(…)` group and/or an identifier chain. */ +function operandBefore(line: string, end: number): string { + let i = end - 1; + while (i >= 0 && /\s/.test(line[i]!)) i--; + const stop = i + 1; + if (line[i] === ")") { + let depth = 0; + for (; i >= 0; i--) { + if (line[i] === ")") depth++; + else if (line[i] === "(" && --depth === 0) { i--; break; } + } + } + while (i >= 0 && /[\w$.\]\[]/.test(line[i]!)) i--; + return line.slice(i + 1, stop); +} -function matchLine(line: string, isSql: boolean): +/** The argument list of the call whose `(` is at `open`. */ +function argumentAfter(line: string, open: number): string { + let depth = 0; + for (let i = open; i < line.length; i++) { + if (line[i] === "(") depth++; + else if (line[i] === ")" && --depth === 0) return line.slice(open + 1, i); + } + return line.slice(open + 1); +} + +/** Does any minor-unit math on the line operate on a money-named value? */ +function moneyMath(line: string): boolean { + for (const m of line.matchAll(MONEY_MATH)) { + const at = m.index ?? 0; + const operand = m[0].startsWith("parseFloat") + ? argumentAfter(line, at + m[0].length - 1) + : operandBefore(line, at); + if (wordsOf(operand).some((w) => MONEY_WORDS.has(w))) return true; + } + return false; +} + +function matchLine(line: string, isSql: boolean, isUi: boolean): | { rule: AntiPatternFinding["rule"]; construct: string } | null { + if ((isSql ? SQL_COMMENT_LINE : TS_COMMENT_LINE).test(line)) return null; + // 1) Hand-written aggregate. AVG/SUM in SQL are almost always a derived metric // you'd project (COUNT is deliberately excluded — too often a pagination - // total). A `.reduce(...)` that sums (a `+` body or a `, 0)` seed) is the TS - // equivalent. + // total). A `.reduce(...)` that ACCUMULATES with `+` is the TS equivalent; a + // reduce taking a max or building a map is not a sum, and one in UI source is + // summing what is already on screen. if (/\b(?:AVG|SUM)\s*\(/i.test(line)) return { rule: "hand-rolled-aggregate", construct: "origin.aggregate" }; - if (/\.reduce\s*\(/.test(line) && (/=>[^;]*\+/.test(line) || /,\s*0\s*\)/.test(line))) + if (!isUi && SUM_REDUCE.test(line)) return { rule: "hand-rolled-aggregate", construct: "origin.aggregate" }; - // 2) Money handled as a float / hand-rolled minor units. Require BOTH a money - // word AND minor-unit math on the same line to stay high-precision. - if (MONEY_WORD.test(line) && MONEY_MATH.test(line)) + // 2) Money handled as a float / hand-rolled minor units: minor-unit math whose + // operand is money-named, so hours or a count formatted to two places is not. + if (moneyMath(line)) return { rule: "money-float", construct: "field.currency" }; // 3) A fixed value set enforced by a SQL CHECK (... IN (...)) — that's an enum. @@ -215,7 +280,7 @@ const ADVICE: Record = { }; function findingFor(file: string, lineNo: number, raw: string, isSql: boolean): AntiPatternFinding | null { - const hit = matchLine(raw, isSql); + const hit = matchLine(raw, isSql, UI_FILE.test(file)); if (!hit) return null; const snippet = raw.trim(); return { diff --git a/server/typescript/packages/cli/src/lib/fk-index-advisory.ts b/server/typescript/packages/cli/src/lib/fk-index-advisory.ts index 4a375ca589..4edfc44447 100644 --- a/server/typescript/packages/cli/src/lib/fk-index-advisory.ts +++ b/server/typescript/packages/cli/src/lib/fk-index-advisory.ts @@ -38,6 +38,10 @@ export interface UnindexedFkFinding { /** `.`. */ construct: string; message: string; + /** `.` (or ` ()`), the key in the author's terms. */ + label: string; + /** The `index.lookup` to declare, as `{ name: …, fields: … }`. */ + declaration: string; } export interface UnindexedFkOptions { @@ -108,6 +112,8 @@ export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOpt out.push({ file: files[0] ?? "", construct: `${fqn}.${ref?.name ?? fk.name}`, + label, + declaration: `{ name: ${indexName}, fields: ${fieldList} }`, message: `${label}: foreign key (${fields.join(", ")}) → ${fk.refTable} has no index covering it, ` + `so joins from ${fk.refTable} and its ON DELETE checks scan ${table.name}. ` + @@ -117,3 +123,26 @@ export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOpt } return out; } + +/** + * Every unindexed foreign key as ONE finding. The keys share a cause and a fix, so a model + * with 25 of them read as 25 separate problems and buried everything else `meta verify` + * said. The summary still names each key and the exact `index.lookup` to declare for it. + * `construct` lists every key's `.`, comma-separated; `file` is the + * one file they share, or "" when they span several. + */ +export function summarizeUnindexedFks( + findings: readonly UnindexedFkFinding[], +): { file: string; construct: string; message: string } | undefined { + if (findings.length === 0) return undefined; + const files = new Set(findings.map((f) => f.file)); + return { + file: files.size === 1 ? findings[0]!.file : "", + construct: findings.map((f) => f.construct).join(", "), + message: + `${findings.length} foreign key(s) have no index covering their columns, so joins from the ` + + `referenced table and its ON DELETE checks scan the referencing one. To index them, declare ` + + `an ${INDEX_LOOKUP} on each: ` + + findings.map((f) => `${f.label} ${f.declaration}`).join("; "), + }; +} diff --git a/server/typescript/packages/cli/src/lib/schema-advisories.ts b/server/typescript/packages/cli/src/lib/schema-advisories.ts index e30c7f985a..a3b5e9e59a 100644 --- a/server/typescript/packages/cli/src/lib/schema-advisories.ts +++ b/server/typescript/packages/cli/src/lib/schema-advisories.ts @@ -14,6 +14,10 @@ export function schemaAdvisoryLines(advisories: readonly SchemaAdvisory[] | unde case "sequence-not-applied": return `${t}.${a.column}: SQLite and D1 have no sequence, so the primary key's @sequenceStart / ` + `@sequenceName is not applied and the key counts from 1.`; + case "view-names-renamed-column": + return `view ${a.view} still names ${t}.${a.column}, which this migration renames to ${a.to}. ` + + `A rename never rewrites a view body, so this view is recreated as declared and the ` + + `migration fails at apply; change its definition (its @sql) to ${a.to} first.`; } }); } diff --git a/server/typescript/packages/cli/test/advisory-structured-output.test.ts b/server/typescript/packages/cli/test/advisory-structured-output.test.ts index 6aa739cce9..6f6a665ad2 100644 --- a/server/typescript/packages/cli/test/advisory-structured-output.test.ts +++ b/server/typescript/packages/cli/test/advisory-structured-output.test.ts @@ -167,8 +167,11 @@ describe("meta gen --format json carries the advisory findings", () => { // The count is stated where a reader will see it, not left to be counted. expect(payload.help.join(" ")).toContain(String(FINDINGS)); - // The human copy still goes to stderr, where it always did. + // stderr keeps the section header and points at the payload; it does not print each + // finding a second time (a large run's output doubled when it did). expect(err).toContain("hand-roll what MetaObjects can model"); + expect(err).toContain("listed in the --format payload on stdout"); + expect(err).not.toContain("meta types field.currency"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/server/typescript/packages/cli/test/anti-patterns.test.ts b/server/typescript/packages/cli/test/anti-patterns.test.ts index cf3753e7e6..26643ea1f5 100644 --- a/server/typescript/packages/cli/test/anti-patterns.test.ts +++ b/server/typescript/packages/cli/test/anti-patterns.test.ts @@ -397,4 +397,45 @@ describe("build output is not authored source", () => { rmSync(root, { recursive: true, force: true }); } }); + + // Found building a 19-entity app: of the advisories `meta verify` printed, most fired on + // comments, on hours formatted to two places, and on a timesheet's on-screen totals. + describe("does not fire on lines that are not money or aggregate code", () => { + const quiet: Record = { + "src/server/services.ts": [ + "/** R3: subtotal = Σ lineTotal; tax = round(subtotal × rate / 100, 2); total = subtotal + tax. */", + " * round(subtotal × ratePercent / 100, 2)", + "// SUM(hours) per project is projected elsewhere", + "const max = rows.reduce((m, r) => Math.max(m, Number(r.code.slice(prefix.length + 1)) || 0), 0);", + ].join("\n"), + "web/src/screens.tsx": [ + "

Time logged ({totalHours.toFixed(2)} h)

", + "{total.toFixed(2)}", + "const dayTotal = (d: string) => (entries.data ?? []).filter((e) => e.workDate === d).reduce((s, e) => s + Number(e.hours), 0);", + ].join("\n"), + "db/report.sql": "-- SUM(amount) lives in the projection now", + }; + test("comments, hour totals and on-screen sums are silent", () => { + const root = scaffold(quiet); + try { + expect(scanSourceForAntiPatterns(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("money-named operands still fire, including one in parentheses", () => { + const root = scaffold({ + "src/a.ts": "const outstanding = (Number(i.totalAmount) - Number(i.amountPaidAmount)).toFixed(2);", + "src/b.ts": "const fee = parseFloat(req.body.feeAmount);", + "src/c.ts": "const hours = Math.round(e.hours * 100) / 100;", + }); + try { + const files = scanSourceForAntiPatterns(root).filter((f) => f.rule === "money-float").map((f) => f.file); + expect(files).toEqual(["src/a.ts", "src/b.ts"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); }); diff --git a/server/typescript/packages/cli/test/integration/verify-db-view-recreate.test.ts b/server/typescript/packages/cli/test/integration/verify-db-view-recreate.test.ts index 719435ab57..0df46647d4 100644 --- a/server/typescript/packages/cli/test/integration/verify-db-view-recreate.test.ts +++ b/server/typescript/packages/cli/test/integration/verify-db-view-recreate.test.ts @@ -108,7 +108,7 @@ describe("D3 — a view recreated only around a table change", () => { }); const NOTE = - "1 view(s) match the metadata and are recreated only because the migration alters a table they read: v_programs"; + "1 view(s) keep their declared definition and are recreated only because the migration alters a table they read: v_programs"; const MIGRATE = ["--dialect", "sqlite", "--slug", "title-required", "--allow", "nullable-to-not-null", "--dry-run"]; // Both planners: the default diffs the metadata against the committed snapshot; --from-db diff --git a/server/typescript/packages/cli/test/verify-fk-index-lint.test.ts b/server/typescript/packages/cli/test/verify-fk-index-lint.test.ts index d9346a2116..f2208c3f44 100644 --- a/server/typescript/packages/cli/test/verify-fk-index-lint.test.ts +++ b/server/typescript/packages/cli/test/verify-fk-index-lint.test.ts @@ -131,7 +131,7 @@ describe("meta verify — unindexed foreign keys (advisory)", () => { expect(payload.summary).toContain("1 advisory finding(s)"); expect(payload.summary).not.toContain("anti-pattern"); const help = payload.help.join("\n"); - expect(help).toContain("1 foreign key(s) with no covering index"); + expect(help).toContain("foreign key(s) with no covering index"); expect(help).toContain("index.lookup"); expect(help).not.toContain("hand-roll"); }); @@ -170,4 +170,47 @@ describe("meta verify — the suggested FK index name is unique per table", () = expect(out).toContain("name: approvalByAuditEntryId"); expect(out).not.toContain("name: byAuditEntryId"); }); + + test("several unindexed keys are one summary finding that still names each", async () => { + const root = await mkdtemp(join(tmpdir(), "verify-fk-index-summary-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + const entity = (name: string, table: string) => ({ "object.entity": { name, children: [ + { "source.rdb": { "@table": table } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "auditEntryId", "@required": true } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "fkAudit", "@fields": ["auditEntryId"], "@references": "AuditEntry" } }, + ] } }); + await writeFile(join(root, "metaobjects", "meta.audit.json"), JSON.stringify({ "metadata.root": { + package: "audit", + children: [ + { "object.entity": { name: "AuditEntry", children: [ + { "source.rdb": { "@table": "audit_entries" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + entity("Citation", "citations"), + entity("Approval", "approvals"), + ], + } }), "utf8"); + const lines: string[] = []; + const log = console.log; + const err = console.error; + console.log = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); }; + console.error = () => {}; + try { + expect(await verifyCommand([], root, undefined, "json")).toBe(0); + } finally { + console.log = log; + console.error = err; + } + const payload = JSON.parse(lines.join("\n")) as { antiPatterns: { rows: { rule: string; construct: string; message: string }[] } }; + const rows = payload.antiPatterns.rows.filter((r) => r.rule === "unindexed-foreign-key"); + expect(rows).toHaveLength(1); + expect(rows[0]!.construct.split(", ").sort()).toEqual(["audit::Approval.fkAudit", "audit::Citation.fkAudit"]); + expect(rows[0]!.message).toStartWith("2 foreign key(s) have no index"); + expect(rows[0]!.message).toContain("name: citationByAuditEntryId"); + expect(rows[0]!.message).toContain("name: approvalByAuditEntryId"); + }); }); diff --git a/server/typescript/packages/cli/test/view-recreate-note-renamed-column.test.ts b/server/typescript/packages/cli/test/view-recreate-note-renamed-column.test.ts new file mode 100644 index 0000000000..3b716b39eb --- /dev/null +++ b/server/typescript/packages/cli/test/view-recreate-note-renamed-column.test.ts @@ -0,0 +1,33 @@ +// The recreate note vouches only for views whose declared definition still fits the change; +// a view whose body names a column the migration renames gets the advisory instead. +import { test, expect } from "bun:test"; +import type { Change } from "@metaobjectsdev/migrate-ts"; +import { viewRecreateNote } from "../src/lib/allow.js"; +import { schemaAdvisoryLines } from "../src/lib/schema-advisories.js"; + +const recreate = (name: string, sql: string): Change => ({ + kind: "create-view", view: { name, sql }, status: { state: "allowed" }, + reason: { kind: "unchanged", tables: ["time_entries"] }, +}); +const rename: Change = { + kind: "rename-column", table: "time_entries", from: "hours", to: "duration_hours", status: { state: "allowed" }, +}; + +test("a recreated view still naming the renamed column is left out of the note", () => { + const note = viewRecreateNote([ + rename, + recreate("v_stale", "SELECT sum(hours) FROM time_entries"), + recreate("v_other", "SELECT count(*) FROM time_entries"), + ]); + expect(note).toBe( + "1 view(s) keep their declared definition and are recreated only because the migration alters a table they read: v_other", + ); +}); + +test("the advisory names the view, the column and the fix", () => { + const [line] = schemaAdvisoryLines([ + { kind: "view-names-renamed-column", table: "time_entries", column: "hours", to: "duration_hours", view: "v_stale" }, + ]); + expect(line).toContain("view v_stale still names time_entries.hours"); + expect(line).toContain("fails at apply"); +}); diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index 6099423931..714f11d277 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -926,6 +926,9 @@ export type ${patchName} = ${z}.input;${preservingBl const HOSTNAME_REGEX_LITERAL = "/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/"; +/** The wire shape of a `field.uuid` value: 8-4-4-4-12 hex, either case. */ +const UUID_REGEX_LITERAL = "/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/"; + /** Zod expression for a scalar value subtype (used for a field.map's @valueType). */ function zodScalarFor(subType: string): string { if (subType === FIELD_SUBTYPE_INT || subType === FIELD_SUBTYPE_LONG || subType === FIELD_SUBTYPE_CURRENCY) return "z.number().int()"; @@ -1152,6 +1155,14 @@ function zodFieldExpr( break; } case FIELD_SUBTYPE_UUID: + // The 8-4-4-4-12 hex shape, the same check the filter parser applies to a uuid + // operand. Without it a malformed value (`"nope"` in a uuid FK) reached Postgres and + // answered 500 ("invalid input syntax for type uuid") instead of a 400. Deliberately + // not Zod's `.uuid()`: Zod 4 also demands RFC version bits, which would refuse + // well-formed keys such as 00000000-0000-0000-0000-000000000001. The TPH read shape + // keeps a bare string, as it does for every other format check. + baseStr = readShape ? "z.string()" : `z.string().regex(${UUID_REGEX_LITERAL}, "Invalid uuid")`; + break; default: baseStr = "z.string()"; break; diff --git a/server/typescript/packages/codegen-ts/test/zod-validators-execution.test.ts b/server/typescript/packages/codegen-ts/test/zod-validators-execution.test.ts index e5fc9d66ef..541d300524 100644 --- a/server/typescript/packages/codegen-ts/test/zod-validators-execution.test.ts +++ b/server/typescript/packages/codegen-ts/test/zod-validators-execution.test.ts @@ -38,7 +38,7 @@ import { IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_SUBTYPE_DURATION, - FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, FIELD_ATTR_REQUIRED, FIELD_ATTR_DEFAULT, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_VALUES, FIELD_ATTR_LENIENT, FIELD_ATTR_STRING_FORMAT, FIELD_ATTR_LOCAL_TIME, STRING_FORMAT_EMAIL, STRING_FORMAT_HOSTNAME, @@ -110,6 +110,27 @@ async function updateSchemaOf(entity: MetaObject): Promise schema.safeParse(value).success; +describe("field.uuid write schemas check the 8-4-4-4-12 shape", () => { + // A bare z.string() let a malformed uuid FK reach Postgres, which answered 500 + // ("invalid input syntax for type uuid") where a 400 was due. + test("insert and update accept a well-formed uuid, any version bits, and reject anything else", async () => { + const clientId = metaField(FIELD_SUBTYPE_UUID, "clientId"); + clientId.setAttr(FIELD_ATTR_REQUIRED, true); + const insert = await insertSchemaOf(entityWith("Project", clientId)); + const update = await updateSchemaOf(entityWith("Project", clientId)); + + for (const ok of ["3f2504e0-4f89-41d3-9a0c-0305e82c3301", "00000000-0000-0000-0000-000000000001", "3F2504E0-4F89-41D3-9A0C-0305E82C3301"]) { + expect(accepts(insert, { clientId: ok })).toBe(true); + expect(accepts(update, { clientId: ok })).toBe(true); + } + for (const bad of ["nope", "", "3f2504e0-4f89-41d3-9a0c-0305e82c330", "3f2504e04f8941d39a0c0305e82c3301"]) { + expect(accepts(insert, { clientId: bad })).toBe(false); + expect(accepts(update, { clientId: bad })).toBe(false); + } + expect(accepts(update, {})).toBe(true); + }); +}); + describe("FR-036 Pin 2 — validator.regex @pattern is FULL-MATCH, not a search", () => { // The emitter wraps an authored pattern as `^(?:…)$`. The non-capturing group // is load-bearing and a text assertion cannot show why: JS alternation binds diff --git a/server/typescript/packages/integration-tests/test/generated-uuid-routes-pg.test.ts b/server/typescript/packages/integration-tests/test/generated-uuid-routes-pg.test.ts new file mode 100644 index 0000000000..92a52acd7d --- /dev/null +++ b/server/typescript/packages/integration-tests/test/generated-uuid-routes-pg.test.ts @@ -0,0 +1,166 @@ +/** + * The STOCK generated routes on Postgres with uuid keys and a uuid foreign key — the one + * combination no adopter exercised, which is how two 500s shipped in it: + * + * - `?search=` ILIKEd the uuid FK (it is `subType: "string", format: "uuid"` in the + * generated allowlist), and Postgres has no `uuid ~~* text` operator. + * - A malformed uuid in `:id`, or in a body FK, reached Postgres as a literal and failed + * with "invalid input syntax for type uuid" — a 500 where a malformed numeric id already + * answered 400 `invalid_id`. + * + * SQLite stores a uuid as text, so neither shows there. This runs the real chain: the schema + * from the migrate engine, `entityFile()` + `routesFile()` emitted by `runGen`, and the + * emitted `*.routes.ts` imported unmodified and mounted on Fastify. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import Fastify, { type FastifyInstance } from "fastify"; +import { buildExpectedSchema, diff, emit, introspectPostgres } from "@metaobjectsdev/migrate-ts"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { runGen, defineConfig } from "@metaobjectsdev/codegen-ts"; +import { entityFile, routesFile } from "@metaobjectsdev/test-generators"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import pg from "pg"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { startPostgres, type RunningPg } from "../src/postgres-container.ts"; + +const META = `{ + "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Client", "children": [ + { "source.rdb": { "@table": "clients" } }, + { "field.uuid": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 100, "@filterable": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "uuid" } } + ] } }, + { "object.entity": { "name": "Project", "children": [ + { "source.rdb": { "@table": "projects" } }, + { "field.uuid": { "name": "id" } }, + { "field.uuid": { "name": "clientId", "@required": true, "@filterable": true } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 100, "@filterable": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "uuid" } }, + { "identity.reference": { "name": "fkClient", "@fields": "clientId", "@references": "Client" } } + ] } } + ] } +}`; + +let runningPg: RunningPg; +let pool: pg.Pool; +let k: Kysely; +let tmp: string; +let fastify: FastifyInstance; +let genPool: pg.Pool | undefined; +let clientId: string; + +async function load() { + return (await new MetaDataLoader().load([new InMemoryStringSource(META)])).root; +} + +async function call(method: string, url: string, payload?: unknown): Promise<{ status: number; body: any }> { + const res = await fastify.inject({ + method: method as "GET", + url, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + return { status: res.statusCode, body: res.statusCode === 204 ? null : res.json() }; +} + +beforeAll(async () => { + runningPg = await startPostgres(); + pool = new pg.Pool({ connectionString: runningPg.connectionUri }); + k = new Kysely({ dialect: new PostgresDialect({ pool }) }); + + // The schema the migrate engine builds, so the uuid columns are what `meta migrate` makes. + await sql.raw(`DROP TABLE IF EXISTS "projects", "clients" CASCADE;`).execute(k); + const result = await diff({ + expected: buildExpectedSchema(await load(), { dialect: "postgres" }), + actual: await introspectPostgres(k), + dialect: "postgres", + }); + expect(result.blocked).toEqual([]); + const { up } = emit(result.changes, { dialect: "postgres" }); + for (const stmt of up.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) { + await sql.raw(stmt.endsWith(";") ? stmt : `${stmt};`).execute(k); + } + + const here = dirname(fileURLToPath(import.meta.url)); + const genTmpRoot = join(here, "..", ".gen-tmp"); + mkdirSync(genTmpRoot, { recursive: true }); + tmp = mkdtempSync(join(genTmpRoot, "generated-uuid-routes-")); + const lr = await runGen({ + config: defineConfig({ + outDir: tmp, extStyle: "none", dbImport: "./db", dialect: "postgres", + generators: [entityFile(), routesFile()], + }), + metadata: await load(), + }); + if (lr.warnings.length > 0) throw new Error(`codegen warnings: ${lr.warnings.join("; ")}`); + writeFileSync(join(tmp, "db.ts"), ` +import { drizzle } from "drizzle-orm/node-postgres"; +import pg from "pg"; +export const pool = new pg.Pool({ connectionString: ${JSON.stringify(runningPg.connectionUri)} }); +export const db = drizzle(pool); +`, "utf8"); + + type RoutesModule = Record Promise>; + const clientRoutes = (await import(pathToFileURL(join(tmp, "Client.routes.ts")).href)) as RoutesModule; + const projectRoutes = (await import(pathToFileURL(join(tmp, "Project.routes.ts")).href)) as RoutesModule; + genPool = ((await import(pathToFileURL(join(tmp, "db.ts")).href)) as { pool: pg.Pool }).pool; + fastify = Fastify(); + await fastify.register(clientRoutes["clientRoutes"]!); + await fastify.register(projectRoutes["projectRoutes"]!); + await fastify.ready(); + + const client = await call("POST", "/clients", { name: "Acme" }); + expect(client.status).toBe(201); + clientId = client.body.id; + for (const name of ["Acme relaunch", "Globex audit"]) { + expect((await call("POST", "/projects", { clientId, name })).status).toBe(201); + } +}, 180_000); + +afterAll(async () => { + await fastify?.close(); + await genPool?.end(); + await k?.destroy(); + await runningPg?.stop(); + if (tmp) rmSync(tmp, { recursive: true, force: true }); +}); + +describe("stock generated routes — uuid keys and a uuid FK on real Postgres", () => { + test("?search= on an entity with a uuid FK answers 200 and searches its text columns", async () => { + const { status, body } = await call("GET", "/projects?search=acme"); + expect(status).toBe(200); + expect(body.map((r: { name: string }) => r.name)).toEqual(["Acme relaunch"]); + }); + + test("filtering on the uuid FK still works", async () => { + const { status, body } = await call("GET", `/projects?filter[clientId][eq]=${clientId}`); + expect(status).toBe(200); + expect(body).toHaveLength(2); + }); + + for (const method of ["GET", "PATCH", "DELETE"]) { + test(`${method} /projects/not-a-uuid answers 400 invalid_id`, async () => { + const { status, body } = await call(method, "/projects/not-a-uuid", method === "PATCH" ? { name: "x" } : undefined); + expect(status).toBe(400); + expect(body).toEqual({ error: "invalid_id" }); + }); + } + + test("a well-formed uuid that names no row answers 404", async () => { + expect((await call("GET", "/projects/00000000-0000-0000-0000-0000000000ff")).status).toBe(404); + }); + + test("POST and PATCH with a malformed uuid FK answer 400 validation", async () => { + const created = await call("POST", "/projects", { clientId: "nope", name: "x" }); + expect(created.status).toBe(400); + expect(created.body.error).toBe("validation"); + + const target = (await call("GET", "/projects?search=globex")).body[0].id as string; + const patched = await call("PATCH", `/projects/${target}`, { clientId: "123" }); + expect(patched.status).toBe(400); + expect(patched.body.error).toBe("validation"); + }); +}); diff --git a/server/typescript/packages/migrate-ts/src/diff/index.ts b/server/typescript/packages/migrate-ts/src/diff/index.ts index bdd995519d..31db429274 100644 --- a/server/typescript/packages/migrate-ts/src/diff/index.ts +++ b/server/typescript/packages/migrate-ts/src/diff/index.ts @@ -15,6 +15,7 @@ import { detectColumnRenames, detectTableRenames } from "./rename-heuristic.js"; import { firstViewSqlDifference, viewSqlEquals } from "../view-sql-compare.js"; import { viewReplaceIsLegal, type ViewReplaceProbe } from "../view-replace.js"; import { liveViewReadsTable, sqlNamesIdentifier } from "../view-reads.js"; +import { viewsNamingRenamedColumns } from "../view-change-reason.js"; import { expectedFromShadow, shadowIdentityProbe, type ShadowSchemaSource } from "../shadow-expected.js"; import { checkExprEquals, checkExprRespelledNullSafe, renameExprIdentifiers } from "../check-expr-compare.js"; import { checkMatchesLive, columnDefaultMatchesLive, indexExprMatchesLive, NO_EXPR_CANON, type ExprCanon, type ExprCanonProbe, type IndexExprCanon } from "../expr-canon.js"; @@ -407,6 +408,7 @@ export async function diff( advisories: [ ...intervalAsStringAdvisories(expectedTables, (id) => actualTables.get(id) ?? predecessors.get(id)), ...(args.dialect === "sqlite" || args.dialect === "d1" ? sequenceNotAppliedAdvisories(changes) : []), + ...viewsNamingRenamedColumns(changes), ], ...(args.dialect === "postgres" ? { exprCanonPath } : {}), ...(shadowAnswer !== undefined && "unavailable" in shadowAnswer ? { shadowUnavailable: shadowAnswer.unavailable } : {}), diff --git a/server/typescript/packages/migrate-ts/src/index.ts b/server/typescript/packages/migrate-ts/src/index.ts index b30ea8d700..5a4c573b4b 100644 --- a/server/typescript/packages/migrate-ts/src/index.ts +++ b/server/typescript/packages/migrate-ts/src/index.ts @@ -102,7 +102,7 @@ export type { WriteMigrationFlywayOptions, WriteMigrationFlywayResult } from "./ // there is no standalone view-migration emitter (the source-aware-diff / view-diff // / view-ddl-{postgres,sqlite} stack was folded into the schema-diff path). export { normalizeViewSql, viewSqlEquals } from "./view-sql-compare.js"; -export { isViewRecreateOnly, withoutViewRecreates } from "./view-change-reason.js"; +export { isViewRecreateOnly, withoutViewRecreates, viewsNamingRenamedColumns } from "./view-change-reason.js"; // Whether a live Postgres view can be replaced in place is asked of the database, in a // transaction that is always rolled back. Pass the probe to `diff` on a live plan. export { postgresViewReplaceProbe } from "./view-replace-probe.js"; diff --git a/server/typescript/packages/migrate-ts/src/types.ts b/server/typescript/packages/migrate-ts/src/types.ts index 15a6bdeb0d..42da43abde 100644 --- a/server/typescript/packages/migrate-ts/src/types.ts +++ b/server/typescript/packages/migrate-ts/src/types.ts @@ -639,10 +639,14 @@ export interface DiffResult { * - `sequence-not-applied`: a column created on SQLite or D1 whose primary key declares * `@sequenceStart` or `@sequenceName`. SQLite has no sequence, so neither is applied; the * key counts from 1. Reported when the column is created, not on every run. + * - `view-names-renamed-column`: a view this migration (re)creates whose body still names a + * column the same migration renames, on a table it reads. The body is written as declared + * (an `@sql` view's text is never rewritten), so the CREATE VIEW fails at apply. */ export type SchemaAdvisory = | { kind: "interval-as-string"; table: string; schema?: string; column: string } - | { kind: "sequence-not-applied"; table: string; schema?: string; column: string }; + | { kind: "sequence-not-applied"; table: string; schema?: string; column: string } + | { kind: "view-names-renamed-column"; table: string; schema?: string; column: string; to: string; view: string }; /** * Where Postgres's own spelling of an expected expression came from. diff --git a/server/typescript/packages/migrate-ts/src/view-change-reason.ts b/server/typescript/packages/migrate-ts/src/view-change-reason.ts index 936a8391df..ba0296c597 100644 --- a/server/typescript/packages/migrate-ts/src/view-change-reason.ts +++ b/server/typescript/packages/migrate-ts/src/view-change-reason.ts @@ -3,7 +3,8 @@ // `diff` attaches a `ViewChangeReason` to every view change it plans. This module answers // the one question every drift report needs from it: is this change drift at all? -import type { Change } from "./types.js"; +import type { Change, SchemaAdvisory } from "./types.js"; +import { sqlNamesIdentifier } from "./view-reads.js"; /** * True for a view change whose definition matches the metadata: half of the drop/create @@ -19,3 +20,32 @@ export function isViewRecreateOnly(c: Change): boolean { export function withoutViewRecreates(changes: readonly C[]): C[] { return changes.filter((c) => !isViewRecreateOnly(c)); } + +/** + * Views this change set (re)creates whose body still names a column the same change set + * renames, on the renamed column's table. A declared rename rewrites the table, never a view + * body: an `@sql` view is created from its text as written, so a body left on the old name + * fails at apply ("column … does not exist"), long after `meta migrate` and `meta verify` + * passed. A textual match, so it can over-report; it is an advisory, never a block. + */ +export function viewsNamingRenamedColumns(changes: readonly Change[]): SchemaAdvisory[] { + const renames = changes.flatMap((c) => (c.kind === "rename-column" ? [c] : [])); + if (renames.length === 0) return []; + const out: SchemaAdvisory[] = []; + for (const c of changes) { + if (c.kind !== "create-view" || c.view.sql === undefined) continue; + for (const r of renames) { + if (sqlNamesIdentifier(c.view.sql, r.from) && sqlNamesIdentifier(c.view.sql, r.table)) { + out.push({ + kind: "view-names-renamed-column", + table: r.table, + ...(r.schema !== undefined ? { schema: r.schema } : {}), + column: r.from, + to: r.to, + view: c.view.name, + }); + } + } + } + return out; +} diff --git a/server/typescript/packages/migrate-ts/test/unit/view-names-renamed-column.test.ts b/server/typescript/packages/migrate-ts/test/unit/view-names-renamed-column.test.ts new file mode 100644 index 0000000000..02fbf56556 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/view-names-renamed-column.test.ts @@ -0,0 +1,61 @@ +/** + * A declared column rename never rewrites a view body. An `@sql` view still naming the old + * column is recreated as written, and the migration fails at apply ("column … does not + * exist") after `meta migrate` and `meta verify` both passed. The diff now says so as an + * advisory, and the recreate note no longer claims such a view matches the metadata. + */ +import { test, expect, describe } from "bun:test"; +import { diff } from "../../src/diff/index.js"; +import { viewsNamingRenamedColumns } from "../../src/view-change-reason.js"; +import type { SchemaSnapshot, TableDescriptor, ViewDescriptor } from "../../src/types.js"; + +function timeEntries(hoursColumn: string): TableDescriptor { + return { + name: "time_entries", + columns: [ + { name: "id", sqlType: { kind: "integer", bits: 64 }, nullable: false }, + { name: hoursColumn, sqlType: { kind: "text" }, nullable: true }, + ], + indexes: [], + foreignKeys: [], + primaryKey: ["id"], + checks: [], + }; +} + +const view = (body: string): ViewDescriptor => ({ name: "v_hours", sql: body }); +const snap = (t: TableDescriptor, v: ViewDescriptor): SchemaSnapshot => ({ tables: [t], views: [v] }); +const RENAME = [{ kind: "column" as const, table: "time_entries", from: "hours", to: "duration_hours" }]; + +describe("a recreated view whose body names a renamed column", () => { + test("is reported as an advisory when its body was left on the old name", async () => { + const stale = view("SELECT sum(hours) AS total FROM time_entries"); + const r = await diff({ + expected: snap(timeEntries("duration_hours"), stale), + actual: snap(timeEntries("hours"), stale), + renames: RENAME, + dialect: "sqlite", + }); + expect(r.changes.some((c) => c.kind === "create-view")).toBe(true); + expect(r.advisories).toContainEqual({ + kind: "view-names-renamed-column", table: "time_entries", column: "hours", to: "duration_hours", view: "v_hours", + }); + }); + + test("is not reported once its body names the new column", async () => { + const r = await diff({ + expected: snap(timeEntries("duration_hours"), view("SELECT sum(duration_hours) AS total FROM time_entries")), + actual: snap(timeEntries("hours"), view("SELECT sum(hours) AS total FROM time_entries")), + renames: RENAME, + dialect: "sqlite", + }); + expect(r.advisories.filter((a) => a.kind === "view-names-renamed-column")).toEqual([]); + }); + + test("a body that does not read the renamed column's table is not reported", () => { + expect(viewsNamingRenamedColumns([ + { kind: "rename-column", table: "time_entries", from: "hours", to: "duration_hours", status: { state: "allowed" } }, + { kind: "create-view", view: view("SELECT hours FROM shifts"), status: { state: "allowed" } }, + ])).toEqual([]); + }); +}); diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts index 644e9a9616..540b6626ab 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts @@ -130,7 +130,12 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult { if (typeof opts.query.search === "string" && opts.query.search !== "") { const term = `%${opts.query.search}%`; const stringCols = Object.entries(opts.allowlist) - .filter(([, rule]) => (rule as FilterFieldRule).subType === "string") + // A rule with a `format` (uuid, …) is a typed column on the wire as a string, not + // text: ILIKE on a Postgres uuid column is "operator does not exist" (a 500). + .filter(([, rule]) => { + const r = rule as FilterFieldRule; + return r.subType === "string" && r.format === undefined; + }) .map(([field]) => opts.table[field]); if (stringCols.length > 0) { // `?search` is a TS-only extension (docs/features/api-contract.md) and is diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/util.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/util.ts index a870252adc..d6aaea4bbb 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/util.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/util.ts @@ -2,6 +2,9 @@ * Shared utilities for drizzle-fastify route helpers. */ +import { FIELD_SUBTYPE_UUID } from "@metaobjectsdev/metadata"; +import { matchesFormat } from "./filter-value-format.js"; + /** * Coerce a path-param id to number when numeric, else keep the string key. * @@ -26,8 +29,8 @@ export function parseId(raw: string): number | string { * matches no row (libsql throws outright) — so a row that is plainly present in the list * response 404s on its own detail route. * - * Returns `undefined` only when the id is malformed FOR A NUMERIC column — the one case - * in which a bad id is actually knowable — so the caller can answer 400. With no column + * Returns `undefined` when the id is malformed for a numeric column or a Postgres uuid + * column — the cases in which a bad id is actually knowable — so the caller can answer 400. With no column * to inspect (a raw-SQL view with no declared columns) it falls back to `parseId`. */ export function coerceIdForColumn(colRef: unknown, raw: string): number | string | undefined { @@ -36,6 +39,12 @@ export function coerceIdForColumn(colRef: unknown, raw: string): number | string const n = Number(raw); return Number.isFinite(n) && raw.trim() !== "" ? n : undefined; } + // A Postgres uuid column rejects a malformed literal at execution time + // ("invalid input syntax for type uuid"), which surfaced as a 500. The id's shape + // is knowable here, so a malformed one answers 400 like a malformed numeric id. + if ((colRef as { columnType?: string }).columnType === "PgUUID" && !matchesFormat(FIELD_SUBTYPE_UUID, raw)) { + return undefined; + } if (dataType !== undefined) return raw; // string / uuid / bigint / … — compare as given return parseId(raw); } diff --git a/server/typescript/packages/runtime-ts/test/drizzle-fastify/filter-parser.test.ts b/server/typescript/packages/runtime-ts/test/drizzle-fastify/filter-parser.test.ts index 8f2bbc424f..a01cc628a0 100644 --- a/server/typescript/packages/runtime-ts/test/drizzle-fastify/filter-parser.test.ts +++ b/server/typescript/packages/runtime-ts/test/drizzle-fastify/filter-parser.test.ts @@ -265,6 +265,20 @@ describe("parseFilterParams — happy path", () => { expect(sqlText).not.toContain(" like "); }); + test("search skips string rules that carry a format (a uuid FK is not text)", () => { + const withFk: FilterAllowlist = { + ...allowlist, + ownerId: { ops: ["eq", "in", "isNull"], subType: "string", leadingWildcard: false, format: "uuid" }, + }; + const r = parseFilterParams({ + query: { search: "term" }, + table: { ...table, ownerId: sql.identifier("owner_id") as any }, allowlist: withFk, sortAllowlist, dialect: "postgres", + }); + const sqlText = collectSqlText(r.searchWhere); + expect(sqlText).toContain("email"); + expect(sqlText).not.toContain("owner_id"); + }); + test("search on sqlite dispatches like (not ilike) for string fields", () => { const r = parseFilterParams({ query: { search: "term" }, diff --git a/server/typescript/packages/runtime-ts/test/uuid-input-pg.test.ts b/server/typescript/packages/runtime-ts/test/uuid-input-pg.test.ts new file mode 100644 index 0000000000..c2e06b12fa --- /dev/null +++ b/server/typescript/packages/runtime-ts/test/uuid-input-pg.test.ts @@ -0,0 +1,147 @@ +/** + * A uuid-keyed entity on a real Postgres: ordinary bad input must answer 4xx, never 500. + * + * Both defects were found by building a 19-entity app on the generated routes: + * - `?search=` ILIKEd every `subType: "string"` allowlist rule, and a uuid FK is + * `subType: "string", format: "uuid"` on the wire. Postgres has no `uuid ~~* text` + * operator, so a search on any entity with a uuid FK answered 500. + * - A malformed `:id` on a uuid primary key reached Postgres as a literal and failed + * with "invalid input syntax for type uuid" — a 500 where a malformed numeric id + * already answered 400 `invalid_id`. + * + * Neither shows on SQLite (uuid is text there), which is why this file needs Postgres. + * Gated on METAOBJECTS_TEST_PG_URL, like dialect-matrix-pg.test.ts. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { Hono } from "hono"; +import Fastify, { type FastifyInstance } from "fastify"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { z } from "zod"; +import { mountCrudRoutes as mountHono } from "../src/hono/index.js"; +import { mountCrudRoutes as mountFastify } from "../src/drizzle-fastify/index.js"; +import type { FilterAllowlist, SortAllowlist } from "../src/drizzle-fastify/filter-allowlist.js"; + +const PG_URL = process.env["METAOBJECTS_TEST_PG_URL"]; + +const projects = pgTable("mo_uuid_projects", { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id").notNull(), + name: text("name").notNull(), +}); + +const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; +const InsertSchema = z.object({ clientId: z.string().regex(UUID, "Invalid uuid"), name: z.string() }); +const UpdateSchema = InsertSchema.partial(); + +const filterAllowlist: FilterAllowlist = { + clientId: { ops: ["eq", "ne", "in", "isNull"], subType: "string", leadingWildcard: false, format: "uuid" }, + name: { ops: ["eq", "ne", "in", "like", "isNull"], subType: "string", leadingWildcard: true }, +}; +const sortAllowlist: SortAllowlist = { name: {} }; + +const CLIENT = "00000000-0000-0000-0000-000000000001"; + +describe("uuid-keyed entity on real Postgres — bad input answers 4xx", () => { + if (!PG_URL) { + test.skip("skipped — METAOBJECTS_TEST_PG_URL not set", () => {}); + return; + } + + let pool: Pool; + let fastify: FastifyInstance; + type Res = Promise<{ status: number; body: unknown }>; + const adapters: Array<{ name: string; call: (method: string, url: string, payload?: unknown) => Res }> = []; + + beforeAll(async () => { + pool = new Pool({ connectionString: PG_URL }); + const db = drizzle(pool); + await pool.query(`DROP TABLE IF EXISTS mo_uuid_projects`); + await pool.query( + `CREATE TABLE mo_uuid_projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL, + name text NOT NULL + )`, + ); + await pool.query( + `INSERT INTO mo_uuid_projects (client_id, name) VALUES ($1, 'Acme relaunch'), ($1, 'Globex audit')`, + [CLIENT], + ); + const opts = { + path: "/projects", db, table: projects, insertSchema: InsertSchema, updateSchema: UpdateSchema, + filterAllowlist, sortAllowlist, dialect: "postgres" as const, + }; + const h = new Hono(); + mountHono({ app: h, ...opts }); + fastify = Fastify(); + mountFastify({ fastify, ...opts }); + await fastify.ready(); + adapters.push( + { + name: "hono", + call: async (method, url, payload) => { + const res = await h.request(url, { + method, + ...(payload === undefined ? {} : { body: JSON.stringify(payload), headers: { "content-type": "application/json" } }), + }); + return { status: res.status, body: res.status === 204 ? null : await res.json() }; + }, + }, + { + name: "fastify", + call: async (method, url, payload) => { + const res = await fastify.inject({ + method: method as "GET", + url, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + return { status: res.statusCode, body: res.statusCode === 204 ? null : res.json() }; + }, + }, + ); + }); + + afterAll(async () => { + await fastify?.close(); + await pool.query(`DROP TABLE IF EXISTS mo_uuid_projects`); + await pool.end(); + }); + + for (const name of ["hono", "fastify"]) { + const use = () => { + const a = adapters.find((x) => x.name === name); + if (!a) throw new Error(`adapter ${name} was never mounted`); + return a; + }; + + describe(name, () => { + test("?search= skips the uuid FK and still searches the text columns", async () => { + const { status, body } = await use().call("GET", "/projects?search=acme"); + expect(status).toBe(200); + expect((body as Array<{ name: string }>).map((r) => r.name)).toEqual(["Acme relaunch"]); + }); + + for (const method of ["GET", "PATCH", "DELETE"]) { + test(`${method} with a malformed uuid id answers 400 invalid_id`, async () => { + const { status, body } = await use().call(method, "/projects/not-a-uuid", method === "PATCH" ? { name: "x" } : undefined); + expect(status).toBe(400); + expect(body).toEqual({ error: "invalid_id" }); + }); + } + + test("a well-formed but absent uuid id still answers 404", async () => { + const { status } = await use().call("GET", "/projects/00000000-0000-0000-0000-0000000000ff"); + expect(status).toBe(404); + }); + + test("POST with a malformed uuid FK answers 400 from the insert schema", async () => { + const { status, body } = await use().call("POST", "/projects", { clientId: "nope", name: "x" }); + expect(status).toBe(400); + expect((body as { error: string }).error).toBe("validation"); + }); + }); + } +}); diff --git a/server/typescript/packages/test-generators/src/templates/zod-validators.ts b/server/typescript/packages/test-generators/src/templates/zod-validators.ts index ec3a5557b3..2e421a0819 100644 --- a/server/typescript/packages/test-generators/src/templates/zod-validators.ts +++ b/server/typescript/packages/test-generators/src/templates/zod-validators.ts @@ -926,6 +926,9 @@ export type ${patchName} = ${z}.input;${preservingBl const HOSTNAME_REGEX_LITERAL = "/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/"; +/** The wire shape of a `field.uuid` value: 8-4-4-4-12 hex, either case. */ +const UUID_REGEX_LITERAL = "/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/"; + /** Zod expression for a scalar value subtype (used for a field.map's @valueType). */ function zodScalarFor(subType: string): string { if (subType === FIELD_SUBTYPE_INT || subType === FIELD_SUBTYPE_LONG || subType === FIELD_SUBTYPE_CURRENCY) return "z.number().int()"; @@ -1152,6 +1155,14 @@ function zodFieldExpr( break; } case FIELD_SUBTYPE_UUID: + // The 8-4-4-4-12 hex shape, the same check the filter parser applies to a uuid + // operand. Without it a malformed value (`"nope"` in a uuid FK) reached Postgres and + // answered 500 ("invalid input syntax for type uuid") instead of a 400. Deliberately + // not Zod's `.uuid()`: Zod 4 also demands RFC version bits, which would refuse + // well-formed keys such as 00000000-0000-0000-0000-000000000001. The TPH read shape + // keeps a bare string, as it does for every other format check. + baseStr = readShape ? "z.string()" : `z.string().regex(${UUID_REGEX_LITERAL}, "Invalid uuid")`; + break; default: baseStr = "z.string()"; break; From a71f94ac9c98da1fd0885c6736e8b079ec3057c8 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 03:46:21 -0500 Subject: [PATCH 2/2] no-mistakes(document): Sync docs for 1.3.0 smoke-test fixes --- docs/features/api-contract.md | 1 + docs/features/cli.md | 6 +++++- docs/features/migrations-and-drift.md | 6 ++++++ docs/features/migrations/upgrading-within-1.0.md | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index f8f66a09fe..461dacf19b 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -215,6 +215,7 @@ or Hono `onError` the adopter installed, answer exactly as they did before. | Response | When | |---|---| | malformed JSON body | a `POST`/`PATCH`/`PUT` body that does not parse as JSON (an empty body sent as `application/json` included) → HTTP 400 `{ "error": "invalid_json" }`. An EMPTY body on a method that takes none (`GET`, `DELETE`, `HEAD`) is accepted even when the request declares `content-type: application/json`, as many clients do on every request; a non-empty `DELETE` body is still parsed. Before, Fastify answered its own `{ "statusCode": 400, "code": "FST_ERR_CTP_INVALID_JSON_BODY", … }` and Hono a Zod `validation` error about a missing object | +| malformed id | a `:id` the key column cannot hold (a malformed uuid on a Postgres uuid key, a non-numeric id on an integer key) → HTTP 400 `{ "error": "invalid_id" }` on GET, PATCH and DELETE. A malformed uuid in a POST or PATCH body's foreign key → HTTP 400 `{ "error": "validation" }`, from the insert/update schema's `8-4-4-4-12` hex check. Postgres used to answer 500 for both. The check is TS-only | | malformed page bound | a `?limit=` or `?offset=` that is not a non-negative integer (`abc`, `-5`, `1.5`, an empty value) → HTTP 400 `{ "error": "pagination.invalid_value", "param": "limit", "value": "abc", "expected": "a non-negative integer (0 or more)" }`. Before, a non-numeric bound was dropped (every row came back) and a negative or fractional one reached SQL. There is no page-size maximum on an entity or a projection with declared columns; an opaque view (no declared columns, served by raw SQL) pages to at most 1000 rows, so there a `limit` above 1000 is refused too, with `"expected": "an integer from 0 to 1000"` (it used to be clamped silently). The same answer comes from the Drizzle mounts and the ObjectManager Fastify mount. The corpus sends a malformed bound only to a report (`report/bad-page-bound`), where every port answers this same envelope and also refuses `limit=0`; on an entity or a projection the other ports answer it as their framework does | | database constraint violation | a write the database itself rejects → `{ "error": "constraint_violation", "constraint": "" }`, HTTP **409** for `unique` and `foreign_key` (a conflict with another row) and HTTP **400** for `check` and `not_null` (the request's own value), per the status rule under "Error response". The body names the constraint KIND only — never the SQL, the table or the bound values. A `CHECK` the model derives (an enum, a `validator.numeric`/`length`/`regex` range) is normally refused earlier by the route's own validation, as the corpus's `{ "error": "validation" }` 400; this row is the database's backstop | | schema validation failure | a body that fails the insert/update schema → HTTP 400 `{ "error": "validation", "issues": [...] }`, each issue as Zod reports it **minus `pattern`**: Zod 4 attaches a failed `.regex()` check's source there, which for a generated `field.date` / `field.time` / `field.timestamp` check is a ~300-character calendar regex (and an authored `validator.regex` pattern is the server's to keep). The generated temporal checks carry a short `message` naming the expected format instead (`"must be an ISO date (YYYY-MM-DD)"`) | diff --git a/docs/features/cli.md b/docs/features/cli.md index d8d57be694..a2c84a40ee 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -583,9 +583,13 @@ the construct that replaces it: | Hand-rolled pattern | Suggested construct | |---|---| | 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` | +| money as a float / hand-rolled minor units (`* 100`, `/ 100`, `.toFixed(2)`, `parseFloat` on a money-named operand) | `field.currency` | | a fixed value set enforced by a SQL `CHECK (... IN (...))` | `field.enum` | +The scan skips comment lines. A `.reduce` counts only when it accumulates into its first +parameter with `+`, and `.tsx`/`.jsx` UI files are not scanned for it, so on-screen totals +are not flagged. + It is **warnings only** — it never changes the exit code (bias to under-flagging; a >15% false-positive rate is a project kill criterion). This pass is **Node-`meta`-specific**; the C#/Java/Kotlin/Python codegen surfaces do not run it. diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md index ef18b8d52e..3d414db849 100644 --- a/docs/features/migrations-and-drift.md +++ b/docs/features/migrations-and-drift.md @@ -754,6 +754,12 @@ the case of an `@sql` body that joins it. `meta migrate` finds it by name in the stored definition, and recreates the view only when that definition also names the changed column. The same two costs apply. +A rename never rewrites a view body. When a migration renames a column and a view's `@sql` +still names the old one, that view is recreated as declared and the migration fails at apply. +`meta migrate` prints an advisory for it (`view still names ., which this +migration renames to `), so the view, column and new name are all shown. The advisory does not block the plan. Change the view's `@sql` to the +new name before you apply. + SQLite and D1 have no `CREATE OR REPLACE VIEW`: every replaced view is written as `DROP VIEW` and `CREATE VIEW` there, so there is no refusal to avoid and no probe. They refuse something else: a `DROP COLUMN`, or the rebuild of a table, while a view that reads the table stands. So diff --git a/docs/features/migrations/upgrading-within-1.0.md b/docs/features/migrations/upgrading-within-1.0.md index d72f37bf79..16edeb1da4 100644 --- a/docs/features/migrations/upgrading-within-1.0.md +++ b/docs/features/migrations/upgrading-within-1.0.md @@ -67,7 +67,7 @@ cross. served `object.report` that means: `entity`, `queries`, `names` and `barrel` write no report files; `routes` and `routes-hono` mount `GET /:id` and the item refusals on a report, and on SQLite send a ratio as a number; `hooks` writes no report hook, so a page that calls - `useList` has nothing to import. `meta eject --list` shows which copies differ; + the report's list hook has nothing to import. `meta eject --list` shows which copies differ; `meta eject --force` takes the new reference, or three-way merge a copy you edited. An owned `mount-read-only.ts` under `codegen/runtime/` needs the same resync (`itemRoutes`, `resource` and `decimalColumns`). Only TypeScript has a generated client tier; the other