Skip to content

Commit 29d52fd

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): typed <Entity>Patch surface for the update path (FR-035, #198)
The generated `update<Entity>` took `data: unknown` and validated with `InsertSchema.partial()`. It worked, but gave adopters zero compile-time safety — a renamed or dropped field in an update call was silently accepted and silently not written. #198's hand-written single-column setters exist partly to regain that safety. Now: - zod-validators emits `export type <Entity>Patch = z.input<typeof <Entity>UpdateSchema>` — every settable field, optional. - renderUpdateFn takes `patch: <Entity>Patch` (was `data: unknown`) and validates with the purpose-built `<Entity>UpdateSchema` — the SAME schema the routes tier already uses — instead of `InsertSchema.partial()`. A renamed/dropped field is now a TS compile error at every `update<Entity>` call site (the #198 compile-safety property); `.set()` still writes only the assigned columns, so an omitted field is untouched (PATCH-1). - empty-patch guard (PATCH-5): an empty patch returns the current row rather than letting Drizzle throw on an empty SET clause. The AGENT-API.md generator's documented `update` signature tracks the new `patch: <Entity>Patch` shape (the api-docs-accuracy gate compiles the doc's worked example against the real generated code). The consumer-compile test's `zod` stub gains the `z.input`/`infer`/`output` type-namespace helpers — the generated code compiles cleanly against real zod (verified), the stub just hadn't modeled that helper. Naming: the function keeps its shipped name `update<Entity>` — the cross-port contract is the PATCH semantics, not the identifier; renaming would churn every adopter for zero behavior change (ports that ship a partial method alongside a full-row one name it `patch`). All 44 query goldens regenerated (verified: only the Patch import/param/type + the empty-patch guard changed). codegen-ts typecheck clean; api-docs accuracy 27/27; consumer-compile 9/9; TS api-contract both lanes 44/44 (incl. the omitted-field-survives forcing function from 7fab436). Part of FR-035. C# (7fab436) and Kotlin (ad632ac) already ship their patch surface; Python + Java typed-patch shapes follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd5NWZ1rKau63vJzrBSpLb
1 parent ad632ac commit 29d52fd

49 files changed

Lines changed: 408 additions & 70 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

server/typescript/packages/codegen-ts/src/generators/api-model.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -506,11 +506,11 @@ function dataAccessSymbols(
506506
name: update,
507507
kind: "data-access",
508508
importPath: mod,
509-
signature: `${update}(db: Db, ${pk}: ${pkType}, data: unknown): Promise<${name} | null>`,
510-
params: [`db: Db`, `${pk}: ${pkType}`, `data: unknown`],
509+
signature: `${update}(db: Db, ${pk}: ${pkType}, patch: ${name}Patch): Promise<${name} | null>`,
510+
params: [`db: Db`, `${pk}: ${pkType}`, `patch: ${name}Patch`],
511511
returns: `Promise<${name} | null>`,
512-
throws: `ZodError when data fails the partial ${name}InsertSchema validation.`,
513-
usage: `Partially update an existing ${name} by primary key; null when not found.`,
512+
throws: `ZodError when the patch fails ${name}UpdateSchema validation.`,
513+
usage: `Partially update a ${name} by primary key — writes only the assigned fields; null when not found. A renamed/dropped field is a compile error.`,
514514
fields: updateShape,
515515
},
516516
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
8080
${dbTypeImport}
8181
${dbTypeAlias}
8282
83-
import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
83+
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
8484
`;
8585

8686
const sections: Code[] = [

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,23 @@ export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
9191
const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
9292
const { fieldName: pkField, tsType: pkType } = getPkInfo(entity, ctx);
9393
const fnName = updateFnName(entityName);
94-
const schemaName = `${entityName}InsertSchema`;
94+
const findByIdFn = findByIdFnName(entityName);
95+
// PATCH contract (FR-035): validate the caller's assignments against the
96+
// UPDATE schema (all-optional, PK/@readOnly excluded, no insert-time transforms
97+
// like @autoSet-onCreate → now() or the InsertSchema's discriminator handling)
98+
// — NOT `InsertSchema.partial()`. The typed `<Entity>Patch` param makes a
99+
// renamed/dropped field a compile error at every call site (PATCH-1..4);
100+
// `.set()` writes ONLY the assigned columns, so an omitted field is untouched.
101+
const updateSchemaName = `${entityName}UpdateSchema`;
102+
const patchType = `${entityName}Patch`;
95103
const eqSym = imp("eq@drizzle-orm");
96104

97105
return code`
98-
export async function ${fnName}(db: Db, ${pkField}: ${pkType}, data: unknown): Promise<${entityName} | null> {
99-
const validated = ${schemaName}.partial().parse(data);
106+
export async function ${fnName}(db: Db, ${pkField}: ${pkType}, patch: ${patchType}): Promise<${entityName} | null> {
107+
const validated = ${updateSchemaName}.parse(patch);
108+
// PATCH-5: an empty patch is a no-op — return the current row rather than let
109+
// Drizzle throw on an empty SET clause.
110+
if (Object.keys(validated).length === 0) return ${findByIdFn}(db, ${pkField});
100111
const [${singularVar}] = await db.update(${varName}).set(validated).where(${eqSym}(${varName}.${pkField}, ${pkField})).returning();
101112
return ${singularVar} ?? null;
102113
}

server/typescript/packages/codegen-ts/src/templates/zod-validators.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ ${joinCode(insertFieldLines, { on: ",\n" })}
325325
${docsPrefix}export const ${updateSchemaName} = ${z}.object({
326326
${joinCode(updateFieldLines, { on: ",\n" })}
327327
});
328+
329+
/** Typed patch shape for ${obj.name}: every settable field, optional (FR-035 PATCH). A
330+
* renamed/dropped field is a compile error at every \`update${obj.name}\` call site. */
331+
export type ${obj.name}Patch = ${z}.input<typeof ${updateSchemaName}>;
328332
`;
329333
}
330334

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/Tag.queries.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { eq, inArray } from "drizzle-orm";
66
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
77
type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;
88

9-
import { type Tag, TagInsertSchema, tags } from "./Tag";
9+
import {
10+
type Tag,
11+
TagInsertSchema,
12+
type TagPatch,
13+
tags,
14+
TagUpdateSchema,
15+
} from "./Tag";
1016
export async function findTagById(db: Db, id: number): Promise<Tag | null> {
1117
const [tag] = await db.select().from(tags).where(eq(tags.id, id)).limit(1);
1218
return tag ?? null;
@@ -32,9 +38,14 @@ export async function createTag(db: Db, data: unknown): Promise<Tag> {
3238
export async function updateTag(
3339
db: Db,
3440
id: number,
35-
data: unknown,
41+
patch: TagPatch,
3642
): Promise<Tag | null> {
37-
const validated = TagInsertSchema.partial().parse(data);
43+
const validated = TagUpdateSchema.parse(patch);
44+
// PATCH-5: an empty patch is a no-op — return the current row rather than let
45+
// Drizzle throw on an empty SET clause.
46+
if (Object.keys(validated).length === 0) {
47+
return findTagById(db, id);
48+
}
3849
const [tag] = await db
3950
.update(tags)
4051
.set(validated)

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/Tag.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export const TagUpdateSchema = z.object({
4040
label: z.string().min(1).max(100).optional(),
4141
customerId: z.number().int().optional(),
4242
});
43+
44+
/** Typed patch shape for Tag: every settable field, optional (FR-035 PATCH). A
45+
* renamed/dropped field is a compile error at every `updateTag` call site. */
46+
export type TagPatch = z.input<typeof TagUpdateSchema>;
4347
/**
4448
* Metadata constants for Tag.
4549
*

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/commerce/Order.queries.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { eq, inArray } from "drizzle-orm";
66
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
77
type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;
88

9-
import { type Order, OrderInsertSchema, orders } from "./Order";
9+
import {
10+
type Order,
11+
OrderInsertSchema,
12+
type OrderPatch,
13+
orders,
14+
OrderUpdateSchema,
15+
} from "./Order";
1016
export async function findOrderById(db: Db, id: number): Promise<Order | null> {
1117
const [order] = await db
1218
.select()
@@ -36,9 +42,14 @@ export async function createOrder(db: Db, data: unknown): Promise<Order> {
3642
export async function updateOrder(
3743
db: Db,
3844
id: number,
39-
data: unknown,
45+
patch: OrderPatch,
4046
): Promise<Order | null> {
41-
const validated = OrderInsertSchema.partial().parse(data);
47+
const validated = OrderUpdateSchema.parse(patch);
48+
// PATCH-5: an empty patch is a no-op — return the current row rather than let
49+
// Drizzle throw on an empty SET clause.
50+
if (Object.keys(validated).length === 0) {
51+
return findOrderById(db, id);
52+
}
4253
const [order] = await db
4354
.update(orders)
4455
.set(validated)

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/commerce/Order.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const OrderUpdateSchema = z.object({
4949
productId: z.number().int().optional(),
5050
quantity: z.number().int().optional(),
5151
});
52+
53+
/** Typed patch shape for Order: every settable field, optional (FR-035 PATCH). A
54+
* renamed/dropped field is a compile error at every `updateOrder` call site. */
55+
export type OrderPatch = z.input<typeof OrderUpdateSchema>;
5256
/**
5357
* Metadata constants for Order.
5458
*

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/commerce/Product.queries.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { eq, inArray } from "drizzle-orm";
66
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
77
type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;
88

9-
import { type Product, ProductInsertSchema, products } from "./Product";
9+
import {
10+
type Product,
11+
ProductInsertSchema,
12+
type ProductPatch,
13+
products,
14+
ProductUpdateSchema,
15+
} from "./Product";
1016
export async function findProductById(
1117
db: Db,
1218
id: number,
@@ -39,9 +45,14 @@ export async function createProduct(db: Db, data: unknown): Promise<Product> {
3945
export async function updateProduct(
4046
db: Db,
4147
id: number,
42-
data: unknown,
48+
patch: ProductPatch,
4349
): Promise<Product | null> {
44-
const validated = ProductInsertSchema.partial().parse(data);
50+
const validated = ProductUpdateSchema.parse(patch);
51+
// PATCH-5: an empty patch is a no-op — return the current row rather than let
52+
// Drizzle throw on an empty SET clause.
53+
if (Object.keys(validated).length === 0) {
54+
return findProductById(db, id);
55+
}
4556
const [product] = await db
4657
.update(products)
4758
.set(validated)

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/commerce/Product.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export const ProductUpdateSchema = z.object({
3737
name: z.string().min(1).max(200).optional(),
3838
tagId: z.number().int().optional(),
3939
});
40+
41+
/** Typed patch shape for Product: every settable field, optional (FR-035 PATCH). A
42+
* renamed/dropped field is a compile error at every `updateProduct` call site. */
43+
export type ProductPatch = z.input<typeof ProductUpdateSchema>;
4044
/**
4145
* Metadata constants for Product.
4246
*

0 commit comments

Comments
 (0)