Skip to content
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,44 @@ metamodel vocabulary (ADR-0023 unaffected). See
[ADR-0044](spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md), whose
Consequences section now marks this recurrence closed.

### Fixed — persistability derives from a declared/inherited source, never object subtype (#248)

**npm-only** (`migrate-ts` + `codegen-ts`; PyPI / NuGet / Maven Central unchanged — schema
migrations and this codegen tier are TS-owned artifacts, ADR-0015, and no other port emits
DDL). Both `migrate-ts`'s expected-schema builder and `codegen-ts`'s query/route/api-doc
emitters decided "is this object persisted?" against a hardcoded `subType === "value"`
compare (migrate) or a subtype allowlist (codegen), instead of asking whether the object
declares — or inherits via `extends` — a `source.*` child, which is the loader's own
already-published contract (`validate-source-roles`: an object with zero sources loads clean
and means "not persisted"). Any OTHER provider-registered `object` subtype with no source fell
through both gates and was silently treated as persisted.

- **`meta migrate` / `meta verify --db|--d1`:** a sourceless object no longer produces a
phantom `CREATE TABLE` (with a fabricated physical table name) and is no longer eligible as
a foreign-key target. Reported blast radius: a package of roughly 150 wire-protocol message
objects modeled as a registered custom `object` subtype, co-loaded with domain entities so
messages could reference them, produced well over a hundred phantom `CREATE TABLE`
statements — making `meta migrate` and `meta verify --db` unusable against that model.
- **`meta gen`:** queries/routes (both the Fastify and Hono generators) and api-doc CRUD are
no longer emitted for a sourceless object, gated on the presence of a `source.rdb` of any
kind (a new `hasAnyRdbSource` check) — matching the Drizzle table tier's existing
`hasWritableRdbSource` gate. This also closes a pre-existing fail-open where a plain
`object.value` got a broken `*.routes.ts` file, importing a table const and
filter/sort allowlists that don't exist for a value object.

This is a bugfix, not a behavior-contract change — it aligns both tiers to an invariant the
loader already enforces. `meta gen` / `meta migrate` output is **byte-identical** for
well-formed models (every table-owning object declares or inherits a source, the norm); the
only delta is that files which could never have typechecked (importing exports that don't
exist) stop being emitted.

**Migration note for models already bitten by the bug:** a database or migration snapshot
that already contains phantom tables (created by applying a pre-fix migration) will correctly
propose `DROP TABLE` for them on the next `meta migrate` — destructive-gated behind the
existing `--allow drop-table` policy, so nothing drops without explicit opt-in.
Previously-generated broken `*.queries.ts` / `*.routes.ts` files are **not** auto-pruned
(`meta gen` never deletes existing files) — remove them by hand.

## [0.20.9] — 2026-07-28

**npm-only** — `migrate-ts` + `codegen-ts` (schema migrations and projection-view codegen
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function setupRepo(): string {
name: "Program",
package: "mikes::commerce",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id", children: [
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } },
] } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { run } from "../../src/index.js";

function metaJson(withColor: boolean): string {
const widgetChildren: Record<string, unknown>[] = [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "name", "@column": "name" } },
];
Expand Down Expand Up @@ -144,6 +145,7 @@ describe("meta verify --db — schema-drift gate", () => {
package: "acme::drift",
children: [
{ "object.entity": { name: "Widget", children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "name", "@column": "name" } },
{ "identity.primary": { name: "pk", "@fields": ["id"] } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("migrate command with --dialect d1", () => {
"object.entity": {
"name": "User",
"children": [
{ "source.rdb": {} },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "email" } },
{ "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } }
Expand Down
1 change: 1 addition & 0 deletions server/typescript/packages/cli/test/unit/verify-d1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe("meta verify --dialect d1 — schema-drift gate", () => {
"object.entity": {
name: "User",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "email" } },
{ "identity.primary": { name: "id", "@fields": ["id"], "@generation": "increment" } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function metaJson(): string {
"object.entity": {
name: "Widget",
children: [
{ "source.rdb": {} },
{ "field.long": { name: "id" } },
{ "field.string": { name: "name", "@column": "name" } },
{ "identity.primary": { name: "pk", "@fields": ["id"] } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"object.entity": {
"name": "Program",
"children": [
{ "source.rdb": {} },
{
"field.long": {
"name": "id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import { getPkInfo } from "../templates/queries.js";
import { isTphSubtype } from "../templates/zod-validators.js";
import { isTphDiscriminatorBase } from "../templates/tph-discriminator.js";
import { isCallableEntity } from "../templates/callable-file.js";
import { hasAnyRdbSource } from "../source-detect.js";
import { CODEGEN_ATTR_EMIT_ROUTES } from "../constants.js";
import { resourcePath } from "../templates/entity-constants.js";
import { isProjection } from "../projection/projection-detector.js";
Expand Down Expand Up @@ -334,18 +335,21 @@ function stripTs(path: string): string {
// ---------------------------------------------------------------------------

/** Mirror of the queries generator's filter (queries-file.ts `skipNonQueryable`
* = `subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype`). A queryable entity is
* any non-value, non-TPH-subtype object:
* • Value objects have no primary identity → the queries/routes/validation
* generators emit no CRUD for them.
* = `hasAnyRdbSource(e) && !isTphSubtype(e)`, #248 R2). A queryable object is
* any source-backed, non-TPH-subtype object:
* • An object with no declared/inherited source.rdb (of ANY kind) isn't
* backed by any store → the queries/routes/validation generators emit no
* CRUD for it. Value objects are subsumed here: value purity (ADR-0028)
* bans sources on values, loader-enforced, so no loadable value ever has
* hasAnyRdbSource === true.
* • TPH subtypes (@discriminatorValue under a @discriminator base) emit no
* standalone queries/routes file — their surface lives in the discriminator
* BASE's polymorphic file (routes-file.ts:27 + queries-file.ts:21-22).
* Either way the object contributes only a model symbol here. (The TPH base's
* per-subtype polymorphic helpers + subpaths are a documented deferral — see
* the module header.) */
function isQueryable(obj: MetaObject): boolean {
return obj.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(obj);
return hasAnyRdbSource(obj) && !isTphSubtype(obj);
}

/** Whether the routes generator emits REST routes for this entity. It filters
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { OBJECT_SUBTYPE_VALUE, type MetaObject } from "@metaobjectsdev/metadata";
import type { MetaObject } from "@metaobjectsdev/metadata";
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
import { renderQueriesFile } from "../templates/queries-file.js";
import { isTphSubtype } from "../templates/zod-validators.js";
import { hasAnyRdbSource } from "../source-detect.js";
import { formatTs } from "../format.js";
import { entityOutputPath } from "../import-path.js";

Expand All @@ -10,16 +11,20 @@ export interface QueriesFileOpts {
target?: string;
}

// object.value records have no primary identity, so the rendered queries module
// emits findById/updateById/deleteById against a non-existent column. Skipping
// value subtypes is unconditional — the user-supplied filter (if any) is applied
// on top via boolean AND.
// #248 R2: persistability derives from declared/inherited source, never
// subtype. An object with no source.rdb (of ANY kind) isn't backed by any
// store — the rendered queries module would emit findById/updateById/deleteById
// against Drizzle table/schema exports the entity-file generator never emits
// for it. object.value is subsumed here too: value purity (ADR-0028) bans
// sources on values, loader-enforced (ERR_SUBTYPE_RULE_VIOLATION), so no
// loadable value ever has hasAnyRdbSource === true — no separate value check
// needed. Skipping non-source objects is unconditional — the user-supplied
// filter (if any) is applied on top via boolean AND.
//
// FR-017 Tier 2: TPH subtypes are ALSO skipped — they emit no standalone
// queries file. Their per-subtype CRUD helpers live in the discriminator
// base's queries file (which targets the single shared table).
const skipNonQueryable = (e: MetaObject): boolean =>
e.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(e);
const skipNonQueryable = (e: MetaObject): boolean => hasAnyRdbSource(e) && !isTphSubtype(e);

export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
const userFilter = opts?.filter;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MetaObject } from "@metaobjectsdev/metadata";
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
import { renderRoutesFileHono } from "../templates/routes-file-hono.js";
import { hasAnyRdbSource } from "../source-detect.js";
import { formatTs } from "../format.js";
import { entityOutputPath } from "../import-path.js";
import { CODEGEN_ATTR_EMIT_ROUTES } from "../constants.js";
Expand All @@ -22,6 +23,10 @@ export interface RoutesFileHonoOpts {
*
* Per-entity opt-out via `@emitRoutes: false` is honored. If the user
* supplies their own filter, both must pass (AND).
*
* #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
* backed by any store — gated by `hasAnyRdbSource` (does NOT add TPH handling;
* that gap is pre-existing and out of scope here).
*/
export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts): Generator {
const userFilter = opts?.filter ?? (() => true);
Expand All @@ -31,7 +36,8 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
// `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface.
emitsHonoRoutes: true,
// ADR-0039: resolving — a concrete entity may inherit @emitRoutes via extends.
filter: (e: MetaObject) => e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && userFilter(e),
filter: (e: MetaObject) =>
e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && hasAnyRdbSource(e) && userFilter(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error("routes-file-hono: renderContext is required (provided by runGen)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
import { renderRoutesFile } from "../templates/routes-file.js";
import { isTphSubtype } from "../templates/zod-validators.js";
import { hasAnyRdbSource } from "../source-detect.js";
import { formatTs } from "../format.js";
import { entityOutputPath } from "../import-path.js";
import { CODEGEN_ATTR_EMIT_ROUTES } from "../constants.js";
Expand All @@ -15,6 +16,10 @@ export interface RoutesFileOpts {
* Per-entity opt-out via `@emitRoutes: false` is honored. If the user supplies
* their own filter, both must pass (AND).
*
* #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
* backed by any store — routes against it would import Drizzle table/allowlist
* exports the entity-file generator never emits. Gated by `hasAnyRdbSource`.
*
* FR-017 Tier 2: TPH subtypes get no standalone routes file — their per-subtype
* route set lives in the discriminator base's routes file.
*/
Expand All @@ -25,7 +30,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
// Always set: AND-composes metadata opt-out with optional user filter.
filter: (e: MetaObject) =>
// ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && !isTphSubtype(e) && userFilter(e),
e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && hasAnyRdbSource(e) && !isTphSubtype(e) && userFilter(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error("routes-file: renderContext is required (provided by runGen)");
Expand Down
2 changes: 1 addition & 1 deletion server/typescript/packages/codegen-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export { isTphSubtype, tphDiscriminatorPin } from "./templates/zod-validators.js
// package-internal relative path. These are the assembly pieces the built-in
// entity/queries composers use; the reference templates relocate that assembly.
export { renderTphDiscriminatorUnion } from "./templates/tph-discriminator.js";
export { hasWritableRdbSource } from "./source-detect.js";
export { hasWritableRdbSource, hasAnyRdbSource } from "./source-detect.js";
export { renderSharedEnumsFile, SHARED_ENUMS_BASENAME } from "./templates/enums-file.js";

// ADR-0034 scaffold-and-own — reader for the copyable reference generators in
Expand Down
20 changes: 14 additions & 6 deletions server/typescript/packages/codegen-ts/src/reference/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
//
// use-when: you want generated typed CRUD finders (find<E>ById, list<E>s, create/update/delete)
// over Drizzle. Drop it if you hand-write your data access.
// emits: <target>/<Entity>.queries.ts per write-through entity.
// emits: <target>/<Entity>.queries.ts per source-backed object (any source.rdb kind,
// incl. read-only projections) — skipped for sourceless objects (incl. every
// object.value, source-less by value purity) and TPH subtypes (#248 R2).
// customize: the vanilla CRUD assembly below is OWNED — reorder, drop verbs (e.g. no delete),
// change the Db type alias, add your own finders. The render<Verb>Fn primitives emit
// each block; call your own instead to change a verb's body.
Expand All @@ -15,7 +17,7 @@
// out of the package source. The vanilla path here is byte-identical to the built-in.

import { code, joinCode, type Code } from "ts-poet";
import { OBJECT_SUBTYPE_VALUE, type MetaObject } from "@metaobjectsdev/metadata";
import type { MetaObject } from "@metaobjectsdev/metadata";
import {
perEntity,
type Generator,
Expand All @@ -33,6 +35,7 @@ import {
isProjection,
isWriteThrough,
isTphSubtype,
hasAnyRdbSource,
renderQueriesFile, // engine composer — used for the delegated variants
formatTs,
entityOutputPath,
Expand Down Expand Up @@ -105,10 +108,15 @@ export interface QueriesFileOpts {
target?: string;
}

// value objects have no identity (findById/updateById would target a non-existent column),
// and TPH subtypes emit no standalone queries file — both are skipped unconditionally.
const skipNonQueryable = (e: MetaObject): boolean =>
e.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(e);
// #248 R2: persistability derives from declared/inherited source, never subtype.
// An object with no source.rdb (of ANY kind) isn't backed by any store — the
// rendered queries module would emit findById/updateById/deleteById against
// Drizzle table/schema exports the entity file never emits for it (value
// objects are subsumed here too: value purity bans sources on them, so no
// loadable value ever has hasAnyRdbSource === true). TPH subtypes emit no
// standalone queries file either — their per-subtype CRUD helpers live in the
// discriminator base's queries file (which targets the single shared table).
const skipNonQueryable = (e: MetaObject): boolean => hasAnyRdbSource(e) && !isTphSubtype(e);

export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
const userFilter = opts?.filter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
// if you need bespoke endpoints — or keep it and add handlers via <Entity>.extra.ts.
// emits: <target>/<Entity>.routes.ts — full CRUD for write-through entities, read-only
// (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
// Skipped for any sourceless object (incl. every object.value, source-less by
// value purity) and for TPH subtypes — no source.rdb means no table/allowlist
// for a routes file to import (#248 R2).
// customize: this generator (filter, output path, per-entity @emitRoutes opt-out, target) is
// YOURS — edit it freely. The route *composition* itself is richer than the others
// (M:N junction traversal, TPH per-subtype route sets), so it stays in the engine via
Expand All @@ -23,6 +26,7 @@ import {
type GeneratorFactory,
renderRoutesFile,
isTphSubtype,
hasAnyRdbSource,
formatTs,
entityOutputPath,
CODEGEN_ATTR_EMIT_ROUTES,
Expand All @@ -39,9 +43,12 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
name: "routes-file",
// per-entity opt-out via `@emitRoutes: false`; TPH subtypes get no standalone routes
// file (their routes live in the discriminator base's); AND-composed with your filter.
// #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
// backed by any store — routes against it would import Drizzle table/allowlist
// exports the entity file never emits. Gated by hasAnyRdbSource.
filter: (e: MetaObject) =>
// ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && !isTphSubtype(e) && userFilter(e),
e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && hasAnyRdbSource(e) && !isTphSubtype(e) && userFilter(e),
generate: perEntity(async (entity, ctx) => {
if (!ctx.renderContext) {
throw new Error("routes-file: renderContext is required (provided by runGen)");
Expand Down
20 changes: 20 additions & 0 deletions server/typescript/packages/codegen-ts/src/source-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,23 @@ export function hasWritableRdbSource(entity: MetaObject): boolean {
}
return false;
}

/**
* True when the object declares (or inherits via extends — ADR-0039 resolving)
* at least one source.rdb child of ANY kind (writable OR read-only). Zero
* sources means "not backed by any store" (loader contract,
* validate-source-roles: zero sources is allowed, means not persisted) — the
* DB-artifact tier (queries/routes/api-model) must emit nothing for it, exactly
* as the table tier already refuses to emit a Drizzle table for it
* (hasWritableRdbSource, above — this is its any-kind sibling).
*/
export function hasAnyRdbSource(entity: MetaObject): boolean {
// ADR-0039: resolving — same rationale as hasWritableRdbSource.
for (const child of entity.children()) {
if (child.type !== TYPE_SOURCE) continue;
if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
if (!(child instanceof MetaSource)) continue;
return true;
}
return false;
}
Loading
Loading