@metaobjectsdev/codegen-ts 0.15.21-rc.1 → 0.16.0-rc.1

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 (37) hide show
  1. package/dist/column-mapper.d.ts +9 -0
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +8 -2
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/generators/api-doc-render.d.ts.map +1 -1
  6. package/dist/generators/api-doc-render.js +8 -4
  7. package/dist/generators/api-doc-render.js.map +1 -1
  8. package/dist/generators/api-model.js +4 -4
  9. package/dist/generators/api-model.js.map +1 -1
  10. package/dist/projection/build-projection-views.d.ts +36 -1
  11. package/dist/projection/build-projection-views.d.ts.map +1 -1
  12. package/dist/projection/build-projection-views.js +87 -1
  13. package/dist/projection/build-projection-views.js.map +1 -1
  14. package/dist/templates/queries-file.js +1 -1
  15. package/dist/templates/queries-file.js.map +1 -1
  16. package/dist/templates/queries.d.ts.map +1 -1
  17. package/dist/templates/queries.js +14 -3
  18. package/dist/templates/queries.js.map +1 -1
  19. package/dist/templates/routes-file.js +9 -1
  20. package/dist/templates/routes-file.js.map +1 -1
  21. package/dist/templates/value-object-file.d.ts.map +1 -1
  22. package/dist/templates/value-object-file.js +8 -2
  23. package/dist/templates/value-object-file.js.map +1 -1
  24. package/dist/templates/zod-validators.d.ts.map +1 -1
  25. package/dist/templates/zod-validators.js +65 -26
  26. package/dist/templates/zod-validators.js.map +1 -1
  27. package/package.json +6 -6
  28. package/src/column-mapper.ts +8 -2
  29. package/src/generators/api-doc-render.ts +8 -4
  30. package/src/generators/api-model.ts +4 -4
  31. package/src/projection/build-projection-views.ts +126 -1
  32. package/src/reference/queries.ts +1 -1
  33. package/src/templates/queries-file.ts +1 -1
  34. package/src/templates/queries.ts +14 -3
  35. package/src/templates/routes-file.ts +9 -1
  36. package/src/templates/value-object-file.ts +8 -1
  37. package/src/templates/zod-validators.ts +61 -23
@@ -9,20 +9,43 @@
9
9
  // drift / snapshot as the `views` input. This is the ONE place view SQL is produced.
10
10
 
11
11
  import {
12
+ type AggregateFunction,
12
13
  type MetaData,
14
+ MetaObject,
13
15
  MetaRoot,
14
16
  MetaSource,
15
17
  SOURCE_KIND_VIEW,
18
+ TYPE_FIELD,
19
+ TYPE_IDENTITY,
20
+ TYPE_ORIGIN,
16
21
  resolveTableName,
17
22
  resolveTableSchema,
18
23
  } from "@metaobjectsdev/metadata";
19
24
  import { isProjection } from "./projection-detector.js";
20
25
  import { extractViewSpec } from "./extract-view-spec.js";
21
26
  import { emitViewDdl } from "./view-ddl-emit.js";
22
- import type { JoinNode, JoinTree } from "./view-spec.js";
27
+ import type { JoinNode, JoinTree, ViewSpec } from "./view-spec.js";
23
28
  import type { ColumnNamingStrategy } from "../metaobjects-config.js";
24
29
 
25
30
  /** Structurally matches migrate-ts's `ViewDescriptor` (name + body sql + optional schema). */
31
+ /**
32
+ * One output column of the view, in SELECT order, described PHYSICALLY (table +
33
+ * column, not entity + field).
34
+ *
35
+ * migrate-ts resolves each of these to a `SqlType` against its own expected table
36
+ * descriptors. Deliberately no SqlType here: codegen-ts stays ignorant of migrate
37
+ * concerns, and migrate-ts stays ignorant of metadata traversal — the existing
38
+ * layering (migrate-ts never imports codegen-ts; the CLI threads views in).
39
+ *
40
+ * Postgres allows a non-destructive `CREATE OR REPLACE VIEW` only when the existing
41
+ * output columns are a PREFIX of the new ones (same names, same types, same order).
42
+ * That decision cannot be made without knowing the column list, which is why it is
43
+ * carried here.
44
+ */
45
+ export type ExpectedViewColumn =
46
+ | { kind: "passthrough"; name: string; sourceTable: string; sourceColumn: string }
47
+ | { kind: "aggregate"; name: string; sourceTable: string; sourceColumn: string; agg: AggregateFunction };
48
+
26
49
  export interface ExpectedView {
27
50
  name: string;
28
51
  schema?: string;
@@ -34,6 +57,15 @@ export interface ExpectedView {
34
57
  * so the view must be dropped before and recreated after.
35
58
  */
36
59
  dependsOn: string[];
60
+ /**
61
+ * The view's output columns, in SELECT order — i.e. DECLARATION order, since
62
+ * extractViewSpec walks the projection's children in order. That is exactly the
63
+ * order Postgres's OR-REPLACE prefix rule wants: a field appended to the
64
+ * projection lands last, so the change stays non-destructive. (Re-canonicalizing
65
+ * to, say, alphabetical order would be strictly WORSE — it would scatter an
66
+ * appended field into the middle and force a destructive drop+create.)
67
+ */
68
+ columns: ExpectedViewColumn[];
37
69
  }
38
70
 
39
71
  export interface BuildProjectionViewsOptions {
@@ -71,27 +103,120 @@ export function buildProjectionViews(
71
103
  // through here silently created a PLAIN view under the matview's name.
72
104
  // Matviews are hand-managed, like the documented custom-SQL-view
73
105
  // exception: migrate neither creates nor drops them.
106
+ // - a STANDALONE read-model — a plain view projection declaring its own
107
+ // columns, with no origin.* children — is hand-authored SQL too. Codegen
108
+ // already treats it that way on purpose (see projection-decl.ts: "lets a
109
+ // standalone read-only view-entity — explicit columns, no `extends` —
110
+ // generate its read model … standalone views hand-author their SQL").
111
+ // The SCHEMA path never got that memo: extractViewSpec THREW on it
112
+ // ("cannot derive the base entity"), and because the CLI calls this
113
+ // function unconditionally, ONE such projection aborted `meta migrate`
114
+ // for the ENTIRE model — every other entity included. Same crash class as
115
+ // the proc projections above. Skip it instead.
74
116
  // ADR-0039: own — mirrors isProjection/viewName's own-source classification.
75
117
  const readOnlySource = projection.ownChildren().find(
76
118
  (c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
77
119
  );
78
120
  if (readOnlySource?.effectiveKind !== SOURCE_KIND_VIEW) continue;
121
+ if (!viewIsDerived(projection)) continue;
79
122
  const spec = extractViewSpec(projection, root, { columnNamingStrategy });
80
123
  const baseTableName = joinTables[spec.joinTree.baseEntity];
81
124
  if (!baseTableName) continue; // unresolved base — skip (loader/codegen surface the error elsewhere)
82
125
  const body = emitViewDdl(spec, { dialect, baseTableName, joinTables, bodyOnly: true });
83
126
  const schema = resolveTableSchema(projection);
84
127
  const dependsOn = collectDependsOn(spec.joinTree, baseTableName, joinTables);
128
+ const columns = collectViewColumns(spec, baseTableName, joinTables);
85
129
  out.push({
86
130
  name: spec.viewName,
87
131
  sql: body,
88
132
  dependsOn,
133
+ columns,
89
134
  ...(schema !== undefined ? { schema } : {}),
90
135
  });
91
136
  }
92
137
  return out;
93
138
  }
94
139
 
140
+ /**
141
+ * The SELECT list as PHYSICAL (table, column) pairs, in emitted order.
142
+ *
143
+ * SelectColumn carries a join ALIAS, which means nothing outside this module — so
144
+ * resolve every alias to its physical table first.
145
+ */
146
+ function collectViewColumns(
147
+ spec: ViewSpec,
148
+ baseTableName: string,
149
+ joinTables: Readonly<Record<string, string>>,
150
+ ): ExpectedViewColumn[] {
151
+ const aliasToTable = new Map<string, string>([[spec.joinTree.baseAlias, baseTableName]]);
152
+ const walk = (node: JoinNode): void => {
153
+ const t = joinTables[node.targetEntity];
154
+ if (t) aliasToTable.set(node.alias, t);
155
+ for (const child of node.children) walk(child);
156
+ };
157
+ for (const j of spec.joinTree.joins) walk(j);
158
+
159
+ const out: ExpectedViewColumn[] = [];
160
+ for (const c of spec.selectSpec.columns) {
161
+ const sourceTable = aliasToTable.get(c.sourceAlias);
162
+ // An unresolvable alias would make the column list a lie, and a wrong list would
163
+ // make the diff propose an ILLEGAL `CREATE OR REPLACE VIEW` that fails at apply.
164
+ // Drop the whole list instead: migrate-ts treats an absent list as "unknown" and
165
+ // fails safe to a gated drop+create.
166
+ if (sourceTable === undefined) return [];
167
+ out.push(
168
+ c.kind === "aggregate"
169
+ ? { kind: "aggregate", name: c.dbColAlias, sourceTable, sourceColumn: c.sourceColumn, agg: c.agg }
170
+ : { kind: "passthrough", name: c.dbColAlias, sourceTable, sourceColumn: c.sourceColumn },
171
+ );
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Is this projection's view DERIVED from the model — i.e. does migrate own its DDL?
178
+ *
179
+ * A derived view is synthesized from a BASE entity, and the base is anchored by an
180
+ * `extends` binding on the projection's own identity or one of its own fields (that is
181
+ * exactly what `baseEntityFor` resolves). So:
182
+ *
183
+ * - ANCHORED (any own identity/field carries `extends`) → derived. Migrate generates
184
+ * and owns the view. If the anchor is ambiguous, extractViewSpec throws — correctly,
185
+ * that is a real authoring error.
186
+ *
187
+ * - NOT anchored, but carries `origin.*` children → the author clearly MEANT a derived
188
+ * view (an origin says "this column comes from that entity's column") but gave it
189
+ * nothing to derive FROM. That is a malformed projection: fall through to
190
+ * extractViewSpec so it throws its actionable message ("declare an extends-bound
191
+ * identity to anchor the base"). Do NOT silently skip it.
192
+ *
193
+ * - NOT anchored and NO origins → a STANDALONE read-model: it declares its own columns
194
+ * and says nothing about where they come from, so there is nothing to synthesize a
195
+ * body from. Its SQL is hand-authored. Codegen already treats this shape as
196
+ * legitimate and intended (projection-decl.ts generates its read model and notes
197
+ * "standalone views hand-author … their SQL") — the schema path simply never agreed,
198
+ * and threw, which aborted `meta migrate` for the WHOLE model.
199
+ *
200
+ * Trade-off, stated plainly: a hand-authored view is UNMANAGED, so `meta verify --db`
201
+ * cannot drift-check it. That is the same bounded exception the docs already carve out
202
+ * for custom SQL views and matviews — not a new hole. What changes is only that ONE
203
+ * such view no longer blocks migration of every other entity in the tree.
204
+ *
205
+ * ADR-0039: own — a projection's derivation is declared locally; an inherited extends or
206
+ * origin belongs to the parent's own view.
207
+ */
208
+ function viewIsDerived(projection: MetaObject): boolean {
209
+ const own = projection.ownChildren();
210
+ const anchored = own.some(
211
+ (c) => (c.type === TYPE_IDENTITY || c.type === TYPE_FIELD) && c.superRef !== undefined,
212
+ );
213
+ if (anchored) return true;
214
+ // Origins without an anchor = malformed, not standalone. Let extractViewSpec say so.
215
+ return own.some(
216
+ (f) => f.type === TYPE_FIELD && f.ownChildren().some((c) => c.type === TYPE_ORIGIN),
217
+ );
218
+ }
219
+
95
220
  /** The base table plus every joined table, deduped — the physical tables the view reads. */
96
221
  function collectDependsOn(
97
222
  joinTree: JoinTree,
@@ -70,7 +70,7 @@ function renderQueries(obj: MetaObject, ctx: RenderContext): string {
70
70
  ${dbTypeImport}
71
71
  ${dbTypeAlias}
72
72
 
73
- import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
73
+ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
74
74
  `;
75
75
 
76
76
  const sections: Code[] = [
@@ -80,7 +80,7 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
80
80
  ${dbTypeImport}
81
81
  ${dbTypeAlias}
82
82
 
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)};
84
84
  `;
85
85
 
86
86
  const sections: Code[] = [
@@ -91,12 +91,23 @@ export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
91
91
  const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
92
92
  const { fieldName: pkField, tsType: pkType } = getPkInfo(entity, ctx);
93
93
  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`;
95
103
  const eqSym = imp("eq@drizzle-orm");
96
104
 
97
105
  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});
100
111
  const [${singularVar}] = await db.update(${varName}).set(validated).where(${eqSym}(${varName}.${pkField}, ${pkField})).returning();
101
112
  return ${singularVar} ?? null;
102
113
  }
@@ -352,6 +352,14 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
352
352
  ctx.selfTarget, ctx.entityModuleTarget, sub.package, sub.name, ctx.extStyle,
353
353
  );
354
354
  const subInsertSym = imp(`${sub.name}InsertSchema@${subFileSpec}`);
355
+ // FR-036 Program B: the per-subtype UPDATE must carry the FR-035 present-key
356
+ // tristate (a non-@required subtype column accepts an explicit null → clears;
357
+ // a @required column's explicit null is rejected). The subtype's own
358
+ // UpdateSchema encodes exactly that (.optional() + .nullable() for non-required),
359
+ // whereas insertSchema.partial() only makes fields optional, not nullable — so a
360
+ // PATCH {col: null} on a nullable subtype column wrongly 400'd. The base
361
+ // polymorphic mount already uses the base UpdateSchema; this aligns the subtypes.
362
+ const subUpdateSym = imp(`${sub.name}UpdateSchema@${subFileSpec}`);
355
363
  // FR-017 Tier 3: each subtype carries its OWN filter/sort allowlist
356
364
  // (discriminator excluded — it's pinned by this path).
357
365
  const subFilterSym = imp(`${sub.name}FilterAllowlist@${subFileSpec}`);
@@ -363,7 +371,7 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
363
371
  db: ${dbSym},
364
372
  table: ${tableSym},
365
373
  insertSchema: ${subInsertSym}.omit({ ${discField}: true }),
366
- updateSchema: ${subInsertSym}.omit({ ${discField}: true }).partial(),
374
+ updateSchema: ${subUpdateSym},
367
375
  filterAllowlist: ${subFilterSym},
368
376
  sortAllowlist: ${subSortSym},
369
377
  dialect: ${dialectLit},
@@ -14,6 +14,7 @@ import type { RenderContext } from "../render-context.js";
14
14
  import { renderValueObjectInterface, renderEnumTypeAliases } from "./inferred-types.js";
15
15
  import {
16
16
  renderInsertSchemaOnly,
17
+ renderZodValidators,
17
18
  isTphSubtype,
18
19
  renderTphSubtypeReadSchema,
19
20
  tphDiscriminatorPin,
@@ -49,7 +50,13 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
49
50
  renderValueObjectInterface(obj, ctx),
50
51
  ...(enumAliases !== null ? [enumAliases] : []),
51
52
  ...(tphReadSchema !== null ? [tphReadSchema] : []),
52
- renderInsertSchemaOnly(obj, ctx),
53
+ // FR-036 Program B: a TPH subtype needs BOTH schemas — its per-subtype PATCH
54
+ // route must carry the FR-035 present-key tristate (a non-@required subtype
55
+ // column accepts an explicit null → clears), which lives in the UpdateSchema
56
+ // (.nullable() for non-required). renderZodValidators emits <Sub>InsertSchema +
57
+ // <Sub>UpdateSchema (the update omits the pinned discriminator). A pure value
58
+ // object stays insert-only (no PATCH semantics — an UpdateSchema would mislead).
59
+ tphSubtype ? renderZodValidators(obj, ctx) : renderInsertSchemaOnly(obj, ctx),
53
60
  ...(tphConstants !== null ? [tphConstants] : []),
54
61
  ...(tphFilterAllowlist !== null ? [tphFilterAllowlist] : []),
55
62
  ...(tphSortAllowlist !== null ? [tphSortAllowlist] : []),
@@ -36,6 +36,9 @@ import { sharedEnumImportSpecifier } from "../enum-import.js";
36
36
  import { sharedEnumZodConstName } from "./enums-file.js";
37
37
  import type { RenderContext } from "../render-context.js";
38
38
  import { valueObjectModuleSpecifier } from "../import-path.js";
39
+ // FR-035: the SAME required-predicate that drives the Drizzle column's .notNull()
40
+ // drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift.
41
+ import { isRequired } from "../column-mapper.js";
39
42
 
40
43
  /**
41
44
  * FR-017 Tier 1 — when this object is a TPH subtype (@discriminatorValue set
@@ -109,19 +112,34 @@ ${joinCode(fieldLines, { on: ",\n" })}
109
112
  `;
110
113
  }
111
114
 
115
+ /** Field names participating in the object's PRIMARY identity, normalized to a
116
+ * list. Empty when the object has no primary identity. */
117
+ function primaryIdentityFieldNames(obj: MetaObject): string[] {
118
+ const primary = obj.primaryIdentity();
119
+ if (!primary) return [];
120
+ const fields = primary.attr(IDENTITY_ATTR_FIELDS);
121
+ if (Array.isArray(fields)) return fields.map(String);
122
+ if (typeof fields === "string") return [fields];
123
+ return [];
124
+ }
125
+
112
126
  /** Auto-generated PK field names that should be omitted from InsertSchema. */
113
127
  function autoGenPkFieldNames(obj: MetaObject): Set<string> {
114
- const out = new Set<string>();
115
- const primary = obj.primaryIdentity();
116
- if (primary) {
117
- const generation = primary.attr(IDENTITY_ATTR_GENERATION);
118
- if (generation === GENERATION_INCREMENT || generation === GENERATION_UUID) {
119
- const fields = primary.attr(IDENTITY_ATTR_FIELDS);
120
- const fieldsList = Array.isArray(fields) ? fields : (typeof fields === "string" ? [fields] : []);
121
- for (const f of fieldsList) out.add(String(f));
122
- }
123
- }
124
- return out;
128
+ const generation = obj.primaryIdentity()?.attr(IDENTITY_ATTR_GENERATION);
129
+ if (generation !== GENERATION_INCREMENT && generation !== GENERATION_UUID) return new Set();
130
+ return new Set(primaryIdentityFieldNames(obj));
131
+ }
132
+
133
+ /** ALL field names participating in the object's PRIMARY identity, regardless of
134
+ * @generation. A PK column is never NULL (single-col via Drizzle `.primaryKey()`,
135
+ * composite via `.notNull()`), so FR-035 PATCH-2 must NOT make a PK field
136
+ * `.nullable()` in the UpdateSchema even when it carries no @required — otherwise
137
+ * an explicit null typechecks into Drizzle's `.set()` (which rejects null on a
138
+ * not-null column) and would violate NOT NULL at runtime. Unlike
139
+ * autoGenPkFieldNames (which only lists increment/uuid PKs, already excluded from
140
+ * the schema), this covers assigned + extended-identity projection PKs too. */
141
+ function primaryKeyFieldNames(obj: MetaObject): Set<string> {
142
+ return new Set(primaryIdentityFieldNames(obj));
125
143
  }
126
144
 
127
145
  /**
@@ -259,6 +277,7 @@ export function updateSchemaFields(obj: MetaObject): SchemaFieldShape[] {
259
277
  export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code {
260
278
  const z = imp("z@zod");
261
279
  const autoGenPkFields = autoGenPkFieldNames(obj);
280
+ const pkFields = primaryKeyFieldNames(obj);
262
281
  const tphPin = tphDiscriminatorPin(obj);
263
282
 
264
283
  const insertFieldLines: Code[] = [];
@@ -305,9 +324,16 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
305
324
  // zodFieldExpr already appends .optional() when the field is non-required
306
325
  // OR has a default; only append once more when it didn't.
307
326
  const baseExpr = zodFieldExpr(child, obj, ctx);
308
- updateFieldLines.push(
309
- fieldWillBeOptional(child) ? code` ${child.name}: ${baseExpr}` : code` ${child.name}: ${baseExpr}.optional()`,
310
- );
327
+ let expr = fieldWillBeOptional(child) ? baseExpr : code`${baseExpr}.optional()`;
328
+ // FR-035 PATCH-2: a NON-@required field additionally accepts an explicit
329
+ // null — a present null CLEARS the column (`.set({field: null})` writes NULL).
330
+ // Keyed on required-ness, NOT fieldWillBeOptional: a required-with-@default
331
+ // field stays non-nullable so an explicit null on it is a 400 (validation),
332
+ // never a silent clear. PK fields are excluded: their Drizzle column is
333
+ // always not-null, so a nullable Zod type would fail `.set()`'s typecheck
334
+ // (and NOT NULL at runtime) — see primaryKeyFieldNames.
335
+ if (!isRequired(child) && !pkFields.has(child.name)) expr = code`${expr}.nullable()`;
336
+ updateFieldLines.push(code` ${child.name}: ${expr}`);
311
337
  }
312
338
  }
313
339
 
@@ -325,6 +351,10 @@ ${joinCode(insertFieldLines, { on: ",\n" })}
325
351
  ${docsPrefix}export const ${updateSchemaName} = ${z}.object({
326
352
  ${joinCode(updateFieldLines, { on: ",\n" })}
327
353
  });
354
+
355
+ /** Typed patch shape for ${obj.name}: every settable field, optional (FR-035 PATCH). A
356
+ * renamed/dropped field is a compile error at every \`update${obj.name}\` call site. */
357
+ export type ${obj.name}Patch = ${z}.input<typeof ${updateSchemaName}>;
328
358
  `;
329
359
  }
330
360
 
@@ -488,12 +518,8 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
488
518
  /** Mirrors the optional-or-not decision inside appendValidatorChain so the update-schema
489
519
  * caller can avoid stacking a second `.optional()` onto an already-optional expression. */
490
520
  function fieldWillBeOptional(field: MetaField): boolean {
491
- let isRequired = field.attr(FIELD_ATTR_REQUIRED) === true;
492
- for (const child of field.validators()) {
493
- if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) isRequired = true;
494
- }
495
521
  const hasDefault = field.attr(FIELD_ATTR_DEFAULT) !== undefined;
496
- return !isRequired || hasDefault;
522
+ return !isRequired(field) || hasDefault;
497
523
  }
498
524
 
499
525
  /** Numeric field subtypes whose Zod base is `z.number()` — value bounds apply. */
@@ -525,7 +551,10 @@ function appendValidatorChain(base: Code, field: MetaField): Code {
525
551
  if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
526
552
  const max = child.attr(VALIDATOR_ATTR_MAX);
527
553
  const min = child.attr(VALIDATOR_ATTR_MIN);
528
- if (typeof max === "number") maxLen = max;
554
+ // FR-036 A3: @maxLength × validator.length @max = strictest-wins (min).
555
+ // maxLen already carries the field-level @maxLength; fold the validator's
556
+ // @max in as a lower bound so the effective cap is min(@maxLength, @max).
557
+ if (typeof max === "number") maxLen = maxLen === undefined ? max : Math.min(maxLen, max);
529
558
  if (typeof min === "number") minLen = min;
530
559
  }
531
560
  if (child.subType === VALIDATOR_SUBTYPE_REGEX) {
@@ -559,10 +588,19 @@ function appendValidatorChain(base: Code, field: MetaField): Code {
559
588
  if (arrMin !== undefined) chain = code`${chain}.min(${arrMin})`;
560
589
  if (arrMax !== undefined) chain = code`${chain}.max(${arrMax})`;
561
590
  } else if (field.subType === FIELD_SUBTYPE_STRING && !isJsonbBag) {
562
- if (minLen !== undefined) chain = code`${chain}.min(${minLen})`;
563
- else if (isRequired) chain = code`${chain}.min(1)`;
591
+ // FR-036 Pin 1: a @required string is non-empty. The floor is max(@min, 1) so an
592
+ // explicit `validator.length @min: 0` on a required field can't suppress it to
593
+ // `.min(0)` (which would accept ""). A non-required field keeps its authored @min.
594
+ const effectiveMin = Math.max(minLen ?? 0, isRequired ? 1 : 0);
595
+ if (effectiveMin > 0) chain = code`${chain}.min(${effectiveMin})`;
564
596
  if (maxLen !== undefined) chain = code`${chain}.max(${maxLen})`;
565
- if (pattern !== undefined) chain = code`${chain}.regex(new RegExp(${JSON.stringify(pattern)}))`;
597
+ if (pattern !== undefined) {
598
+ // FR-036 Pin 2: validator.regex @pattern is FULL-MATCH — the whole value
599
+ // must match. JS RegExp.test searches, so anchor as ^(?:…)$ (always-wrap;
600
+ // a redundant anchor on an already-anchored pattern still matches identically).
601
+ const anchored = `^(?:${pattern})$`;
602
+ chain = code`${chain}.regex(new RegExp(${JSON.stringify(anchored)}))`;
603
+ }
566
604
  } else if (NUMERIC_FIELD_SUBTYPES.has(field.subType)) {
567
605
  if (numMin !== undefined) chain = code`${chain}.min(${numMin})`;
568
606
  if (numMax !== undefined) chain = code`${chain}.max(${numMax})`;