Skip to content

Commit 6d46128

Browse files
dmealingclaude
andcommitted
feat(#203): TS insertPreserving escape hatch (@autoset verbatim insert)
Closes the one TS gap in #203's cross-port @autoset stamping: the insertPreserving escape hatch (Python/C#/Java/Kotlin already emit it). For an entity that declares @autoset fields, codegen-ts now emits an additional `<Entity>InsertPreservingSchema` (the @autoset timestamp columns validated VERBATIM — no create-time now() transform) plus an `insertPreserving<Entity>(db, data)` query that persists the caller's original timestamps unchanged. The import / restore / replication path; the normal `create<Entity>` still stamps now() (created == updated). Both the schema and the function are emitted ONLY for @autoset entities — a plain entity is byte-identical to before (no fixture declares @autoset, so no golden changes). The emit predicate (hasAutoSetFields) is shared across the schema emission, the per-field preserving line, and the queries-file import+fn, so a queries file can never import a preserving schema that wasn't emitted. The preserving schema's @autoset columns use the field's natural zod expr, so a @required onCreate column is required (you supply the value you're preserving) and a non-required onUpdate column is optional (Drizzle's $defaultFn fills now() if omitted). Verified: codegen-ts 946 pass / 0 fail (incl. new auto-set-insert-preserving test, 8 cases); typecheck exit 0; rendered Event output eyeballed. Known follow-up for the coordinated gate: a TPH base/subtype with @autoset takes a non-vanilla queries path, so its preserving schema would be a dead export — folded into the TPH-parity reconciliation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent d72052d commit 6d46128

6 files changed

Lines changed: 222 additions & 14 deletions

File tree

server/typescript/packages/codegen-ts/src/naming.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ export function createFnName(entityName: string): string {
111111
return `create${entityName}`;
112112
}
113113

114+
/** Generated insert-preserving helper name: `insertPreserving<Entity>` (#203 —
115+
* the import/restore/replication escape hatch that writes @autoSet columns
116+
* verbatim). Emitted only for entities that declare @autoSet fields. */
117+
export function insertPreservingFnName(entityName: string): string {
118+
return `insertPreserving${entityName}`;
119+
}
120+
114121
/** Generated update helper name: `update<Entity>`. */
115122
export function updateFnName(entityName: string): string {
116123
return `update${entityName}`;

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
renderFindByIdFn,
1414
renderListFn,
1515
renderCreateFn,
16+
renderInsertPreservingFn,
1617
renderUpdateFn,
1718
renderDeleteByIdFn,
1819
renderReverseFinderFns,
@@ -23,6 +24,7 @@ import { pluralize, findByIdFnName, listFnName } from "../naming.js";
2324
import { GENERATED_HEADER } from "../constants.js";
2425
import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator.js";
2526
import { isProjection } from "../projection/projection-detector.js";
27+
import { hasAutoSetFields } from "./zod-validators.js";
2628

2729
export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
2830
// FR-017 Tier 2 — a TPH discriminator base gets a polymorphic queries file:
@@ -74,20 +76,26 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
7476
// (the most common SQLite driver) with "is not assignable".
7577
: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
7678

79+
// #203 — an @autoSet entity additionally imports its preserving-shape schema
80+
// and emits the `insertPreserving<Entity>` escape hatch after `create<Entity>`.
81+
const autoSet = hasAutoSetFields(obj);
82+
const preservingImport = autoSet ? `, ${entityName}InsertPreservingSchema` : "";
83+
7784
// Literal imports (Db type + entity types) live in a code block so they sort
7885
// alongside ts-poet's hoisted imp() imports at the top of the body.
7986
const literalImports = code`
8087
${dbTypeImport}
8188
${dbTypeAlias}
8289
83-
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
90+
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema${preservingImport}, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
8491
`;
8592

8693
const sections: Code[] = [
8794
literalImports,
8895
renderFindByIdFn(obj, ctx),
8996
renderListFn(obj, ctx),
9097
renderCreateFn(obj, ctx),
98+
...(autoSet ? [renderInsertPreservingFn(obj, ctx)] : []),
9199
renderUpdateFn(obj, ctx),
92100
renderDeleteByIdFn(obj, ctx),
93101
];

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
findByIdFnName,
1010
listFnName,
1111
createFnName,
12+
insertPreservingFnName,
1213
updateFnName,
1314
deleteByIdFnName,
1415
reverseFinderFnName,
@@ -85,6 +86,31 @@ export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
8586
`;
8687
}
8788

89+
/**
90+
* #203 — the `insertPreserving<Entity>` escape hatch. Cross-port with the Java /
91+
* Kotlin / C# / Python ports: emitted ONLY for an entity that declares @autoSet
92+
* fields, it persists the row WITHOUT the create-time now() stamp — the @autoSet
93+
* columns are written verbatim from the caller's data — for import / restore /
94+
* replication paths that must keep the original timestamps. It validates through
95+
* `<Entity>InsertPreservingSchema` (the preserving-shape schema whose @autoSet
96+
* columns carry no transform); the normal `create<Entity>` always stamps now().
97+
*/
98+
export function renderInsertPreservingFn(entity: MetaObject, ctx: RenderContext): Code {
99+
const varName = ctx.collectionName(entity.name);
100+
const entityName = entity.name;
101+
const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
102+
const fnName = insertPreservingFnName(entityName);
103+
const schemaName = `${entityName}InsertPreservingSchema`;
104+
105+
return code`
106+
export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
107+
const validated = ${schemaName}.parse(data);
108+
const [${singularVar}] = await db.insert(${varName}).values(validated).returning();
109+
return ${singularVar}!;
110+
}
111+
`;
112+
}
113+
88114
export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
89115
const varName = ctx.collectionName(entity.name);
90116
const entityName = entity.name;

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

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ export function isTphSubtype(obj: MetaObject): boolean {
7373
return tphDiscriminatorPin(obj) !== undefined;
7474
}
7575

76+
/** True when this object declares at least one @autoSet timestamp field
77+
* (onCreate or onUpdate). Drives whether codegen emits the #203
78+
* `<Entity>InsertPreservingSchema` + `insertPreserving<Entity>` escape hatch —
79+
* a plain entity has nothing to preserve, so both are omitted. Resolving
80+
* (ADR-0039): honors an @autoSet inherited via extends. */
81+
export function hasAutoSetFields(obj: MetaObject): boolean {
82+
for (const child of obj.fields()) {
83+
const autoSet = child.attr(FIELD_ATTR_AUTO_SET);
84+
if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) return true;
85+
}
86+
return false;
87+
}
88+
7689
/**
7790
* FR-017 Tier 2 — the per-subtype FULL read schema `<Sub>Schema`. Unlike the
7891
* insert schema, this includes every effective field (PK included) so a raw DB
@@ -282,6 +295,11 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
282295

283296
const insertFieldLines: Code[] = [];
284297
const updateFieldLines: Code[] = [];
298+
// #203 — the `insertPreserving` escape hatch's schema: same shape as the insert
299+
// schema, but @autoSet columns are written VERBATIM (no create-time now()
300+
// transform). Only emitted when the entity declares @autoSet fields.
301+
const preservingFieldLines: Code[] = [];
302+
const emitPreserving = hasAutoSetFields(obj);
285303
for (const child of obj.fields()) {
286304
if (autoGenPkFields.has(child.name)) continue;
287305
// FR-013: @readOnly fields appear in neither InsertSchema nor UpdateSchema.
@@ -295,9 +313,9 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
295313
// path) — the app never writes it via the body and never updates it.
296314
// Insert: pinned literal. Update: omitted entirely (clients can't change subtype).
297315
if (tphPin !== undefined && child.name === tphPin.fieldName) {
298-
insertFieldLines.push(
299-
code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`,
300-
);
316+
const litLine = code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`;
317+
insertFieldLines.push(litLine);
318+
preservingFieldLines.push(litLine);
301319
continue;
302320
}
303321

@@ -308,8 +326,13 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
308326
insertFieldLines.push(
309327
code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
310328
);
329+
// Preserving schema: the @autoSet column is validated verbatim (its natural
330+
// field expr) so an import/restore keeps the caller's original timestamp.
331+
preservingFieldLines.push(code` ${child.name}: ${zodFieldExpr(child, obj, ctx)}`);
311332
} else {
312-
insertFieldLines.push(code` ${child.name}: ${zodFieldExpr(child, obj, ctx)}`);
333+
const fieldLine = code` ${child.name}: ${zodFieldExpr(child, obj, ctx)}`;
334+
insertFieldLines.push(fieldLine);
335+
preservingFieldLines.push(fieldLine);
313336
}
314337

315338
// Update schema: @autoSet onCreate → omit entirely; onUpdate → transform
@@ -339,10 +362,24 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
339362

340363
const insertSchemaName = `${obj.name}InsertSchema`;
341364
const updateSchemaName = `${obj.name}UpdateSchema`;
365+
const preservingSchemaName = `${obj.name}InsertPreservingSchema`;
342366

343367
const docs = renderDocsFor(obj);
344368
const docsPrefix = docs ? `${docs}\n` : "";
345369

370+
// #203 — emit the preserving insert-shape only for @autoSet entities (nothing to
371+
// preserve otherwise). Backs `insertPreserving<Entity>` (import/restore/replication).
372+
const preservingBlock = emitPreserving
373+
? code`
374+
375+
/** Insert-shape for import / restore / replication of ${obj.name}: identical to
376+
* ${insertSchemaName}, but the @autoSet timestamp columns are written VERBATIM
377+
* (no create-time now() stamp) so the caller's original values are preserved. */
378+
export const ${preservingSchemaName} = ${z}.object({
379+
${joinCode(preservingFieldLines, { on: ",\n" })}
380+
});`
381+
: code``;
382+
346383
return code`
347384
${docsPrefix}export const ${insertSchemaName} = ${z}.object({
348385
${joinCode(insertFieldLines, { on: ",\n" })}
@@ -354,7 +391,7 @@ ${joinCode(updateFieldLines, { on: ",\n" })}
354391
355392
/** Typed patch shape for ${obj.name}: every settable field, optional (FR-035 PATCH). A
356393
* renamed/dropped field is a compile error at every \`update${obj.name}\` call site. */
357-
export type ${obj.name}Patch = ${z}.input<typeof ${updateSchemaName}>;
394+
export type ${obj.name}Patch = ${z}.input<typeof ${updateSchemaName}>;${preservingBlock}
358395
`;
359396
}
360397

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Issue #203 — the `insertPreserving` escape hatch (TS parity with the other 4 ports).
2+
//
3+
// For an entity that declares @autoSet fields, codegen emits an additional
4+
// `<Entity>InsertPreservingSchema` (the @autoSet columns validated VERBATIM, WITHOUT
5+
// the create-time `now()` transform) plus an `insertPreserving<Entity>(db, data)`
6+
// query — the import / restore / replication path that keeps the original timestamps.
7+
// Emitted ONLY when the entity has @autoSet fields; a plain entity is byte-identical.
8+
9+
import { describe, test, expect } from "bun:test";
10+
import type { MetaObject } from "@metaobjectsdev/metadata";
11+
import { TypeId, TYPE_IDENTITY,
12+
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_TIMESTAMP,
13+
IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY } from "@metaobjectsdev/metadata";
14+
import { meta, metaRoot, metaObject, metaField } from "../_meta-build.js";
15+
import { renderZodValidators, hasAutoSetFields } from "../../src/templates/zod-validators.js";
16+
import { renderInsertPreservingFn } from "../../src/templates/queries.js";
17+
import { renderQueriesFile } from "../../src/templates/queries-file.js";
18+
import { makeRenderContext } from "../../src/render-context.js";
19+
import { buildPkMap } from "../../src/pk-resolver.js";
20+
import { buildRelationMap } from "../../src/relation-resolver.js";
21+
22+
/** Entity WITH @autoSet fields (createdAt onCreate + updatedAt onUpdate). */
23+
function makeAutoSetEntity(): MetaObject {
24+
const entity = metaObject(OBJECT_SUBTYPE_ENTITY, "Event");
25+
const id = metaField(FIELD_SUBTYPE_LONG, "id");
26+
entity.addChild(id);
27+
const name = metaField(FIELD_SUBTYPE_STRING, "name");
28+
name.setAttr("required", true);
29+
entity.addChild(name);
30+
const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt");
31+
createdAt.setAttr("autoSet", "onCreate");
32+
entity.addChild(createdAt);
33+
const updatedAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "updatedAt");
34+
updatedAt.setAttr("autoSet", "onUpdate");
35+
entity.addChild(updatedAt);
36+
const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary");
37+
primary.setAttr("fields", ["id"]);
38+
primary.setAttr("generation", "increment");
39+
entity.addChild(primary);
40+
return entity;
41+
}
42+
43+
/** Plain entity, NO @autoSet field. */
44+
function makePlainEntity(): MetaObject {
45+
const entity = metaObject(OBJECT_SUBTYPE_ENTITY, "Post");
46+
const id = metaField(FIELD_SUBTYPE_LONG, "id");
47+
entity.addChild(id);
48+
const title = metaField(FIELD_SUBTYPE_STRING, "title");
49+
title.setAttr("required", true);
50+
entity.addChild(title);
51+
const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary");
52+
primary.setAttr("fields", ["id"]);
53+
primary.setAttr("generation", "increment");
54+
entity.addChild(primary);
55+
return entity;
56+
}
57+
58+
function makeCtx(obj: MetaObject) {
59+
const root = metaRoot();
60+
root.addChild(obj);
61+
return makeRenderContext({
62+
dialect: "sqlite",
63+
loadedRoot: root,
64+
outDir: "/x",
65+
dbImport: "~/server/db",
66+
pkMap: buildPkMap(root),
67+
relationMap: buildRelationMap(root),
68+
});
69+
}
70+
71+
describe("hasAutoSetFields", () => {
72+
test("true when an entity declares an @autoSet field", () => {
73+
expect(hasAutoSetFields(makeAutoSetEntity())).toBe(true);
74+
});
75+
test("false for a plain entity", () => {
76+
expect(hasAutoSetFields(makePlainEntity())).toBe(false);
77+
});
78+
});
79+
80+
describe("InsertPreservingSchema (emitted only for @autoSet entities)", () => {
81+
test("@autoSet entity emits <Entity>InsertPreservingSchema", () => {
82+
const out = renderZodValidators(makeAutoSetEntity()).toString();
83+
expect(out).toContain("export const EventInsertPreservingSchema");
84+
});
85+
86+
test("@autoSet columns are VERBATIM in the preserving schema — no now() transform", () => {
87+
const out = renderZodValidators(makeAutoSetEntity()).toString();
88+
const preserving = out.split("export const EventInsertPreservingSchema")[1] ?? "";
89+
// createdAt + updatedAt appear, but NOT with the create-time now() transform.
90+
expect(preserving).toContain("createdAt:");
91+
expect(preserving).toContain("updatedAt:");
92+
expect(preserving).not.toContain("new Date().toISOString()");
93+
});
94+
95+
test("plain entity emits NO preserving schema", () => {
96+
const out = renderZodValidators(makePlainEntity()).toString();
97+
expect(out).not.toContain("InsertPreservingSchema");
98+
});
99+
});
100+
101+
describe("renderInsertPreservingFn", () => {
102+
test("emits insertPreserving<Entity> parsing the preserving schema and inserting verbatim", () => {
103+
const entity = makeAutoSetEntity();
104+
const out = renderInsertPreservingFn(entity, makeCtx(entity)).toString();
105+
expect(out).toContain("insertPreservingEvent");
106+
expect(out).toContain("EventInsertPreservingSchema.parse(data)");
107+
expect(out).toContain(".insert(");
108+
expect(out).toContain(".returning()");
109+
});
110+
});
111+
112+
describe("renderQueriesFile insertPreserving gating", () => {
113+
test("@autoSet entity's queries file contains insertPreserving + imports the preserving schema", () => {
114+
const entity = makeAutoSetEntity();
115+
const out = renderQueriesFile(entity, makeCtx(entity));
116+
expect(out).toContain("insertPreservingEvent");
117+
expect(out).toContain("EventInsertPreservingSchema");
118+
});
119+
120+
test("plain entity's queries file has NO insertPreserving", () => {
121+
const entity = makePlainEntity();
122+
const out = renderQueriesFile(entity, makeCtx(entity));
123+
expect(out).not.toContain("insertPreserving");
124+
expect(out).not.toContain("InsertPreservingSchema");
125+
});
126+
});

server/typescript/packages/codegen-ts/test/templates/auto-set-zod.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,25 +58,29 @@ describe("Zod insert schema honors @autoSet", () => {
5858
});
5959
});
6060

61+
/** The field-list body of the emitted UpdateSchema object literal (between its
62+
* `z.object({` and the closing `})`). Bounds the section so assertions can't
63+
* spill into the #203 InsertPreservingSchema that trails the file (which DOES
64+
* carry createdAt verbatim — that's the point). */
65+
function updateSchemaBody(out: string): string {
66+
return out.split("FooUpdateSchema = z.object({")[1]?.split("})")[0] ?? "";
67+
}
68+
6169
describe("Zod update schema omits onCreate, transforms onUpdate", () => {
6270
test("@autoSet: onCreate field is OMITTED from update schema", () => {
6371
const out = renderZodValidators(makeEntity()).toString();
6472
expect(out).toContain("export const FooUpdateSchema");
65-
// createdAt must not appear in UpdateSchema at all
66-
// Split on UpdateSchema to check only that section
67-
const updateSection = out.split("export const FooUpdateSchema")[1] ?? "";
68-
expect(updateSection).not.toContain("createdAt");
73+
// createdAt must not appear in the UpdateSchema body at all.
74+
expect(updateSchemaBody(out)).not.toContain("createdAt");
6975
});
7076

7177
test("@autoSet: onUpdate field is present in update schema with transform", () => {
7278
const out = renderZodValidators(makeEntity()).toString();
73-
const updateSection = out.split("export const FooUpdateSchema")[1] ?? "";
74-
expect(updateSection).toMatch(/updatedAt:.*optional\(\)\.transform\(\(\) => new Date\(\)\.toISOString\(\)\)/);
79+
expect(updateSchemaBody(out)).toMatch(/updatedAt:.*optional\(\)\.transform\(\(\) => new Date\(\)\.toISOString\(\)\)/);
7580
});
7681

7782
test("ordinary required fields become optional in update schema", () => {
7883
const out = renderZodValidators(makeEntity()).toString();
79-
const updateSection = out.split("export const FooUpdateSchema")[1] ?? "";
80-
expect(updateSection).toMatch(/name:.*optional\(\)/);
84+
expect(updateSchemaBody(out)).toMatch(/name:.*optional\(\)/);
8185
});
8286
});

0 commit comments

Comments
 (0)