@metaobjectsdev/codegen-ts 0.6.0 → 0.7.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +161 -4
  2. package/dist/column-mapper.d.ts +16 -0
  3. package/dist/column-mapper.d.ts.map +1 -1
  4. package/dist/column-mapper.js +73 -2
  5. package/dist/column-mapper.js.map +1 -1
  6. package/dist/generators/entity-file.d.ts +15 -0
  7. package/dist/generators/entity-file.d.ts.map +1 -1
  8. package/dist/generators/entity-file.js +2 -1
  9. package/dist/generators/entity-file.js.map +1 -1
  10. package/dist/generators/index.d.ts +2 -0
  11. package/dist/generators/index.d.ts.map +1 -1
  12. package/dist/generators/index.js +2 -0
  13. package/dist/generators/index.js.map +1 -1
  14. package/dist/generators/output-parser-file.d.ts +9 -0
  15. package/dist/generators/output-parser-file.d.ts.map +1 -0
  16. package/dist/generators/output-parser-file.js +37 -0
  17. package/dist/generators/output-parser-file.js.map +1 -0
  18. package/dist/generators/prompt-render-file.d.ts +9 -0
  19. package/dist/generators/prompt-render-file.d.ts.map +1 -0
  20. package/dist/generators/prompt-render-file.js +70 -0
  21. package/dist/generators/prompt-render-file.js.map +1 -0
  22. package/dist/generators/queries-file.d.ts +1 -1
  23. package/dist/generators/queries-file.d.ts.map +1 -1
  24. package/dist/generators/queries-file.js +11 -3
  25. package/dist/generators/queries-file.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/metaobjects-config.d.ts +3 -1
  31. package/dist/metaobjects-config.d.ts.map +1 -1
  32. package/dist/metaobjects-config.js +2 -1
  33. package/dist/metaobjects-config.js.map +1 -1
  34. package/dist/naming.d.ts +3 -12
  35. package/dist/naming.d.ts.map +1 -1
  36. package/dist/naming.js +14 -44
  37. package/dist/naming.js.map +1 -1
  38. package/dist/payload-codegen.d.ts +8 -0
  39. package/dist/payload-codegen.d.ts.map +1 -1
  40. package/dist/payload-codegen.js +33 -3
  41. package/dist/payload-codegen.js.map +1 -1
  42. package/dist/projection/extract-view-spec.d.ts +1 -1
  43. package/dist/projection/extract-view-spec.js +1 -1
  44. package/dist/source-detect.d.ts +10 -0
  45. package/dist/source-detect.d.ts.map +1 -0
  46. package/dist/source-detect.js +30 -0
  47. package/dist/source-detect.js.map +1 -0
  48. package/dist/templates/drizzle-schema.js +27 -3
  49. package/dist/templates/drizzle-schema.js.map +1 -1
  50. package/dist/templates/entity-file.d.ts +15 -1
  51. package/dist/templates/entity-file.d.ts.map +1 -1
  52. package/dist/templates/entity-file.js +15 -5
  53. package/dist/templates/entity-file.js.map +1 -1
  54. package/dist/templates/inferred-types.d.ts +9 -0
  55. package/dist/templates/inferred-types.d.ts.map +1 -1
  56. package/dist/templates/inferred-types.js +88 -2
  57. package/dist/templates/inferred-types.js.map +1 -1
  58. package/dist/templates/output-parser.d.ts +8 -0
  59. package/dist/templates/output-parser.d.ts.map +1 -0
  60. package/dist/templates/output-parser.js +129 -0
  61. package/dist/templates/output-parser.js.map +1 -0
  62. package/dist/templates/projection-decl.d.ts +1 -1
  63. package/dist/templates/projection-decl.js +1 -1
  64. package/dist/templates/queries-file.d.ts.map +1 -1
  65. package/dist/templates/queries-file.js +15 -4
  66. package/dist/templates/queries-file.js.map +1 -1
  67. package/dist/templates/queries.d.ts.map +1 -1
  68. package/dist/templates/queries.js +11 -30
  69. package/dist/templates/queries.js.map +1 -1
  70. package/dist/templates/value-object-file.d.ts +3 -0
  71. package/dist/templates/value-object-file.d.ts.map +1 -0
  72. package/dist/templates/value-object-file.js +27 -0
  73. package/dist/templates/value-object-file.js.map +1 -0
  74. package/dist/templates/zod-validators.d.ts +10 -0
  75. package/dist/templates/zod-validators.d.ts.map +1 -1
  76. package/dist/templates/zod-validators.js +108 -30
  77. package/dist/templates/zod-validators.js.map +1 -1
  78. package/package.json +4 -4
  79. package/src/column-mapper.ts +86 -1
  80. package/src/generators/entity-file.ts +17 -1
  81. package/src/generators/index.ts +2 -0
  82. package/src/generators/output-parser-file.ts +50 -0
  83. package/src/generators/prompt-render-file.ts +95 -0
  84. package/src/generators/queries-file.ts +13 -4
  85. package/src/index.ts +1 -1
  86. package/src/metaobjects-config.ts +4 -2
  87. package/src/naming.ts +22 -46
  88. package/src/payload-codegen.ts +34 -2
  89. package/src/projection/extract-view-spec.ts +1 -1
  90. package/src/source-detect.ts +28 -0
  91. package/src/templates/drizzle-schema.ts +27 -3
  92. package/src/templates/entity-file.ts +36 -5
  93. package/src/templates/inferred-types.ts +117 -3
  94. package/src/templates/output-parser.ts +143 -0
  95. package/src/templates/projection-decl.ts +1 -1
  96. package/src/templates/queries-file.ts +18 -4
  97. package/src/templates/queries.ts +11 -33
  98. package/src/templates/value-object-file.ts +30 -0
  99. package/src/templates/zod-validators.ts +121 -35
@@ -5,12 +5,7 @@ import { code, imp, type Code } from "ts-poet";
5
5
  import type { MetaObject } from "@metaobjectsdev/metadata";
6
6
  import { IDENTITY_ATTR_FIELDS } from "@metaobjectsdev/metadata";
7
7
  import type { RenderContext } from "../render-context.js";
8
- import { variableNameFromEntity, toSnakeCase, pluralize } from "../naming.js";
9
-
10
- /** Derive a stable prepared statement name. Deterministic from entity + field names. */
11
- function prepareName(prefix: string, entitySnakeName: string, fieldDbName: string): string {
12
- return `${prefix}_${entitySnakeName}_by_${fieldDbName}`;
13
- }
8
+ import { variableNameFromEntity, pluralize } from "../naming.js";
14
9
 
15
10
  /** Get the PK field name and its TS type for a given entity. */
16
11
  function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: string; tsType: string } {
@@ -34,31 +29,13 @@ export function renderFindByIdFn(entity: MetaObject, ctx: RenderContext): Code {
34
29
  const varName = variableNameFromEntity(entity.name);
35
30
  const entityName = entity.name;
36
31
  const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
37
- const entitySnakeName = toSnakeCase(entityName);
38
32
  const { fieldName: pkField, tsType: pkType } = getPkInfo(entity, ctx);
39
- const pkSnakeName = toSnakeCase(pkField);
40
- const prepName = prepareName("find", entitySnakeName, pkSnakeName);
41
33
  const fnName = `find${entityName}ById`;
42
- const prepVarName = `${fnName}Prepared`;
43
34
  const eqSym = imp("eq@drizzle-orm");
44
- const sqlSym = imp("sql@drizzle-orm");
45
-
46
- const baseVarName = `${fnName}Base`;
47
-
48
- // Drizzle's `.prepare()` signature differs by dialect:
49
- // - Postgres: prepare(name) — the name is used by the pg driver to cache the plan
50
- // - SQLite: prepare() — no name; the name arg was removed in drizzle-orm 0.41+
51
- const prepArg = ctx.dialect === "postgres" ? `"${prepName}"` : "";
52
35
 
53
36
  return code`
54
- const ${baseVarName} = db
55
- .select()
56
- .from(${varName})
57
- .where(${eqSym}(${varName}.${pkField}, ${sqlSym}.placeholder(${JSON.stringify(pkField)})));
58
- const ${prepVarName} = ${baseVarName}.prepare(${prepArg});
59
-
60
- export async function ${fnName}(${pkField}: ${pkType}): Promise<${entityName} | null> {
61
- const [${singularVar}] = await ${prepVarName}.execute({ ${pkField} });
37
+ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise<${entityName} | null> {
38
+ const [${singularVar}] = await db.select().from(${varName}).where(${eqSym}(${varName}.${pkField}, ${pkField})).limit(1);
62
39
  return ${singularVar} ?? null;
63
40
  }
64
41
  `;
@@ -72,7 +49,7 @@ export function renderListFn(entity: MetaObject, _ctx: RenderContext): Code {
72
49
  const fnName = `list${pluralize(entityName)}`;
73
50
 
74
51
  return code`
75
- export async function ${fnName}(opts?: { limit?: number; offset?: number }): Promise<${entityName}[]> {
52
+ export async function ${fnName}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${entityName}[]> {
76
53
  let q = db.select().from(${varName}).$dynamic();
77
54
  if (opts?.limit !== undefined) q = q.limit(opts.limit);
78
55
  if (opts?.offset !== undefined) q = q.offset(opts.offset);
@@ -89,7 +66,7 @@ export function renderCreateFn(entity: MetaObject, _ctx: RenderContext): Code {
89
66
  const schemaName = `${entityName}InsertSchema`;
90
67
 
91
68
  return code`
92
- export async function ${fnName}(data: unknown): Promise<${entityName}> {
69
+ export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
93
70
  const validated = ${schemaName}.parse(data);
94
71
  const [${singularVar}] = await db.insert(${varName}).values(validated).returning();
95
72
  return ${singularVar}!;
@@ -107,7 +84,7 @@ export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
107
84
  const eqSym = imp("eq@drizzle-orm");
108
85
 
109
86
  return code`
110
- export async function ${fnName}(${pkField}: ${pkType}, data: unknown): Promise<${entityName} | null> {
87
+ export async function ${fnName}(db: Db, ${pkField}: ${pkType}, data: unknown): Promise<${entityName} | null> {
111
88
  const validated = ${schemaName}.partial().parse(data);
112
89
  const [${singularVar}] = await db.update(${varName}).set(validated).where(${eqSym}(${varName}.${pkField}, ${pkField})).returning();
113
90
  return ${singularVar} ?? null;
@@ -123,10 +100,11 @@ export function renderDeleteByIdFn(entity: MetaObject, ctx: RenderContext): Code
123
100
  const eqSym = imp("eq@drizzle-orm");
124
101
 
125
102
  return code`
126
- export async function ${fnName}(${pkField}: ${pkType}): Promise<boolean> {
127
- const result = await db.delete(${varName}).where(${eqSym}(${varName}.${pkField}, ${pkField}));
128
- // SQLite (libsql/Turso) returns { rowsAffected }; postgres returns array from .returning()
129
- return ('rowsAffected' in result ? result.rowsAffected : (result as unknown as unknown[]).length) > 0;
103
+ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise<boolean> {
104
+ // Use .returning() unconditionally — supported on SQLite ≥3.35 (covers D1, libsql/Turso)
105
+ // and Postgres. Result is an array of deleted rows; presence implies success.
106
+ const deleted = await db.delete(${varName}).where(${eqSym}(${varName}.${pkField}, ${pkField})).returning();
107
+ return deleted.length > 0;
130
108
  }
131
109
  `;
132
110
  }
@@ -0,0 +1,30 @@
1
+ // Value-object file composer — emits a streamlined "value-only" module for
2
+ // metaobjects with no writable source.rdb child. Output is just the TS
3
+ // interface + the Zod schema (and enum type aliases when applicable); no
4
+ // Drizzle table, no Infer*Model aliases, no filter allowlists, no constants
5
+ // object.
6
+ //
7
+ // Dispatch: entity-file.ts routes here when hasWritableRdbSource(entity) is
8
+ // false. The entity may still have read-only source.* children (those are
9
+ // handled by the projection path before this one is reached).
10
+
11
+ import { joinCode, type Code } from "ts-poet";
12
+ import type { MetaObject } from "@metaobjectsdev/metadata";
13
+ import { renderValueObjectInterface, renderEnumTypeAliases } from "./inferred-types.js";
14
+ import { renderInsertSchemaOnly } from "./zod-validators.js";
15
+ import { GENERATED_HEADER } from "../constants.js";
16
+
17
+ export function renderValueObjectFile(obj: MetaObject): string {
18
+ const enumAliases = renderEnumTypeAliases(obj);
19
+ const sections: Code[] = [
20
+ renderValueObjectInterface(obj),
21
+ ...(enumAliases !== null ? [enumAliases] : []),
22
+ renderInsertSchemaOnly(obj),
23
+ ];
24
+ const body = joinCode(sections, { on: "\n" }).toString();
25
+ const header =
26
+ `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
27
+ `// Source metadata: ${obj.name} (${obj.fqn()})\n` +
28
+ `// Customize via ${obj.name}.extra.ts in this directory.\n`;
29
+ return header + body;
30
+ }
@@ -2,53 +2,102 @@
2
2
  // Auto-generated PKs are EXCLUDED from InsertSchema (caller doesn't provide them).
3
3
  // @autoSet fields: INSERT → .optional().transform(() => new Date().toISOString())
4
4
  // UPDATE → onCreate fields omitted entirely; onUpdate gets same transform
5
+ //
6
+ // field.object isArray:true objectRef:<Ref> — emits z.array(<Ref>InsertSchema)
7
+ // with a cross-module imp() so consumers passing the schema to
8
+ // zod-to-json-schema get a properly element-typed array. Without this, every
9
+ // object-array field collapsed to z.array(z.string()) and the JSON Schema sent
10
+ // downstream (e.g. to LLM tool_use input_schema) lost the nested object shape.
5
11
 
6
- import { code, imp, type Code } from "ts-poet";
12
+ import { code, joinCode, imp, type Code } from "ts-poet";
7
13
  import { MetaObject, MetaField } from "@metaobjectsdev/metadata";
8
14
  import {
9
15
  FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
10
16
  FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
11
17
  FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP,
12
- FIELD_SUBTYPE_ENUM,
18
+ FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT,
13
19
  VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
14
20
  IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
15
21
  FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT,
16
- FIELD_ATTR_AUTO_SET, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE,
22
+ FIELD_ATTR_AUTO_SET, FIELD_ATTR_OBJECT_REF, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE,
17
23
  VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN,
18
24
  GENERATION_INCREMENT, GENERATION_UUID,
19
25
  } from "@metaobjectsdev/metadata";
20
26
  import { enumValues, zodEnumExpr } from "../enum-meta.js";
21
27
  import { renderDocsFor } from "./jsdoc.js";
22
28
 
23
- export function renderZodValidators(obj: MetaObject): Code {
24
- const z = imp("z@zod");
29
+ /** Auto-generated PK field names that should be omitted from InsertSchema. */
30
+ function autoGenPkFieldNames(obj: MetaObject): Set<string> {
31
+ const out = new Set<string>();
25
32
  const primary = obj.primaryIdentity();
26
- const autoGenPkFields = new Set<string>();
27
33
  if (primary) {
28
34
  const generation = primary.ownAttr(IDENTITY_ATTR_GENERATION);
29
35
  if (generation === GENERATION_INCREMENT || generation === GENERATION_UUID) {
30
36
  const fields = primary.ownAttr(IDENTITY_ATTR_FIELDS);
31
37
  const fieldsList = Array.isArray(fields) ? fields : (typeof fields === "string" ? [fields] : []);
32
- for (const f of fieldsList) autoGenPkFields.add(String(f));
38
+ for (const f of fieldsList) out.add(String(f));
33
39
  }
34
40
  }
41
+ return out;
42
+ }
35
43
 
36
- const insertFieldLines: string[] = [];
37
- const updateFieldLines: string[] = [];
44
+ /**
45
+ * Emit ONLY the `<Name>InsertSchema`. Used by the value-object file emitter
46
+ * for metaobjects with no writable source.rdb — those have no PATCH/update
47
+ * semantics, so emitting an UpdateSchema would be misleading.
48
+ *
49
+ * The schema name is kept as `<Name>InsertSchema` even for pure value objects
50
+ * so consumer imports don't churn. A future polish PR could add a `<Name>Schema`
51
+ * alias for clarity.
52
+ */
53
+ export function renderInsertSchemaOnly(obj: MetaObject): Code {
54
+ const z = imp("z@zod");
55
+ const autoGenPkFields = autoGenPkFieldNames(obj);
56
+
57
+ const insertFieldLines: Code[] = [];
58
+ for (const child of obj.fields()) {
59
+ if (autoGenPkFields.has(child.name)) continue;
60
+
61
+ const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
62
+
63
+ if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) {
64
+ insertFieldLines.push(
65
+ code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
66
+ );
67
+ } else {
68
+ insertFieldLines.push(code` ${child.name}: ${zodFieldExpr(child)}`);
69
+ }
70
+ }
71
+
72
+ const insertSchemaName = `${obj.name}InsertSchema`;
73
+ const docs = renderDocsFor(obj);
74
+ const docsPrefix = docs ? `${docs}\n` : "";
75
+
76
+ return code`
77
+ ${docsPrefix}export const ${insertSchemaName} = ${z}.object({
78
+ ${joinCode(insertFieldLines, { on: ",\n" })}
79
+ });
80
+ `;
81
+ }
82
+
83
+ export function renderZodValidators(obj: MetaObject): Code {
84
+ const z = imp("z@zod");
85
+ const autoGenPkFields = autoGenPkFieldNames(obj);
86
+
87
+ const insertFieldLines: Code[] = [];
88
+ const updateFieldLines: Code[] = [];
38
89
  for (const child of obj.fields()) {
39
90
  if (autoGenPkFields.has(child.name)) continue;
40
91
 
41
92
  const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
42
93
 
43
94
  // Insert schema: @autoSet fields use transform (always override client input).
44
- // NOTE: use "z" as a literal string here — these lines are embedded in the
45
- // `code` template tag below which resolves the imp("z@zod") import.
46
95
  if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) {
47
96
  insertFieldLines.push(
48
- ` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
97
+ code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
49
98
  );
50
99
  } else {
51
- insertFieldLines.push(` ${child.name}: ${zodFieldExpr(child)}`);
100
+ insertFieldLines.push(code` ${child.name}: ${zodFieldExpr(child)}`);
52
101
  }
53
102
 
54
103
  // Update schema: @autoSet onCreate → omit entirely; onUpdate → transform
@@ -56,13 +105,16 @@ export function renderZodValidators(obj: MetaObject): Code {
56
105
  // Omit: creation timestamps cannot be changed after creation
57
106
  } else if (autoSet === AUTO_SET_ON_UPDATE) {
58
107
  updateFieldLines.push(
59
- ` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
108
+ code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
60
109
  );
61
110
  } else {
62
- // All non-autoSet fields are optional in the update schema (PATCH semantics)
63
- const expr = zodFieldExpr(child);
64
- const optionalExpr = expr.endsWith(".optional()") ? expr : `${expr}.optional()`;
65
- updateFieldLines.push(` ${child.name}: ${optionalExpr}`);
111
+ // All non-autoSet fields are optional in the update schema (PATCH semantics).
112
+ // zodFieldExpr already appends .optional() when the field is non-required
113
+ // OR has a default; only append once more when it didn't.
114
+ const baseExpr = zodFieldExpr(child);
115
+ updateFieldLines.push(
116
+ fieldWillBeOptional(child) ? code` ${child.name}: ${baseExpr}` : code` ${child.name}: ${baseExpr}.optional()`,
117
+ );
66
118
  }
67
119
  }
68
120
 
@@ -74,48 +126,82 @@ export function renderZodValidators(obj: MetaObject): Code {
74
126
 
75
127
  return code`
76
128
  ${docsPrefix}export const ${insertSchemaName} = ${z}.object({
77
- ${insertFieldLines.join(",\n")}
129
+ ${joinCode(insertFieldLines, { on: ",\n" })}
78
130
  });
79
131
 
80
132
  ${docsPrefix}export const ${updateSchemaName} = ${z}.object({
81
- ${updateFieldLines.join(",\n")}
133
+ ${joinCode(updateFieldLines, { on: ",\n" })}
82
134
  });
83
135
  `;
84
136
  }
85
137
 
86
- function zodFieldExpr(field: MetaField): string {
87
- let base: string;
138
+ function zodFieldExpr(field: MetaField): Code {
139
+ // FIELD_SUBTYPE_OBJECT: emit z.array(<Ref>InsertSchema) / <Ref>InsertSchema
140
+ // via an imp() so ts-poet hoists the cross-module import. Without this the
141
+ // field used to collapse to z.string() / z.array(z.string()) and downstream
142
+ // JSON Schema (e.g. LLM tool_use input_schema) lost the nested object shape.
143
+ if (field.subType === FIELD_SUBTYPE_OBJECT) {
144
+ const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
145
+ if (typeof ref === "string" && ref.length > 0) {
146
+ const refImp = imp(`${ref}InsertSchema@./${ref}.js`);
147
+ let base: Code = code`${refImp}`;
148
+ if (field.isArray) base = code`z.array(${base})`;
149
+ return appendValidatorChain(base, field);
150
+ }
151
+ // No resolvable @objectRef — fall through to z.unknown(); downstream code
152
+ // can still pass a value through but loses validation.
153
+ let base: Code = code`z.unknown()`;
154
+ if (field.isArray) base = code`z.array(${base})`;
155
+ return appendValidatorChain(base, field);
156
+ }
157
+
158
+ let baseStr: string;
88
159
  switch (field.subType) {
89
160
  case FIELD_SUBTYPE_INT:
90
161
  case FIELD_SUBTYPE_CURRENCY:
91
162
  case FIELD_SUBTYPE_LONG:
92
- base = "z.number().int()";
163
+ baseStr = "z.number().int()";
93
164
  break;
94
165
  case FIELD_SUBTYPE_DOUBLE:
95
166
  case FIELD_SUBTYPE_FLOAT:
96
- base = "z.number()";
167
+ baseStr = "z.number()";
97
168
  break;
98
169
  case FIELD_SUBTYPE_BOOLEAN:
99
- base = "z.boolean()";
170
+ baseStr = "z.boolean()";
100
171
  break;
101
172
  case FIELD_SUBTYPE_DATE:
102
173
  case FIELD_SUBTYPE_TIME:
103
174
  case FIELD_SUBTYPE_TIMESTAMP:
104
- base = "z.string()";
175
+ baseStr = "z.string()";
105
176
  break;
106
177
  case FIELD_SUBTYPE_ENUM: {
107
178
  const values = enumValues(field);
108
- base = values !== undefined ? zodEnumExpr(values) : "z.string()";
179
+ baseStr = values !== undefined ? zodEnumExpr(values) : "z.string()";
109
180
  break;
110
181
  }
111
182
  case FIELD_SUBTYPE_STRING:
112
183
  default:
113
- base = "z.string()";
184
+ baseStr = "z.string()";
114
185
  break;
115
186
  }
116
187
 
117
- if (field.isArray) base = `z.array(${base})`;
188
+ if (field.isArray) baseStr = `z.array(${baseStr})`;
189
+ return appendValidatorChain(code`${baseStr}`, field);
190
+ }
191
+
192
+ /** Mirrors the optional-or-not decision inside appendValidatorChain so the update-schema
193
+ * caller can avoid stacking a second `.optional()` onto an already-optional expression. */
194
+ function fieldWillBeOptional(field: MetaField): boolean {
195
+ let isRequired = field.ownAttr(FIELD_ATTR_REQUIRED) === true;
196
+ for (const child of field.validators()) {
197
+ if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) isRequired = true;
198
+ }
199
+ const hasDefault = field.ownAttr(FIELD_ATTR_DEFAULT) !== undefined;
200
+ return !isRequired || hasDefault;
201
+ }
118
202
 
203
+ /** Append .min/.max/.regex/.optional() based on field-level validators + required state. */
204
+ function appendValidatorChain(base: Code, field: MetaField): Code {
119
205
  let isRequired = field.ownAttr(FIELD_ATTR_REQUIRED) === true;
120
206
  let maxLen: number | undefined = field.ownAttr(FIELD_ATTR_MAX_LENGTH) as number | undefined;
121
207
  let minLen: number | undefined;
@@ -134,18 +220,18 @@ function zodFieldExpr(field: MetaField): string {
134
220
  }
135
221
  }
136
222
 
137
- let chain = base;
223
+ let chain: Code = base;
138
224
  if (field.subType === FIELD_SUBTYPE_STRING && !field.isArray) {
139
- if (minLen !== undefined) chain += `.min(${minLen})`;
140
- else if (isRequired) chain += `.min(1)`;
141
- if (maxLen !== undefined) chain += `.max(${maxLen})`;
142
- if (pattern !== undefined) chain += `.regex(new RegExp(${JSON.stringify(pattern)}))`;
225
+ if (minLen !== undefined) chain = code`${chain}.min(${minLen})`;
226
+ else if (isRequired) chain = code`${chain}.min(1)`;
227
+ if (maxLen !== undefined) chain = code`${chain}.max(${maxLen})`;
228
+ if (pattern !== undefined) chain = code`${chain}.regex(new RegExp(${JSON.stringify(pattern)}))`;
143
229
  }
144
230
 
145
231
  // Fields with DB-level defaults are optional in the InsertSchema: the caller
146
232
  // can omit them and the DB will fill in. Otherwise required-with-default
147
233
  // would force callers to repeat the default at every call site.
148
234
  const hasDefault = field.ownAttr(FIELD_ATTR_DEFAULT) !== undefined;
149
- if (!isRequired || hasDefault) chain += `.optional()`;
235
+ if (!isRequired || hasDefault) chain = code`${chain}.optional()`;
150
236
  return chain;
151
237
  }