Skip to content

Commit 2aea5a2

Browse files
dmealingclaude
andcommitted
Merge origin/main (FR-017 TPH Tier-2) into sp-g-registry-conformance — SP-G Java reconciliation; clean (0 conflicts, TS-only advance)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 85b7f16 + 74db8e9 commit 2aea5a2

13 files changed

Lines changed: 858 additions & 20 deletions

File tree

docs/superpowers/specs/2026-06-02-fr-017-tph-polymorphic-codegen-design.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,31 @@ New TPH query scenarios:
372372

373373
## Realization status
374374

375-
- **Tier 1 (TS types-only):** unimplemented. Plan: ship in a single PR alongside this design.
376-
- **Tier 2 (TS working CRUD):** unimplemented. Plan: separate multi-day PR after Tier 1 lands and soaks.
377-
- **Tiers 3–5:** unimplemented. Pick up as adopter need + per-port fan-out pressure dictates.
375+
- **Tier 1 (TS types-only):** **shipped** (commit `7ac54a35`) — discriminated
376+
union + per-subtype type guards + `parse<Base>` dispatcher + per-subtype Zod
377+
discriminator pin.
378+
- **Tier 2 (TS working CRUD):** **shipped** across four slices:
379+
- #1 Drizzle single-table emission (union of subtype columns, subtype-only
380+
columns forced nullable, no DB default).
381+
- #2 per-subtype full read schema `<Sub>Schema` (fixes the Tier 1
382+
`parse<Base>` reference, which pointed at a never-emitted schema) + the
383+
polymorphic & per-subtype queries file (base `find/list` dispatch through
384+
`parse<Base>`; per-subtype list/findById/create/update/delete against the
385+
single table; subtypes get no standalone queries file).
386+
- #3 per-subtype REST routes: a runtime-ts `mountCrudRoutes`
387+
`discriminator: { column, value }` option (subtype-scoped reads,
388+
cross-subtype 404, create-injects / update-strips the discriminator) +
389+
`routesFile` emitting polymorphic list/get at the base path and a per-subtype
390+
CRUD set at `<base>/<discriminatorValue lowercased>`; subtypes get no
391+
standalone routes file.
392+
- **Deviation noted:** per-subtype create/update use
393+
`<Sub>InsertSchema` (`.omit({<disc>: true})` on the route boundary,
394+
`.partial()` for update) rather than separately-named `<Sub>Create` /
395+
`<Sub>Update` aliases. Functionally equivalent; a named-alias polish is
396+
deferred.
397+
- **Edge deferred:** an *abstract* discriminator base. The conformance
398+
fixtures use a concrete base (which owns the table); an abstract base
399+
short-circuits the entity-file value-object path before the Drizzle path, so
400+
its single TPH table is not yet emitted. Revisit when an adopter needs it.
401+
- **Tiers 3–5:** unimplemented. Pick up as adopter need + per-port fan-out
402+
pressure dictates.

server/typescript/packages/codegen-ts/src/generators/queries-file.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OBJECT_SUBTYPE_VALUE, type MetaObject } from "@metaobjectsdev/metadata";
22
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
33
import { renderQueriesFile } from "../templates/queries-file.js";
4+
import { isTphSubtype } from "../templates/zod-validators.js";
45
import { formatTs } from "../format.js";
56
import { entityOutputPath } from "../import-path.js";
67

@@ -13,13 +14,18 @@ export interface QueriesFileOpts {
1314
// emits findById/updateById/deleteById against a non-existent column. Skipping
1415
// value subtypes is unconditional — the user-supplied filter (if any) is applied
1516
// on top via boolean AND.
16-
const skipValueTypes = (e: MetaObject): boolean => e.subType !== OBJECT_SUBTYPE_VALUE;
17+
//
18+
// FR-017 Tier 2: TPH subtypes are ALSO skipped — they emit no standalone
19+
// queries file. Their per-subtype CRUD helpers live in the discriminator
20+
// base's queries file (which targets the single shared table).
21+
const skipNonQueryable = (e: MetaObject): boolean =>
22+
e.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(e);
1723

1824
export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
1925
const userFilter = opts?.filter;
2026
const filter: (e: MetaObject) => boolean = userFilter
21-
? (e) => skipValueTypes(e) && userFilter(e)
22-
: skipValueTypes;
27+
? (e) => skipNonQueryable(e) && userFilter(e)
28+
: skipNonQueryable;
2329

2430
const generator: Generator = {
2531
name: "queries-file",

server/typescript/packages/codegen-ts/src/generators/routes-file.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { MetaObject } from "@metaobjectsdev/metadata";
22
import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
33
import { renderRoutesFile } from "../templates/routes-file.js";
4+
import { isTphSubtype } from "../templates/zod-validators.js";
45
import { formatTs } from "../format.js";
56
import { entityOutputPath } from "../import-path.js";
67

@@ -12,13 +13,17 @@ export interface RoutesFileOpts {
1213
/**
1314
* Per-entity opt-out via `@emitRoutes: false` is honored. If the user supplies
1415
* their own filter, both must pass (AND).
16+
*
17+
* FR-017 Tier 2: TPH subtypes get no standalone routes file — their per-subtype
18+
* route set lives in the discriminator base's routes file.
1519
*/
1620
export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator {
1721
const userFilter = opts?.filter ?? (() => true);
1822
const generator: Generator = {
1923
name: "routes-file",
2024
// Always set: AND-composes metadata opt-out with optional user filter.
21-
filter: (e: MetaObject) => e.ownAttr("emitRoutes") !== false && userFilter(e),
25+
filter: (e: MetaObject) =>
26+
e.ownAttr("emitRoutes") !== false && !isTphSubtype(e) && userFilter(e),
2227
generate: perEntity(async (entity, ctx) => {
2328
if (!ctx.renderContext) {
2429
throw new Error("routes-file: renderContext is required (provided by runGen)");

server/typescript/packages/codegen-ts/src/templates/queries-file.ts

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
// Queries file composer — composes all CRUD function renderers (from queries.ts) into
22
// a complete <Entity>.queries.ts file with @generated header and correct imports.
33

4-
import { code, joinCode, type Code } from "ts-poet";
5-
import { MetaObject } from "@metaobjectsdev/metadata";
4+
import { code, joinCode, imp, type Code } from "ts-poet";
5+
import {
6+
MetaObject,
7+
OBJECT_ATTR_DISCRIMINATOR,
8+
OBJECT_ATTR_DISCRIMINATOR_VALUE,
9+
} from "@metaobjectsdev/metadata";
610
import { type RenderContext } from "../render-context.js";
711
import { entityModuleSpecifier } from "../import-path.js";
812
import {
@@ -11,11 +15,21 @@ import {
1115
renderCreateFn,
1216
renderUpdateFn,
1317
renderDeleteByIdFn,
18+
getPkInfo,
1419
} from "./queries.js";
15-
import { variableNameFromEntity } from "../naming.js";
20+
import { variableNameFromEntity, pluralize } from "../naming.js";
1621
import { GENERATED_HEADER } from "../constants.js";
22+
import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator.js";
1723

1824
export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
25+
// FR-017 Tier 2 — a TPH discriminator base gets a polymorphic queries file:
26+
// base reads dispatch through parse<Base>, and per-subtype CRUD targets the
27+
// single base table scoped to the discriminator value. (Subtype entities are
28+
// filtered out of the queries generator entirely — their CRUD lives here.)
29+
if (isTphDiscriminatorBase(obj, ctx.loadedRoot)) {
30+
return renderTphQueriesFile(obj, ctx);
31+
}
32+
1933
const entityName = obj.name;
2034
// Import the entity's own file. Same target → relative "./Entity"; cross
2135
// target → importBase-qualified package path.
@@ -68,3 +82,118 @@ import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON
6882
`// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
6983
return header + body;
7084
}
85+
86+
/**
87+
* FR-017 Tier 2 — the polymorphic + per-subtype queries file for a TPH base.
88+
*
89+
* Base reads (`find<Base>ById`, `list<BasePlural>`) project every row through
90+
* `parse<Base>` so they return the discriminated union. There is intentionally
91+
* NO `create<Base>` / `update<Base>` — you cannot instantiate an abstract base.
92+
*
93+
* Each concrete subtype gets list / findById (filtered to the discriminator
94+
* value, parsed with `<Sub>Schema`) plus create / updateById / deleteById, all
95+
* against the single base table. Creates inject the discriminator value;
96+
* updates strip it (a row's subtype is immutable).
97+
*/
98+
function renderTphQueriesFile(base: MetaObject, ctx: RenderContext): string {
99+
const baseName = base.name;
100+
const tableVar = variableNameFromEntity(baseName);
101+
const discField = base.ownAttr(OBJECT_ATTR_DISCRIMINATOR) as string;
102+
const { fieldName: pkField, tsType: pkType } = getPkInfo(base, ctx);
103+
104+
const baseFileSpec = entityModuleSpecifier(
105+
ctx.selfTarget, ctx.entityModuleTarget, base.package, baseName, ctx.extStyle,
106+
);
107+
const tableSym = imp(`${tableVar}@${baseFileSpec}`);
108+
const baseTypeSym = imp(`t:${baseName}@${baseFileSpec}`);
109+
const parseSym = imp(`parse${baseName}@${baseFileSpec}`);
110+
const eqSym = imp("eq@drizzle-orm");
111+
const andSym = imp("and@drizzle-orm");
112+
113+
const dbTypeImport =
114+
ctx.dialect === "postgres"
115+
? `import type { NodePgDatabase } from "drizzle-orm/node-postgres";`
116+
: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
117+
const dbTypeAlias =
118+
ctx.dialect === "postgres"
119+
? `type Db = NodePgDatabase<Record<string, never>>;`
120+
: `type Db = BaseSQLiteDatabase<"async", Record<string, never>>;`;
121+
122+
// --- Polymorphic base reads ---
123+
const polymorphic = code`
124+
export async function find${baseName}ById(db: Db, ${pkField}: ${pkType}): Promise<${baseTypeSym} | null> {
125+
const [row] = await db.select().from(${tableSym}).where(${eqSym}(${tableSym}.${pkField}, ${pkField})).limit(1);
126+
return row ? ${parseSym}(row) : null;
127+
}
128+
129+
export async function list${pluralize(baseName)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${baseTypeSym}[]> {
130+
let q = db.select().from(${tableSym}).$dynamic();
131+
if (opts?.limit !== undefined) q = q.limit(opts.limit);
132+
if (opts?.offset !== undefined) q = q.offset(opts.offset);
133+
const rows = await q;
134+
return rows.map((r) => ${parseSym}(r));
135+
}
136+
`;
137+
138+
// --- Per-subtype CRUD against the single base table ---
139+
const subtypeSections: Code[] = [];
140+
for (const sub of tphConcreteSubtypes(base, ctx.loadedRoot)) {
141+
const value = sub.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE) as string;
142+
const valueLit = JSON.stringify(value);
143+
const subFileSpec = entityModuleSpecifier(
144+
ctx.selfTarget, ctx.entityModuleTarget, sub.package, sub.name, ctx.extStyle,
145+
);
146+
const subTypeSym = imp(`t:${sub.name}@${subFileSpec}`);
147+
const subSchemaSym = imp(`${sub.name}Schema@${subFileSpec}`);
148+
const subInsertSym = imp(`${sub.name}InsertSchema@${subFileSpec}`);
149+
150+
subtypeSections.push(code`
151+
export async function list${pluralize(sub.name)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${subTypeSym}[]> {
152+
let q = db.select().from(${tableSym}).where(${eqSym}(${tableSym}.${discField}, ${valueLit})).$dynamic();
153+
if (opts?.limit !== undefined) q = q.limit(opts.limit);
154+
if (opts?.offset !== undefined) q = q.offset(opts.offset);
155+
const rows = await q;
156+
return rows.map((r) => ${subSchemaSym}.parse(r));
157+
}
158+
159+
export async function find${sub.name}ById(db: Db, ${pkField}: ${pkType}): Promise<${subTypeSym} | null> {
160+
const [row] = await db.select().from(${tableSym})
161+
.where(${andSym}(${eqSym}(${tableSym}.${pkField}, ${pkField}), ${eqSym}(${tableSym}.${discField}, ${valueLit}))).limit(1);
162+
return row ? ${subSchemaSym}.parse(row) : null;
163+
}
164+
165+
export async function create${sub.name}(db: Db, data: unknown): Promise<${subTypeSym}> {
166+
const validated = ${subInsertSym}.parse(data);
167+
const [row] = await db.insert(${tableSym}).values({ ...validated, ${discField}: ${valueLit} }).returning();
168+
return ${subSchemaSym}.parse(row!);
169+
}
170+
171+
export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: unknown): Promise<${subTypeSym} | null> {
172+
const validated = ${subInsertSym}.partial().parse(data) as Record<string, unknown>;
173+
// The discriminator is immutable — a ${sub.name} can never become another subtype.
174+
const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated;
175+
const [row] = await db.update(${tableSym}).set(safe)
176+
.where(${andSym}(${eqSym}(${tableSym}.${pkField}, ${pkField}), ${eqSym}(${tableSym}.${discField}, ${valueLit}))).returning();
177+
return row ? ${subSchemaSym}.parse(row) : null;
178+
}
179+
180+
export async function delete${sub.name}ById(db: Db, ${pkField}: ${pkType}): Promise<boolean> {
181+
const deleted = await db.delete(${tableSym})
182+
.where(${andSym}(${eqSym}(${tableSym}.${pkField}, ${pkField}), ${eqSym}(${tableSym}.${discField}, ${valueLit}))).returning();
183+
return deleted.length > 0;
184+
}
185+
`);
186+
}
187+
188+
const literalImports = code`
189+
${dbTypeImport}
190+
${dbTypeAlias}
191+
`;
192+
193+
const body = joinCode([literalImports, polymorphic, ...subtypeSections], { on: "\n" }).toString();
194+
const header =
195+
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
196+
`// Source metadata: ${baseName} (${base.fqn()}) — TPH discriminator base\n` +
197+
`// Customize via ${baseName}.extra.ts in this directory (additional queries, custom logic).\n`;
198+
return header + body;
199+
}

server/typescript/packages/codegen-ts/src/templates/queries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { RenderContext } from "../render-context.js";
88
import { variableNameFromEntity, pluralize } from "../naming.js";
99

1010
/** Get the PK field name and its TS type for a given entity. */
11-
function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: string; tsType: string } {
11+
export function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: string; tsType: string } {
1212
// Use primaryIdentity() to find the primary identity (may be inherited from extends:/super:).
1313
const primary = entity.primaryIdentity();
1414
const rawFields = primary?.ownAttr(IDENTITY_ATTR_FIELDS);

0 commit comments

Comments
 (0)