@wildo-ai/zod-decorators 1.0.1 → 1.1.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 (98) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/.builder.pid +9 -0
  3. package/dist/esm/__mocks__/mime.d.ts.map +1 -1
  4. package/dist/esm/__mocks__/mime.js +1 -1
  5. package/dist/esm/__mocks__/mime.js.map +1 -1
  6. package/dist/esm/commons.d.ts.map +1 -1
  7. package/dist/esm/commons.js +1 -1
  8. package/dist/esm/commons.js.map +1 -1
  9. package/dist/esm/companion-exports.d.ts +8 -0
  10. package/dist/esm/companion-exports.d.ts.map +1 -0
  11. package/dist/esm/companion-exports.js +8 -0
  12. package/dist/esm/companion-exports.js.map +1 -0
  13. package/dist/esm/currencies.shared.schemas.d.ts.map +1 -1
  14. package/dist/esm/currencies.shared.schemas.js +1 -1
  15. package/dist/esm/currencies.shared.schemas.js.map +1 -1
  16. package/dist/esm/files/agent-file-formats.d.ts.map +1 -1
  17. package/dist/esm/files/agent-file-formats.js +1 -1
  18. package/dist/esm/files/agent-file-formats.js.map +1 -1
  19. package/dist/esm/files/extension-nature-map.d.ts +1 -1
  20. package/dist/esm/files/extension-nature-map.d.ts.map +1 -1
  21. package/dist/esm/files/extension-nature-map.js +1 -1
  22. package/dist/esm/files/extension-nature-map.js.map +1 -1
  23. package/dist/esm/files/file-extensions.enum.d.ts.map +1 -1
  24. package/dist/esm/files/file-extensions.enum.js +1 -1
  25. package/dist/esm/files/file-extensions.enum.js.map +1 -1
  26. package/dist/esm/files/mime-registry.d.ts +1 -1
  27. package/dist/esm/files/mime-registry.d.ts.map +1 -1
  28. package/dist/esm/files/mime-registry.js +1 -1
  29. package/dist/esm/files/mime-registry.js.map +1 -1
  30. package/dist/esm/files/unified-registry.d.ts +1 -1
  31. package/dist/esm/files/unified-registry.d.ts.map +1 -1
  32. package/dist/esm/files/unified-registry.js +1 -1
  33. package/dist/esm/files/unified-registry.js.map +1 -1
  34. package/dist/esm/index.d.ts +20 -19
  35. package/dist/esm/index.d.ts.map +1 -1
  36. package/dist/esm/index.js +10 -1
  37. package/dist/esm/index.js.map +1 -1
  38. package/dist/esm/install-bigint-json-encoding.d.ts +80 -0
  39. package/dist/esm/install-bigint-json-encoding.d.ts.map +1 -0
  40. package/dist/esm/install-bigint-json-encoding.js +85 -0
  41. package/dist/esm/install-bigint-json-encoding.js.map +1 -0
  42. package/dist/esm/testing/decorated-test-schemas.d.ts +8 -8
  43. package/dist/esm/testing/decorated-test-schemas.d.ts.map +1 -1
  44. package/dist/esm/testing/decorated-test-schemas.js +1 -1
  45. package/dist/esm/testing/decorated-test-schemas.js.map +1 -1
  46. package/dist/esm/testing.d.ts +1 -1
  47. package/dist/esm/testing.d.ts.map +1 -1
  48. package/dist/esm/testing.js +1 -1
  49. package/dist/esm/testing.js.map +1 -1
  50. package/dist/esm/zod-bigdecimal.d.ts +5 -1
  51. package/dist/esm/zod-bigdecimal.d.ts.map +1 -1
  52. package/dist/esm/zod-bigdecimal.js +4 -1
  53. package/dist/esm/zod-bigdecimal.js.map +1 -1
  54. package/dist/esm/zod-code.d.ts +4 -1
  55. package/dist/esm/zod-code.d.ts.map +1 -1
  56. package/dist/esm/zod-code.js +4 -1
  57. package/dist/esm/zod-code.js.map +1 -1
  58. package/dist/esm/zod-color.d.ts.map +1 -1
  59. package/dist/esm/zod-color.js +1 -1
  60. package/dist/esm/zod-color.js.map +1 -1
  61. package/dist/esm/zod-decorators.types.d.ts +1410 -56
  62. package/dist/esm/zod-decorators.types.d.ts.map +1 -1
  63. package/dist/esm/zod-decorators.types.js +662 -16
  64. package/dist/esm/zod-decorators.types.js.map +1 -1
  65. package/dist/esm/zod-duration.d.ts +8 -4
  66. package/dist/esm/zod-duration.d.ts.map +1 -1
  67. package/dist/esm/zod-duration.js +2 -1
  68. package/dist/esm/zod-duration.js.map +1 -1
  69. package/dist/esm/zod-file.d.ts +75 -14
  70. package/dist/esm/zod-file.d.ts.map +1 -1
  71. package/dist/esm/zod-file.js +151 -8
  72. package/dist/esm/zod-file.js.map +1 -1
  73. package/dist/esm/zod-money.d.ts +18 -3
  74. package/dist/esm/zod-money.d.ts.map +1 -1
  75. package/dist/esm/zod-money.js +4 -1
  76. package/dist/esm/zod-money.js.map +1 -1
  77. package/dist/esm/zod-phone.d.ts +37 -1
  78. package/dist/esm/zod-phone.d.ts.map +1 -1
  79. package/dist/esm/zod-phone.js +66 -5
  80. package/dist/esm/zod-phone.js.map +1 -1
  81. package/dist/esm/zod-range.d.ts +4 -1
  82. package/dist/esm/zod-range.d.ts.map +1 -1
  83. package/dist/esm/zod-range.js +4 -1
  84. package/dist/esm/zod-range.js.map +1 -1
  85. package/dist/esm/zod-utils.shared.d.ts +411 -6
  86. package/dist/esm/zod-utils.shared.d.ts.map +1 -1
  87. package/dist/esm/zod-utils.shared.js +624 -9
  88. package/dist/esm/zod-utils.shared.js.map +1 -1
  89. package/dist/esm/zod-versioned.d.ts +1 -1
  90. package/dist/esm/zod-versioned.d.ts.map +1 -1
  91. package/dist/esm/zod-versioned.js +1 -1
  92. package/dist/esm/zod-versioned.js.map +1 -1
  93. package/dist/esm/zod.module-override.d.ts +458 -68
  94. package/dist/esm/zod.module-override.d.ts.map +1 -1
  95. package/dist/esm/zod.module-override.js +1214 -195
  96. package/dist/esm/zod.module-override.js.map +1 -1
  97. package/dist/tsconfig.build.tsbuildinfo +1 -1
  98. package/package.json +20 -11
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { ForeignKeyDisplayMode, FieldSchemaType, FormFieldComponent, RangeType, SCHEMA_METADATA, ZodDecorator } from './zod-decorators.types.js';
2
+ import { StringEditMode } from './zod-decorators.types.js';
3
+ import { DEFAULT_FOREIGN_KEY_DISPLAY_MODE, FileGenerationSource, FieldSchemaType, FormFieldComponent, RangeType, SCHEMA_METADATA, ZodDecorator } from './zod-decorators.types.js';
3
4
  import { z_range } from './zod-range.js';
4
5
  import { addDecorators, getMetadataRecursive } from './zod.module-override.js';
5
6
  // Type guard to check if a schema has Zod v4 internals
@@ -96,6 +97,238 @@ export function unwrapSchema(schema) {
96
97
  return schema;
97
98
  }
98
99
  }
100
+ /**
101
+ * Make a field OPTIONAL for a PATCH-style UPDATE DTO, peeling any OUTER
102
+ * `.default()` wrapper first.
103
+ *
104
+ * PATCH semantics: a field the caller OMITS must be left untouched. A field
105
+ * declared `z.number().default(0)` breaks this — Zod re-materialises `0` for the
106
+ * absent key on every parse, so the derived `$set` silently CLOBBERS the stored
107
+ * value with the default. (Live defect: a webhook delivery row's `attemptCount`
108
+ * counter was reset to `0` by every status transition that did not set it, so
109
+ * the retry cap `attemptCount >= MAX_ATTEMPTS` was never reached and dead
110
+ * deliveries retried forever.)
111
+ *
112
+ * It is ALSO where the framework's field-CLEARING contract is admitted onto the wire. An optional
113
+ * field additionally gains a `.nullable()` wrapper here, so a caller can distinguish the two
114
+ * PATCH intents that an absent key alone cannot express:
115
+ *
116
+ * - key ABSENT → leave the stored value untouched (classic PATCH semantics, above);
117
+ * - value NULL → this field has no value (clear it).
118
+ *
119
+ * Eligibility is decided by `fieldAdmitsAddedNullClear` — notably NOT by `isOptional()`, which
120
+ * would wrongly admit `.default()` fields. Required fields keep rejecting `null`. The wrapper is
121
+ * added INSIDE the optional (`ZodOptional<ZodNullable<…>>`) so the outer type stays `optional` for
122
+ * every consumer that introspects it.
123
+ *
124
+ * Behaviour:
125
+ * - Non-default fields: `fieldSchema.optional()`, plus a `.nullable()` wrapper when the field is
126
+ * optional and does not already accept null.
127
+ * - Default-wrapped fields: the outer `ZodDefault` is peeled, the inner schema
128
+ * (keeping `.int()` / `.min()` / refinements) is made optional, and any
129
+ * decorator metadata attached to the default wrapper (e.g.
130
+ * `.default(0).isSummaryField()`) is carried onto the resulting optional so
131
+ * `getMetadataRecursive` is unchanged. The inner schema is SHARED, never
132
+ * mutated, so the source schema is left intact.
133
+ *
134
+ * Defaults remain correct for CREATE (a new row gets them) — this transform is
135
+ * for the UPDATE DTO only.
136
+ */
137
+ export function toPatchOptionalField(fieldSchema) {
138
+ const admitsNullClear = fieldAdmitsAddedNullClear(fieldSchema);
139
+ if (!isZodDefault(fieldSchema)) {
140
+ return admitsNullClear ? fieldSchema.nullable().optional() : fieldSchema.optional();
141
+ }
142
+ const innerType = getZodDefSchemaProperty(fieldSchema, 'innerType');
143
+ if (!innerType)
144
+ return fieldSchema.optional();
145
+ // A defaulted field is never null-clearable (see `fieldAdmitsAddedNullClear`), so the peeled
146
+ // inner is made optional WITHOUT a nullable wrapper.
147
+ const optionalInner = innerType.optional();
148
+ const defaultWrapperMetadata = fieldSchema[SCHEMA_METADATA];
149
+ if (defaultWrapperMetadata) {
150
+ const optionalRecord = optionalInner;
151
+ const existing = optionalRecord[SCHEMA_METADATA] ?? {};
152
+ optionalRecord[SCHEMA_METADATA] = {
153
+ ...existing,
154
+ ...defaultWrapperMetadata,
155
+ };
156
+ }
157
+ return optionalInner;
158
+ }
159
+ /**
160
+ * The value an edit control should contribute to form state, once a "the user emptied this field"
161
+ * gesture has been normalised into the framework's single CLEARED representation.
162
+ *
163
+ * # Why a normalisation step has to exist at all
164
+ *
165
+ * An HTML form control cannot express "no value". The platform defines a form entry's value as a
166
+ * string (or a `File`), and assigning `null` to `input.value` is coerced to `""` at the DOM
167
+ * boundary. So a user clearing a text box produces `''`, a user clearing a date picker produces
168
+ * `undefined`, and neither of those is a wire representation of "clear this field". Left alone they
169
+ * produce three different wrong outcomes: `''` fails a refined schema outright (a colour or phone
170
+ * field could never be emptied once set), `''` passes a plain string schema and PERSISTS an empty
171
+ * string as a second, redundant "no data" value, and `undefined` is dropped by `JSON.stringify` so
172
+ * the clear silently never reaches the server at all.
173
+ *
174
+ * # What it normalises TO, and why not `undefined`
175
+ *
176
+ * Always `null`, never `undefined`. React Hook Form reserves `undefined` to mean "fall back to
177
+ * defaultValue" and documents that calling `onChange` with it is not valid, so `undefined` is not a
178
+ * representable field value there. `null` is the value RHF itself prescribes for a cleared field,
179
+ * and it is the one sentinel that survives every downstream seam: the submission path keeps it, the
180
+ * section-save path keeps it, and `JSON.stringify` keeps it.
181
+ *
182
+ * # Where this must be called
183
+ *
184
+ * At the ONE place a control's change reaches form state, before the value is handed to both the
185
+ * form library and the autosave queue. Normalising later (at submit) is too late — validation has
186
+ * already rejected the empty string — and normalising only for validation is the documented Formik
187
+ * defect, where the schema validates a value the server never receives.
188
+ *
189
+ * # What it deliberately leaves alone
190
+ *
191
+ * - **Fields that cannot be cleared** — a required field, or one whose declaration otherwise
192
+ * rejects null. Their empty value is returned untouched so validation still fails with the normal
193
+ * message rather than being silently rewritten.
194
+ * - **Password and OTP inputs** — an empty password box conventionally means "unchanged", never
195
+ * "remove the credential". Removing a secret must be an explicit destructive action, not a blank
196
+ * field.
197
+ * - **Any non-empty value**, including `0` and `false`, which are values rather than absences.
198
+ *
199
+ * # Two entry points, because the caller's schema decides the question
200
+ *
201
+ * A form validates the operation's REQUEST DTO; inline edit on a display surface only ever holds
202
+ * the resource's MAIN schema. Those two answer "can this be cleared?" differently and cannot be
203
+ * told apart by shape, so the caller picks the entry point matching what it holds — see
204
+ * `requestDtoFieldIsClearable` for why merging them is unsound. This is the REQUEST-DTO one.
205
+ *
206
+ * @param fieldSchema - the field as it appears on the operation's request DTO.
207
+ * @param value - the raw value the control emitted.
208
+ * @returns `null` when the gesture is a clear on a clearable field; otherwise `value` unchanged.
209
+ */
210
+ export function normalizeClearedRequestDtoValue(fieldSchema, value) {
211
+ return normalizeClearedValue(fieldSchema, value, requestDtoFieldIsClearable);
212
+ }
213
+ /**
214
+ * `normalizeClearedFieldValue` for a caller holding the resource's MAIN schema — inline edit on a
215
+ * display surface, and any edit control reasoning about its own declared field.
216
+ *
217
+ * Same contract, different question asked of the schema. See `mainSchemaFieldIsClearable`.
218
+ */
219
+ export function normalizeClearedMainSchemaValue(fieldSchema, value) {
220
+ return normalizeClearedValue(fieldSchema, value, mainSchemaFieldIsClearable);
221
+ }
222
+ function normalizeClearedValue(fieldSchema, value, isClearable) {
223
+ if (!isEmptyFieldGesture(value))
224
+ return value;
225
+ const editMode = getStringUI(fieldSchema)?.edit?.editMode;
226
+ if (editMode === StringEditMode.PASSWORD || editMode === StringEditMode.OTP)
227
+ return value;
228
+ if (!isClearable(fieldSchema))
229
+ return value;
230
+ return null;
231
+ }
232
+ /**
233
+ * Is "clear it" a meaningful instruction for this field, as declared on an UPDATE REQUEST DTO?
234
+ *
235
+ * On a request DTO the answer is exactly "does it accept null", because `toPatchOptionalField` adds
236
+ * a nullable wrapper to the clearable fields and to no others.
237
+ *
238
+ * **This must never be asked of a MAIN-schema field, and its sibling must never be asked of a DTO
239
+ * field.** The two schemas cannot be told apart by shape, and each answers only its own question
240
+ * correctly:
241
+ *
242
+ * - On a DTO, PATCH semantics make EVERY field optional, so a required field is shape-identical to
243
+ * an optional one. "Declares absence" is therefore meaningless here and would report every
244
+ * required field clearable.
245
+ * - On a main schema, an `.optional()` field does NOT accept null, so "accepts null" would report
246
+ * every ordinary optional field un-clearable.
247
+ *
248
+ * Merging them into one permissive predicate looks convenient and is unsound in the first
249
+ * direction: it silently made required fields clearable, which a pin caught.
250
+ */
251
+ export function requestDtoFieldIsClearable(fieldSchema) {
252
+ return fieldSchema.safeParse(null).success;
253
+ }
254
+ /**
255
+ * Is "clear it" a meaningful instruction for this field, as declared on a resource's MAIN schema?
256
+ *
257
+ * Here the author's own declaration is the answer: a field that may be absent (`.optional()`) or
258
+ * explicitly null (`.nullish()`) can be cleared; a required or defaulted one cannot. Read the
259
+ * warning on `requestDtoFieldIsClearable` before using either.
260
+ */
261
+ export function mainSchemaFieldIsClearable(fieldSchema) {
262
+ return fieldSchema.safeParse(null).success || fieldAdmitsAddedNullClear(fieldSchema);
263
+ }
264
+ /**
265
+ * Is this value an edit control's way of saying "the user emptied me"?
266
+ *
267
+ * The three shapes are the three the framework's controls actually emit: `''` from the string,
268
+ * enum and number families, `undefined` from the date/time family, and `null` from a control that
269
+ * has already been normalised (so re-normalising is idempotent).
270
+ */
271
+ function isEmptyFieldGesture(value) {
272
+ return value === '' || value === undefined || value === null;
273
+ }
274
+ /**
275
+ * Should an UPDATE DTO ADD a `.nullable()` wrapper to this field so a caller can CLEAR it?
276
+ *
277
+ * This is the eligibility half of the framework's field-clearing contract. The wire rule is
278
+ * JSON-Merge-Patch shaped and has exactly one meaning: an ABSENT key leaves the stored value
279
+ * untouched, and an explicit `null` means "this field has no value". The repository write boundary
280
+ * then derives the STORAGE representation from the field's own declaration — `$unset` / SQL NULL
281
+ * for a field whose schema rejects null, a stored null for one authored `.nullish()`.
282
+ *
283
+ * Three declarations must be told apart, and the distinction is not the one `isOptional()` draws:
284
+ *
285
+ * - **Optional (`.optional()`, `.nullish()`)** — the author said this field may have no value, so
286
+ * clearing it is meaningful. ELIGIBLE.
287
+ * - **Required (`z.string()`)** — no "no value" state exists. NOT eligible: `null` must keep
288
+ * failing validation, which is also what the industry guidance prescribes (a field that cannot be
289
+ * deleted answers 400 rather than silently ignoring the instruction).
290
+ * - **Defaulted (`.default('x')`)** — `isOptional()` reports TRUE for these because the *input* may
291
+ * be omitted, but the *output* type is non-optional: the schema guarantees a value is always
292
+ * present, and the READ path validates against that guarantee. Clearing one would persist an
293
+ * absence the resource's own main schema rejects, turning the next read into a 500. NOT eligible,
294
+ * and this is precisely why this predicate exists instead of reusing `isOptional()`.
295
+ *
296
+ * A field that ALREADY accepts null (authored `.nullable()` / `.nullish()`) is reported `false` —
297
+ * not because it cannot be cleared, but because nothing needs adding: it accepts `null` on its own,
298
+ * and re-wrapping would emit a redundant nullable in the generated JSON Schema / OpenAPI document.
299
+ *
300
+ * @param fieldSchema - the field as declared on the resource's main schema.
301
+ * @returns `true` only when a nullable wrapper must be ADDED for the field to be clearable.
302
+ */
303
+ export function fieldAdmitsAddedNullClear(fieldSchema) {
304
+ // A default makes the OUTPUT non-optional — see the JSDoc above. Checked first because
305
+ // `ZodDefault` wraps an inner schema that is frequently optional-looking.
306
+ if (isZodDefault(fieldSchema))
307
+ return false;
308
+ if (!declaresAbsenceAsAValidState(fieldSchema))
309
+ return false;
310
+ // Already null-accepting (`.nullable()` / `.nullish()`): clearable without any addition.
311
+ return !fieldSchema.safeParse(null).success;
312
+ }
313
+ /**
314
+ * Does this field's declaration allow the value to be ABSENT (i.e. accept `undefined`)?
315
+ *
316
+ * Deliberately NOT `isOptional()`: that helper answers "may the user omit this input?", which is
317
+ * true for `.default()` fields, whereas this answers "may this field legitimately hold no value?",
318
+ * which is false for them. Merging the two is what would make a defaulted field clearable.
319
+ */
320
+ function declaresAbsenceAsAValidState(fieldSchema) {
321
+ if (isZodDefault(fieldSchema))
322
+ return false;
323
+ if (isZodOptional(fieldSchema))
324
+ return true;
325
+ const type = getZodType(fieldSchema);
326
+ if (type === 'nullable' || type === 'pipe' || type === 'readonly' || type === 'catch') {
327
+ const innerSchema = getZodDefSchemaProperty(fieldSchema, 'innerType') ?? getZodDefSchemaProperty(fieldSchema, 'in');
328
+ return innerSchema ? declaresAbsenceAsAValidState(innerSchema) : false;
329
+ }
330
+ return false;
331
+ }
99
332
  // =============================================================================
100
333
  // ZOD TYPE DETECTION HELPERS
101
334
  //
@@ -132,6 +365,39 @@ export function isZodObject(schema) {
132
365
  export function isZodArray(schema) {
133
366
  return hasZodType(schema, 'array');
134
367
  }
368
+ /**
369
+ * The same array schema with a different ELEMENT, keeping everything else it carries.
370
+ *
371
+ * Use this instead of `z.array(newElement)` whenever an existing array is being rewritten.
372
+ * A fresh `z.array()` is a brand-new schema that carries none of the original's own checks, so
373
+ * every authored `.min()` / `.max()` / `.length()` / `.nonempty()` is silently dropped — the
374
+ * rewritten schema still parses, just without the constraint the author declared. That is
375
+ * invisible to types and to any test that only feeds it valid data.
376
+ *
377
+ * It cost real time to find: all FOUR DTO-boundary walkers (templatable-file stripping,
378
+ * network-boundary coercion, backend-only stripping, summary-field extraction) rebuilt arrays this
379
+ * way, so an authored array constraint never reached the schema the controller and the repository
380
+ * validate against. `reset_to_defaults` depended on exactly such a constraint to reject a request
381
+ * naming no preference block, and without it the route answered HTTP 200 having written nothing.
382
+ *
383
+ * Native `.clone()` re-instantiates from the schema's own definition, so checks, error maps and
384
+ * metadata survive by construction rather than by a copy loop that the next Zod version could
385
+ * outgrow.
386
+ *
387
+ * @param arraySchema - the array being rewritten. Returned untouched when it is not an array or
388
+ * carries no element, so callers do not need to pre-check.
389
+ * @param element - the replacement element schema.
390
+ */
391
+ export function withArrayElement(arraySchema, element) {
392
+ if (!isZodArray(arraySchema)) {
393
+ return arraySchema;
394
+ }
395
+ const def = arraySchema._zod?.def;
396
+ if (!def) {
397
+ return arraySchema;
398
+ }
399
+ return arraySchema.clone({ ...def, element });
400
+ }
135
401
  export function isZodOptional(schema) {
136
402
  return hasZodType(schema, 'optional');
137
403
  }
@@ -421,6 +687,39 @@ export function getZodObjectShape(schema) {
421
687
  const shape = getObjectShape(schema);
422
688
  return shape ?? {};
423
689
  }
690
+ /**
691
+ * Field shape of an object schema OR a discriminated union, merged across ALL
692
+ * variants. Unlike {@link getZodObjectShape} (which returns `{}` for a union),
693
+ * this walks the union's variant options and merges their shapes, so callers that
694
+ * need the full field set of a polymorphic resource — FK/ObjectId discovery,
695
+ * field-access checks, etc. — see every variant's fields instead of nothing.
696
+ *
697
+ * For a field present in multiple variants the FIRST occurrence wins (variants
698
+ * share the common base fields, so the common shape is identical across them).
699
+ * Returns `{}` for non-object, non-union schemas.
700
+ */
701
+ export function getMergedObjectOrUnionShape(schema) {
702
+ if (isZodObject(schema)) {
703
+ return getZodObjectShape(schema);
704
+ }
705
+ if (isDiscriminatedUnion(schema)) {
706
+ const options = getZodDefArrayProperty(schema, 'options');
707
+ if (!options) {
708
+ return {};
709
+ }
710
+ const merged = {};
711
+ for (const option of options) {
712
+ const optionShape = isZodObject(option) ? getZodObjectShape(option) : {};
713
+ for (const [fieldName, fieldSchema] of Object.entries(optionShape)) {
714
+ if (!(fieldName in merged)) {
715
+ merged[fieldName] = fieldSchema;
716
+ }
717
+ }
718
+ }
719
+ return merged;
720
+ }
721
+ return {};
722
+ }
424
723
  export function getZodDef(schema) {
425
724
  if (!hasZodInternals(schema))
426
725
  return undefined;
@@ -536,6 +835,31 @@ export function getFieldUIDisplayConfig(schema) {
536
835
  }
537
836
  return undefined;
538
837
  }
838
+ /**
839
+ * Display-context base params resolved for the SUMMARY context (cards, list
840
+ * items, FK reference previews): `summaryOverride` merged over `display`,
841
+ * key-by-key. A field with no `summaryOverride` behaves exactly as in the
842
+ * regular display context.
843
+ *
844
+ * Summary renderers (the `ResourceSummaryDisplayer` pipeline) call this instead
845
+ * of {@link getFieldUIDisplayConfig}; everything else keeps reading the plain
846
+ * display params. The same merge convention applies to the primitive-specific
847
+ * params (`getEnumUI(s)` consumers merge `{...ui.display, ...ui.summaryOverride}`
848
+ * when rendering a summary surface).
849
+ */
850
+ export function getFieldUISummaryDisplayConfig(schema) {
851
+ const metadata = getCustomFieldMetadata(schema);
852
+ for (const key of UI_DECORATOR_KEYS) {
853
+ const params = metadata[key];
854
+ if (params && typeof params === 'object' && ('display' in params || 'summaryOverride' in params)) {
855
+ const typed = params;
856
+ if (!typed.display && !typed.summaryOverride)
857
+ return undefined;
858
+ return { ...typed.display, ...typed.summaryOverride };
859
+ }
860
+ }
861
+ return undefined;
862
+ }
539
863
  // =============================================================================
540
864
  // TOOLTIP DECORATOR UTILITIES
541
865
  // =============================================================================
@@ -813,6 +1137,40 @@ function stripZodInternalKeys(shape) {
813
1137
  *
814
1138
  * If the schema is wrapped (e.g., with .default()), it will be unwrapped first.
815
1139
  */
1140
+ /**
1141
+ * Resolve ONE field's declared schema from an object-like root, seeing through a discriminated
1142
+ * union.
1143
+ *
1144
+ * `getObjectShape()` deliberately returns `null` for a union, which is correct for its own contract
1145
+ * but useless to a caller that only needs to ask a question about a single named field. Polymorphic
1146
+ * resources (`createSchemaFamily` output) present exactly that shape, so a caller using
1147
+ * `getObjectShape()` alone silently answers "field unknown" for every field of every polymorphic
1148
+ * resource — the failure mode that once left polymorphic form fields unlabelled.
1149
+ *
1150
+ * Union handling returns the FIRST variant that declares the name. Variants of one resource family
1151
+ * share a field's declaration for any field they have in common, so the first match is
1152
+ * representative; a caller needing per-variant precision must narrow the union itself first.
1153
+ *
1154
+ * @param schema - object, or discriminated union of objects (wrappers are unwrapped).
1155
+ * @param fieldName - top-level field name.
1156
+ * @returns the field's declared schema, or `undefined` when no variant declares it.
1157
+ */
1158
+ export function getObjectLikeFieldSchema(schema, fieldName) {
1159
+ const directShape = getObjectShape(schema);
1160
+ if (directShape)
1161
+ return directShape[fieldName];
1162
+ const unwrapped = unwrapSchema(schema);
1163
+ if (!isZodUnion(unwrapped))
1164
+ return undefined;
1165
+ const options = getZodDefArrayProperty(unwrapped, 'options') ?? [];
1166
+ for (const option of options) {
1167
+ const optionShape = getObjectShape(option);
1168
+ const fieldSchema = optionShape?.[fieldName];
1169
+ if (fieldSchema)
1170
+ return fieldSchema;
1171
+ }
1172
+ return undefined;
1173
+ }
816
1174
  export function getObjectShape(schema) {
817
1175
  const unwrapped = unwrapSchema(schema);
818
1176
  if (getZodType(unwrapped) !== 'object') {
@@ -1117,7 +1475,11 @@ export function getForeignKeyParams(schema) {
1117
1475
  }
1118
1476
  /**
1119
1477
  * Get FK UI display params from `FOREIGN_KEY_UI.display`. Returns mode, linkable, displayPresetOverride.
1120
- * Falls back to default `mode: LABEL` if the FK exists but has no explicit UI config.
1478
+ *
1479
+ * When the FK exists but authors no explicit `display` config, `mode` falls back to
1480
+ * {@link DEFAULT_FOREIGN_KEY_DISPLAY_MODE} — the single source for that default. Do not
1481
+ * restate a `ForeignKeyDisplayMode` member here: a second declaration of the
1482
+ * default is how this surface and the display preset came to disagree.
1121
1483
  */
1122
1484
  export function getForeignKeyUIDisplay(schema) {
1123
1485
  if (!isForeignKeyField(schema))
@@ -1125,7 +1487,7 @@ export function getForeignKeyUIDisplay(schema) {
1125
1487
  const metadata = getCustomFieldMetadata(schema);
1126
1488
  const uiParams = metadata[ZodDecorator.FOREIGN_KEY_UI];
1127
1489
  const display = uiParams?.display;
1128
- return { mode: ForeignKeyDisplayMode.LABEL, ...display };
1490
+ return { mode: DEFAULT_FOREIGN_KEY_DISPLAY_MODE, ...display };
1129
1491
  }
1130
1492
  /**
1131
1493
  * Get the polymorphic variants from a foreign key field.
@@ -1135,6 +1497,29 @@ export function getPolymorphicFkVariants(schema) {
1135
1497
  const params = getForeignKeyParams(schema);
1136
1498
  return params?.polymorphicVariants;
1137
1499
  }
1500
+ /**
1501
+ * Resolve the per-record DISCRIMINATED polymorphic FK declaration, if any.
1502
+ *
1503
+ * Returns `{ discriminatorField, variants }` only when the FK is a single
1504
+ * SHARED field pointing at a polymorphic branch group — i.e. BOTH a
1505
+ * `polymorphicDiscriminatorField` AND at least one `polymorphicVariants` entry
1506
+ * are declared. Returns `undefined` for plain FKs and for rename-only
1507
+ * polymorphic FKs (variants present, no discriminator field).
1508
+ *
1509
+ * Consumers (registry FK→type map builders, FK display/link resolution) use
1510
+ * this as the signal that the FK resolves to a different branch PER RECORD: the
1511
+ * holder record's `discriminatorField` value is matched against each variant's
1512
+ * `discriminatorValue` to pick the branch. See
1513
+ * {@link ForeignKeyStructuralParams.polymorphicDiscriminatorField}.
1514
+ */
1515
+ export function getDiscriminatedPolymorphicForeignKey(schema) {
1516
+ const params = getForeignKeyParams(schema);
1517
+ const discriminatorField = params?.polymorphicDiscriminatorField;
1518
+ const variants = params?.polymorphicVariants;
1519
+ if (!discriminatorField || !variants || variants.length === 0)
1520
+ return undefined;
1521
+ return { discriminatorField, variants };
1522
+ }
1138
1523
  /**
1139
1524
  * Check if a schema field is a foreign key (recursive traversal through wrappers).
1140
1525
  */
@@ -1182,11 +1567,171 @@ export function getBackendOnlyParams(schema) {
1182
1567
  return value;
1183
1568
  }
1184
1569
  /**
1185
- * Check if a field has auditAccess enabled via `.isBackendOnly({ auditAccess: true })`.
1186
- * Fields with this flag generate FIELD_ACCESS audit events when included in API responses.
1570
+ * Check if a schema field has the isWriteOnlySecret decorator.
1571
+ */
1572
+ export function isWriteOnlySecretField(schema) {
1573
+ const metadata = getMetadataRecursive(schema);
1574
+ return metadata?.[ZodDecorator.IS_WRITE_ONLY_SECRET] !== undefined;
1575
+ }
1576
+ /**
1577
+ * Read a field's stored impersonalization params, declared via the fluent
1578
+ * `.impersonalizeWith(treatment, options?)` method or the equivalent
1579
+ * `addImpersonalizeWith(schema, treatment, options?)` helper.
1580
+ *
1581
+ * Reads through wrapper types (`getMetadataRecursive` walks outer → inner), so it observes the
1582
+ * declaration whether it was made before or after `.optional()` / `.nullable()` / `.nullish()` /
1583
+ * `.default()`. This matters because the retention guards `safeParse` the OUTER wrapper, and
1584
+ * treatments are commonly declared on `.nullish()` fields (`REMOVE` writes `null`).
1585
+ *
1586
+ * The decorator chokepoint normalizes both authoring forms to the params object, so the stored
1587
+ * value is always an object. The bare-string branch below is a defensive read for metadata written
1588
+ * by any path that bypassed the chokepoint — it keeps a legal treatment legible rather than
1589
+ * silently reading as UNTREATED, which is the PII-leak failure mode this decorator exists to
1590
+ * prevent.
1591
+ */
1592
+ /**
1593
+ * Read a field's declared portability provenance — where its value came from — set via the fluent
1594
+ * `.portabilityProvenance(provenance)` method or `addPortabilityProvenance(schema, provenance)`.
1595
+ *
1596
+ * Returns the raw string; the saas-models layer types it as `DataPortabilityProvenance`
1597
+ * (zod-decorators sits below saas-models and cannot import the enum).
1598
+ *
1599
+ * Reads through wrapper types (`getMetadataRecursive` walks outer → inner), so it observes the
1600
+ * declaration whether it was made before or after `.optional()` / `.nullable()` / `.nullish()` /
1601
+ * `.default()` — the same wrapper-transparency `getImpersonalizeTreatment` needs, and for the same
1602
+ * reason: personal-data fields are commonly optional.
1603
+ *
1604
+ * `undefined` means the field carries NO declaration of its own. That is not an error — the field
1605
+ * inherits its resource's `portabilityPolicy.defaultProvenance`, and only if THAT is absent too
1606
+ * does an Art. 20 export refuse. Resolving that fallback chain belongs to the resource factory,
1607
+ * which knows the resource; this reader answers only "did the field itself say?".
1608
+ */
1609
+ export function getPortabilityProvenance(schema) {
1610
+ const metadata = getMetadataRecursive(schema);
1611
+ const value = metadata?.[ZodDecorator.PORTABILITY_PROVENANCE];
1612
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
1613
+ }
1614
+ /**
1615
+ * WHICH KINDS of personal data does this field hold? Returns the declared `PersonalDataCategory`
1616
+ * values (as opaque strings — this package sits below `saas-models` and cannot import the enum),
1617
+ * or `undefined` when the field carries no declaration.
1618
+ *
1619
+ * Set via the fluent `.dataCategories(...)` method or `addDataCategories(schema, ...)`. Always an
1620
+ * ARRAY, deduped and non-empty: the decorator chokepoint normalizes a single-value authoring form
1621
+ * on the way in, so a caller never has to handle two shapes.
1622
+ *
1623
+ * Reads through wrapper types (`getMetadataRecursive` walks outer → inner), so it observes the
1624
+ * declaration whether it was written before or after `.optional()` / `.nullable()` / `.nullish()` /
1625
+ * `.default()`. An optional field is the common shape for exactly the data a notice enumerates, so
1626
+ * a reader that missed the wrapper would under-report — the silent direction.
1627
+ *
1628
+ * `undefined` is the correct and common answer: most fields hold no personal data and carry no
1629
+ * declaration. It is ABSENCE, not an empty array, that the compliance gap report reads — the
1630
+ * chokepoint refuses to store an empty declaration for that reason.
1631
+ *
1632
+ * @wildo_source:part:start zod.decorators.data-categories facet:layer:shared facet:family:decorator facet:kind:api facet:audience:app-developer
1633
+ */
1634
+ export function getDataCategories(schema) {
1635
+ const metadata = getMetadataRecursive(schema);
1636
+ const value = metadata?.[ZodDecorator.DATA_CATEGORIES];
1637
+ return Array.isArray(value) && value.length > 0 ? value : undefined;
1638
+ }
1639
+ /** @wildo_source:part:end zod.decorators.data-categories */
1640
+ /**
1641
+ * Is this field's VALUE recorded on the resource-operation audit event when an operation changes
1642
+ * it? Set via the fluent `.isAuditEvidence()` method or `addAuditEvidence(schema)`.
1643
+ *
1644
+ * Reads through wrapper types (`getMetadataRecursive` walks outer → inner), so it observes the
1645
+ * marker whether it was declared before or after `.optional()` / `.nullable()` / `.nullish()` /
1646
+ * `.default()`. That matters here in both directions: a nullable `suspendedUntil` is exactly the
1647
+ * kind of lifecycle field worth marking, and a marker the reader missed would silently drop the
1648
+ * evidence rather than fail.
1649
+ *
1650
+ * `false` is the correct and common answer — the audit event still records that the field CHANGED
1651
+ * (by name); only its before/after VALUES are withheld. Withholding is the default on purpose:
1652
+ * audit rows reach the immutable ledger, which cannot be scrubbed.
1653
+ *
1654
+ * ⚠️ This is the WRITE-side question. The READ-side one ("was this value disclosed?") is answered
1655
+ * only by `isBackendOnly({ isReturnedAtCreation: true })`, which fires a `FIELD_ACCESS` event when a
1656
+ * server-minted secret is first exposed. The two are independent claims; neither implies the other.
1187
1657
  */
1188
- export function isAuditAccessField(schema) {
1189
- return getBackendOnlyParams(schema)?.auditAccess === true;
1658
+ export function isAuditEvidenceField(schema) {
1659
+ const metadata = getMetadataRecursive(schema);
1660
+ return metadata?.[ZodDecorator.IS_AUDIT_EVIDENCE] === true;
1661
+ }
1662
+ function getImpersonalizeParams(schema) {
1663
+ const metadata = getMetadataRecursive(schema);
1664
+ const value = metadata?.[ZodDecorator.IMPERSONALIZE_WITH];
1665
+ if (typeof value === 'string')
1666
+ return { treatment: value };
1667
+ if (value !== null && typeof value === 'object' && typeof value.treatment === 'string') {
1668
+ return value;
1669
+ }
1670
+ return undefined;
1671
+ }
1672
+ /**
1673
+ * Get a field's impersonalization scrub treatment (a `RedactionType` value). Returns the raw
1674
+ * string; the saas-models layer types it as `RedactionType` (zod-decorators is below saas-models
1675
+ * and cannot import it). `undefined` means the field carries no treatment and is left intact.
1676
+ */
1677
+ export function getImpersonalizeTreatment(schema) {
1678
+ return getImpersonalizeParams(schema)?.treatment;
1679
+ }
1680
+ /**
1681
+ * @wildo_source:part:start zod.decorators.rag-source facet:layer:shared facet:family:decorator facet:kind:api facet:audience:app-developer
1682
+ *
1683
+ * Read a field's stored RAG-source configuration, declared via the fluent `.ragSource(config?)`
1684
+ * method, the `addRagSource(schema, config?)` helper, or `z_file({ ragSource })`.
1685
+ *
1686
+ * Import from `@wildo-ai/zod-decorators`. Use it in custom implementations or checks that need to
1687
+ * ask "is this field part of the retrieval corpus?" — never to re-derive the corpus itself, which
1688
+ * the framework already does.
1689
+ *
1690
+ * `undefined` means the field is NOT a retrieval-corpus source — presence of the returned object is
1691
+ * the declaration itself (there is no `enabled` flag), and a bare `.ragSource()` reads back as `{}`
1692
+ * because the decorator chokepoint normalizes an omitted config to the empty object.
1693
+ *
1694
+ * Reads through wrapper types (`getMetadataRecursive` walks outer → inner), so it observes the
1695
+ * declaration whether it was made before or after `.optional()` / `.nullable()` / `.nullish()` /
1696
+ * `.default()` — corpus-source fields (descriptions, knowledge bodies) are routinely optional.
1697
+ * This is the ONE detection door: `RagSourceFieldDetection` in the backend walks resource schemas
1698
+ * with it, uniformly for plain string fields and `z_file()` fields.
1699
+ */
1700
+ export function getRagSourceConfig(schema) {
1701
+ const metadata = getMetadataRecursive(schema);
1702
+ // Typed as the normalized object, but read defensively as `unknown`: metadata written by any
1703
+ // path that bypassed the chokepoint's normalization (a raw `true` sentinel) must still read as
1704
+ // "declared, default config" rather than silently reading as NOT-a-source, which would drop the
1705
+ // field from every downstream seam (detection, chunk store, erasure).
1706
+ const value = metadata?.[ZodDecorator.RAG_SOURCE];
1707
+ if (value === undefined || value === null || value === false)
1708
+ return undefined;
1709
+ return typeof value === 'object' ? value : {};
1710
+ }
1711
+ /** @wildo_source:part:end zod.decorators.rag-source */
1712
+ /**
1713
+ * Get a field's `MASK` override value — the literal written in place of the field, instead of the
1714
+ * framework mask constant. `undefined` means "use the constant".
1715
+ *
1716
+ * Authored for CONSTRAINED string fields (`z.email()`, `z.uuid()`, `.min(n)`) that the default
1717
+ * constant cannot satisfy. See {@link ImpersonalizeFieldParams} for the contract; the saas-models
1718
+ * boot guard validates the value against the field's own schema and rejects it on a non-`MASK`
1719
+ * treatment.
1720
+ */
1721
+ export function getImpersonalizeMaskValue(schema) {
1722
+ return getImpersonalizeParams(schema)?.maskValue;
1723
+ }
1724
+ /**
1725
+ * Get the isWriteOnlySecret decorator parameters.
1726
+ */
1727
+ export function getWriteOnlySecretParams(schema) {
1728
+ const metadata = getMetadataRecursive(schema);
1729
+ const value = metadata?.[ZodDecorator.IS_WRITE_ONLY_SECRET];
1730
+ if (value === undefined)
1731
+ return undefined;
1732
+ if (value === true)
1733
+ return {}; // Default params when decorator is used without params
1734
+ return value;
1190
1735
  }
1191
1736
  /**
1192
1737
  * Check if a schema field has the isDiscriminator decorator.
@@ -1288,6 +1833,55 @@ export function isPrimaryKeyField(schema) {
1288
1833
  const metadata = getMetadataRecursive(schema);
1289
1834
  return metadata?.[ZodDecorator.IS_PRIMARY_KEY] === true;
1290
1835
  }
1836
+ // =============================================================================
1837
+ // BUSINESS KEY HELPERS (data-seeding subsystem)
1838
+ // =============================================================================
1839
+ /**
1840
+ * Check if a schema field has the `isBusinessKey` decorator (recursive
1841
+ * traversal through wrappers — `optional`, `nullable`, etc.).
1842
+ *
1843
+ * A business key is the **declaration-side identity** used by the data-seeding
1844
+ * subsystem to upsert / upgrade / sync seeded rows independently from the
1845
+ * random `_id` (`isPrimaryKey`). See `.cursor/plans/seeding-first-class.md`
1846
+ * §3.3 for the semantic contract.
1847
+ */
1848
+ export function isBusinessKeyField(schema) {
1849
+ const metadata = getMetadataRecursive(schema);
1850
+ return metadata?.[ZodDecorator.IS_BUSINESS_KEY] === true;
1851
+ }
1852
+ /**
1853
+ * Read the composite business key declared on an object schema via
1854
+ * `businessKeySet([...])` (object-level decorator). Returns the ordered
1855
+ * list of field names, or `undefined` if no composite key is declared.
1856
+ *
1857
+ * The composite key is the schema-level analog of `isBusinessKey` — used
1858
+ * when identity is `(orgId, templateKey)` rather than a single field. See
1859
+ * `.cursor/plans/seeding-first-class.md` §3.3.
1860
+ */
1861
+ export function getBusinessKeySet(schema) {
1862
+ return getBusinessKeySetParams(schema)?.fields;
1863
+ }
1864
+ /**
1865
+ * Read the FULL composite-business-key declaration, not just its field list.
1866
+ *
1867
+ * `getBusinessKeySet` answers "what identifies a row" and is all the seeding filter builder needs.
1868
+ * The storage adapter needs more: which members take a row out of the constraint when absent
1869
+ * (`skipRowsMissing`), and which error the API should raise when the resulting index is violated
1870
+ * (`conflictMessageReference`). Both are properties OF the key, so they are read from the same
1871
+ * declaration rather than re-derived — a second source would be free to disagree with the first.
1872
+ */
1873
+ export function getBusinessKeySetParams(schema) {
1874
+ const metadata = getMetadataRecursive(schema);
1875
+ const params = metadata?.[ZodDecorator.OBJECT_BUSINESS_KEY_SET];
1876
+ return params?.fields ? params : undefined;
1877
+ }
1878
+ /**
1879
+ * Convenience predicate: does the schema declare any business key
1880
+ * identity (single field-level marker OR composite object-level set)?
1881
+ */
1882
+ export function hasBusinessKey(schema) {
1883
+ return isBusinessKeyField(schema) || getBusinessKeySet(schema) !== undefined;
1884
+ }
1291
1885
  /**
1292
1886
  * Get all metadata for a schema field (non-recursive, direct access only).
1293
1887
  */
@@ -1594,6 +2188,17 @@ export function isFileSchema(schema) {
1594
2188
  }
1595
2189
  return false;
1596
2190
  }
2191
+ /**
2192
+ * Check if a file schema is generated from a backend-bound PDF template.
2193
+ *
2194
+ * This helper intentionally keys off frontend-safe metadata only. Runtime
2195
+ * template objects are bound later by backend resource configuration and must
2196
+ * not leak into shared schema metadata.
2197
+ */
2198
+ export function isTemplatableFileField(schema) {
2199
+ const fileMetadata = getFileMetadata(schema);
2200
+ return fileMetadata?.generation?.source === FileGenerationSource.PDF_TEMPLATE;
2201
+ }
1597
2202
  /**
1598
2203
  * Get file schema metadata if this is a file schema.
1599
2204
  * Checks both the schema directly and the unwrapped inner schema.
@@ -2145,7 +2750,17 @@ export function serializeZodSchema(schema, seen = new Set()) {
2145
2750
  case 'unknown':
2146
2751
  return withOptionals(withMeta({ type: 'unknown' }), optional, nullable);
2147
2752
  case 'literal': {
2148
- const value = getZodDefProperty(inner, 'value');
2753
+ // In Zod 4 a literal's value lives in def.values (an ARRAY — z.literal(['a','b'])
2754
+ // is legal in v4); the legacy Zod-3 def.value is always undefined in v4. Reading
2755
+ // def.value alone emitted { type: 'literal', value: undefined } for EVERY literal,
2756
+ // which then round-tripped through deserializeZodSchema as z.literal(undefined).
2757
+ // Read def.values first, fall back to def.value, and take the first non-nullish
2758
+ // entry — preserving string/number/boolean literals. (A bare z.literal(null) has no
2759
+ // non-nullish entry and legitimately serializes as value: undefined.) Mirrors the
2760
+ // Zod-4 literal read in zod-to-mongoose-schema-convertor.backend.service.ts.
2761
+ const literalValues = (getZodDefArrayProperty(inner, 'values') ?? [getZodDefProperty(inner, 'value')])
2762
+ .filter((entry) => entry !== undefined && entry !== null);
2763
+ const value = literalValues[0];
2149
2764
  return withOptionals(withMeta({ type: 'literal', value }), optional, nullable);
2150
2765
  }
2151
2766
  case 'enum': {
@@ -2644,4 +3259,4 @@ export function getObjectUI(schema) {
2644
3259
  export function hasObjectUI(schema) {
2645
3260
  return hasDecorator(schema, ZodDecorator.OBJECT_UI);
2646
3261
  }
2647
- //# sourceMappingURL=zod-utils.shared.js.map
3262
+ //# sourceMappingURL=zod-utils.shared.js.map