Skip to content

feat(codegen-ts): add copyable reference template library#86

Merged
dmealing merged 2 commits into
mainfrom
feat/codegen-reference-templates
Jun 28, 2026
Merged

feat(codegen-ts): add copyable reference template library#86
dmealing merged 2 commits into
mainfrom
feat/codegen-reference-templates

Conversation

@dmealing

Copy link
Copy Markdown
Member

Intent

ADR-0034 Phase-1 step-1: add a reference template library to @metaobjectsdev/codegen-ts (the shadcn-for-codegen pivot). Adds src/reference/{entity,queries,routes,barrel}.ts — self-contained COPYABLE reference generators a consumer copies into their repo and owns, importing only the public engine (@metaobjectsdev/codegen-ts) + ts-poet + @metaobjectsdev/metadata, each with a use-when/emits/customize/composes-with header. entity relocates the full renderEntityFile composition; queries owns the vanilla-CRUD composition + delegates TPH/projection variants to the public renderQueriesFile; barrel relocates renderBarrel; routes deliberately STAYS generator-level (its composition pulls in internal M:N junction logic renderM2mMounts that we intentionally do NOT promote — the header documents the deeper-ownership path). Promotes only re-exports of already-exported assembly helpers (renderTphDiscriminatorUnion, hasWritableRdbSource, renderSharedEnumsFile, SHARED_ENUMS_BASENAME, the queries CRUD-block renderers). PURELY ADDITIVE — no existing generator or export removed; meta init scaffolding + deprecation + guidance rewrite are later steps. reference/ is excluded from the tsc build (scaffold assets). Verified locally: byte-identical built-in-vs-reference over 5 fixtures (68 assertions), full codegen-ts suite 831 pass 0 fail, tsc --noEmit strict of reference/* against the public API clean. Run tests LOCALLY in this worktree; GitHub Actions CI skipped (CI is local, only the PR on GitHub).

What Changed

  • Added src/reference/{entity,queries,routes,barrel}.ts to @metaobjectsdev/codegen-ts — self-contained, copyable reference generators (ADR-0034 scaffold-and-own, step 1) that a consumer copies into their repo and owns, importing only the public engine plus ts-poet and @metaobjectsdev/metadata. entity relocates the full renderEntityFile composition, queries owns vanilla-CRUD and delegates TPH/projection variants to the public renderQueriesFile, barrel relocates renderBarrel, and routes stays generator-level (its internal M:N junction logic is intentionally not promoted, documented in the header).
  • Promoted re-exports in src/index.ts (already-exported assembly helpers: renderTphDiscriminatorUnion, hasWritableRdbSource, renderSharedEnumsFile, SHARED_ENUMS_BASENAME, the queries CRUD-block renderers) — purely additive, no existing generator or export removed; reference/ is excluded from the tsc build as scaffold assets.
  • Added test/reference-byte-identical.test.ts asserting the reference generators produce byte-identical output to the built-in generators across 5 fixtures (68 assertions), and documented the library in CHANGELOG.md.

Risk Assessment

✅ Low: Purely additive scaffold assets: no existing generator or export is removed, the relocated compositions are byte-identical to the built-ins they mirror, and a dedicated test asserts that equivalence across five fixtures.

Testing

Exercised the scaffold-and-own guarantee end-to-end: the existing byte-identical conformance test (5 fixtures, 68 assertions) passes, the full codegen-ts suite is 831 pass/0 fail, and a fresh end-to-end run drove the copyable reference generators through the public runGen() on a real fixture — all 7 generated files (Drizzle table + Zod + queries + Fastify routes + barrel) came out byte-identical to the built-in generators, and I persisted the actual generated code as reviewer-visible artifacts. I also confirmed the templates strict-typecheck against the built public dist (the surface a consumer imports) with zero diagnostics, the package tsc build passes with reference/ excluded, and the index.ts change is re-exports only (purely additive). This is a library/codegen change with no UI surface, so artifacts are the generated code and CLI transcripts rather than screenshots. No failures or setup issues.

Evidence: Reference-vs-built-in end-to-end transcript (7 files byte-identical)

fixture: two-entities-fk.json reference generators emitted 7 files: Post.queries.ts, Post.routes.ts, Post.ts, User.queries.ts, User.routes.ts, User.ts, index.ts ✓ byte-identical Post.queries.ts (1760 bytes) ✓ byte-identical Post.routes.ts (1105 bytes) ✓ byte-identical Post.ts (2610 bytes) ✓ byte-identical User.queries.ts (1760 bytes) ✓ byte-identical User.routes.ts (1105 bytes) ✓ byte-identical User.ts (2200 bytes) ✓ byte-identical index.ts (107 bytes) ALL FILES BYTE-IDENTICAL (reference == built-in): true

fixture: two-entities-fk.json
reference generators emitted 7 files: Post.queries.ts, Post.routes.ts, Post.ts, User.queries.ts, User.routes.ts, User.ts, index.ts

  ✓ byte-identical  Post.queries.ts  (1760 bytes)
  ✓ byte-identical  Post.routes.ts  (1105 bytes)
  ✓ byte-identical  Post.ts  (2610 bytes)
  ✓ byte-identical  User.queries.ts  (1760 bytes)
  ✓ byte-identical  User.routes.ts  (1105 bytes)
  ✓ byte-identical  User.ts  (2200 bytes)
  ✓ byte-identical  index.ts  (107 bytes)

ALL FILES BYTE-IDENTICAL (reference == built-in): true
Evidence: Real generated User.ts produced by the copyable reference/entity.ts template
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
// Source metadata: User (User)
// Customize via User.extra.ts in this directory.
import { InferInsertModel, InferSelectModel, relations } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { z } from "zod";
import { posts } from "./Post";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  email: text("email").notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));
export type User = InferSelectModel<typeof users>;
export type UserInsert = InferInsertModel<typeof users>;
export type UserUpdate = Partial<UserInsert>;
export const UserInsertSchema = z.object({ email: z.string().min(1) });

export const UserUpdateSchema = z.object({
  email: z.string().min(1).optional(),
});
/**
 * Metadata constants for User.
 *
 * Use these instead of magic strings so TS catches typos and refactors stay
 * coherent. Each non-dollar-prefixed key is a per-field object carrying
 * name, label, view, optional htmlType/placeholder/helpText, and the
 * RHF-shaped validation rules derived from the field's validator children.
 *
 * Typical usage with the metaobjects React form helper:
 *
 *   import { useEntityForm } from '@metaobjectsdev/react';
 *   const form = useEntityForm(User, UserInsertSchema);
 *   <input {...form.input.id} />
 */
export const User = {
  $entity: "User",
  $table: "users",
  $path: "/users",
  $apiPrefix: "",
  id: { name: "id", label: "Id", view: "number", htmlType: "number" },
  email: {
    name: "email",
    label: "Email",
    view: "text",
    htmlType: "text",
    rules: { required: "Email is required" },
  },
} as const;
import type { FilterAllowlist } from "@metaobjectsdev/runtime-ts/drizzle-fastify";

export const UserFilterAllowlist = {} as const satisfies FilterAllowlist;
import type { SortAllowlist } from "@metaobjectsdev/runtime-ts/drizzle-fastify";

export const UserSortAllowlist = {} as const satisfies SortAllowlist;
export type UserFilter = {
  limit?: number;
  offset?: number;
  sort?: string;
  or?: UserFilter[];
  and?: UserFilter[];
};
Evidence: Generated User.queries.ts (reference queries template)
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
// Source metadata: User (User)
// Customize via User.extra.ts in this directory (additional queries, custom logic).
import { eq } from "drizzle-orm";

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

import { type User, UserInsertSchema, users } from "./User";
export async function findUserById(db: Db, id: number): Promise<User | null> {
  const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
  return user ?? null;
}
export async function listUsers(
  db: Db,
  opts?: { limit?: number; offset?: number },
): Promise<User[]> {
  let q = db.select().from(users).$dynamic();
  if (opts?.limit !== undefined) {
    q = q.limit(opts.limit);
  }
  if (opts?.offset !== undefined) {
    q = q.offset(opts.offset);
  }
  return q;
}
export async function createUser(db: Db, data: unknown): Promise<User> {
  const validated = UserInsertSchema.parse(data);
  const [user] = await db.insert(users).values(validated).returning();
  return user!;
}
export async function updateUser(
  db: Db,
  id: number,
  data: unknown,
): Promise<User | null> {
  const validated = UserInsertSchema.partial().parse(data);
  const [user] = await db
    .update(users)
    .set(validated)
    .where(eq(users.id, id))
    .returning();
  return user ?? null;
}
export async function deleteUserById(db: Db, id: number): Promise<boolean> {
  // Use .returning() unconditionally — supported on SQLite ≥3.35 (covers D1, libsql/Turso)
  // and Postgres. Result is an array of deleted rows; presence implies success.
  const deleted = await db.delete(users).where(eq(users.id, id)).returning();
  return deleted.length > 0;
}
Evidence: Generated User.routes.ts (built-in routes via reference composition)
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
// Source metadata: User (User)
// Customize via User.extra.ts in this directory (e.g., auth, additional handlers).
import { db } from "~/server/db";
import {
  User,
  UserFilterAllowlist,
  UserInsertSchema,
  users,
  UserSortAllowlist,
  UserUpdateSchema,
} from "./User";
import { mountCrudRoutes } from "@metaobjectsdev/runtime-ts/drizzle-fastify";
import type { FastifyInstance } from "fastify";

/**
 * Mount the 5 standard REST endpoints for User using Drizzle directly.
 *
 * Customize: register this as-is for stock CRUD, OR import the per-verb
 * helpers (mountListRoute, mountGetRoute, ...) from
 * @metaobjectsdev/runtime-ts/drizzle-fastify and mix with your own handlers
 * (auth, side effects, etc.).
 */
export async function userRoutes(fastify: FastifyInstance) {
  mountCrudRoutes({
    fastify,
    path: User.$path,
    db,
    table: users,
    insertSchema: UserInsertSchema,
    updateSchema: UserUpdateSchema,
    filterAllowlist: UserFilterAllowlist,
    sortAllowlist: UserSortAllowlist,
    dialect: "sqlite",
  });
}
Evidence: Strict tsc --noEmit of reference/* against public API dist (clean)

$ tsc --noEmit --strict over src/reference/*.ts, with @metaobjectsdev/codegen-ts mapped to ./dist (the public API a consumer imports) (tsc produced NO diagnostics) EXIT=0 — reference templates are type-clean against the published public engine surface

$ tsc --noEmit --strict over src/reference/*.ts, with @metaobjectsdev/codegen-ts mapped to ./dist (the public API a consumer imports)
(tsc produced NO diagnostics)
EXIT=0  — reference templates are type-clean against the published public engine surface

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ server/typescript/packages/codegen-ts/tsconfig.json:8 - src/reference/* is excluded from the package tsc build (tsconfig exclude). The byte-identical runtime test exercises the reference generators and would catch behavioral drift, but a future public-API change that breaks the reference files only at the type level (e.g. a renamed/removed type that leaves the runtime symbol intact) would not fail any automated gate going forward — the tsc --noEmit check against the public API was a one-time manual verification. Consider wiring a CI typecheck of reference/* against the built public API to institutionalize that guard.
✅ **Test** - passed

✅ No issues found.

  • bun test test/reference-byte-identical.test.ts — 5 fixtures / 68 assertions, reference vs built-in byte-identical (pass)
  • bun test (full codegen-ts package suite) — 831 pass / 0 fail
  • Built workspace deps then bun run build (tsc -p .) for codegen-ts — passes with src/reference excluded as scaffold assets
  • Strict tsc --noEmit over src/reference/*.ts with @metaobjectsdev/codegen-ts mapped to ./dist (public-API view) — no diagnostics, exit 0
  • End-to-end: ran the reference generators via public runGen() on the two-entities-fk fixture; all 7 emitted files byte-identical to built-in output; persisted real generated entity/queries/routes artifacts
  • git diff of src/index.ts confirms re-exports added only (additive); git status clean after test cleanup
⚠️ **Document** - 1 info
  • ℹ️ server/typescript/packages/codegen-ts/README.md - The consumer-facing codegen-ts README and the metaobjects-codegen guidance/skill still teach importing built-in generators from @metaobjectsdev/codegen-ts/generators and do not yet describe the scaffold-and-own copy-a-reference-template path. This is intentionally deferred to later ADR-0034 steps (meta init scaffolding, generator-export deprecation, guidance rewrite), not an oversight from this additive step-1 change — flagged only so a reviewer knows it was a deliberate no-op, not a missed gap.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dmealing and others added 2 commits June 28, 2026 07:41
…wn, step 1)

Adds src/reference/{entity,queries,routes,barrel}.ts — self-contained COPYABLE
reference generators a consumer copies into their repo and owns, importing only the
public engine (@metaobjectsdev/codegen-ts) + ts-poet + @metaobjectsdev/metadata. Each
carries a use-when / emits / customize / composes-with header. entity relocates the
full renderEntityFile composition (full ownership); queries owns the vanilla-CRUD
composition and delegates TPH/projection variants; barrel relocates renderBarrel;
routes stays generator-level (its vanilla composition pulls in internal M:N junction
logic — renderM2mMounts — that we deliberately do NOT promote; the header documents
how to copy renderRoutesFile's body for deeper ownership).

Promotes the composition's assembly helpers to public (re-exports only — already
exported from their modules): renderTphDiscriminatorUnion, hasWritableRdbSource,
renderSharedEnumsFile, SHARED_ENUMS_BASENAME, and the queries CRUD-block renderers
(renderFindByIdFn/List/Create/Update/DeleteById + getPkInfo).

Purely additive — no existing generator or export removed (deprecation + the meta init
scaffolding + guidance rewrite are later steps). reference/ excluded from the tsc build
(scaffold assets). Verified: byte-identical built-in-vs-reference over 5 fixtures (68
assertions), full codegen-ts suite 831 pass, tsc --noEmit strict of reference/* against
the public API clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
@dmealing
dmealing merged commit 4118986 into main Jun 28, 2026
17 of 19 checks passed
@dmealing
dmealing deleted the feat/codegen-reference-templates branch June 28, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant