@wildo-ai/zod-decorators 1.0.1 → 1.1.0
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.
- package/LICENSE +34 -0
- package/dist/esm/.builder.pid +9 -0
- package/dist/esm/__mocks__/mime.d.ts.map +1 -1
- package/dist/esm/__mocks__/mime.js +1 -1
- package/dist/esm/__mocks__/mime.js.map +1 -1
- package/dist/esm/commons.d.ts.map +1 -1
- package/dist/esm/commons.js +1 -1
- package/dist/esm/commons.js.map +1 -1
- package/dist/esm/companion-exports.d.ts +8 -0
- package/dist/esm/companion-exports.d.ts.map +1 -0
- package/dist/esm/companion-exports.js +8 -0
- package/dist/esm/companion-exports.js.map +1 -0
- package/dist/esm/currencies.shared.schemas.d.ts.map +1 -1
- package/dist/esm/currencies.shared.schemas.js +1 -1
- package/dist/esm/currencies.shared.schemas.js.map +1 -1
- package/dist/esm/files/agent-file-formats.d.ts.map +1 -1
- package/dist/esm/files/agent-file-formats.js +1 -1
- package/dist/esm/files/agent-file-formats.js.map +1 -1
- package/dist/esm/files/extension-nature-map.d.ts +1 -1
- package/dist/esm/files/extension-nature-map.d.ts.map +1 -1
- package/dist/esm/files/extension-nature-map.js +1 -1
- package/dist/esm/files/extension-nature-map.js.map +1 -1
- package/dist/esm/files/file-extensions.enum.d.ts.map +1 -1
- package/dist/esm/files/file-extensions.enum.js +1 -1
- package/dist/esm/files/file-extensions.enum.js.map +1 -1
- package/dist/esm/files/mime-registry.d.ts +1 -1
- package/dist/esm/files/mime-registry.d.ts.map +1 -1
- package/dist/esm/files/mime-registry.js +1 -1
- package/dist/esm/files/mime-registry.js.map +1 -1
- package/dist/esm/files/unified-registry.d.ts +1 -1
- package/dist/esm/files/unified-registry.d.ts.map +1 -1
- package/dist/esm/files/unified-registry.js +1 -1
- package/dist/esm/files/unified-registry.js.map +1 -1
- package/dist/esm/index.d.ts +20 -19
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +10 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/install-bigint-json-encoding.d.ts +80 -0
- package/dist/esm/install-bigint-json-encoding.d.ts.map +1 -0
- package/dist/esm/install-bigint-json-encoding.js +85 -0
- package/dist/esm/install-bigint-json-encoding.js.map +1 -0
- package/dist/esm/testing/decorated-test-schemas.d.ts +8 -8
- package/dist/esm/testing/decorated-test-schemas.d.ts.map +1 -1
- package/dist/esm/testing/decorated-test-schemas.js +1 -1
- package/dist/esm/testing/decorated-test-schemas.js.map +1 -1
- package/dist/esm/testing.d.ts +1 -1
- package/dist/esm/testing.d.ts.map +1 -1
- package/dist/esm/testing.js +1 -1
- package/dist/esm/testing.js.map +1 -1
- package/dist/esm/zod-bigdecimal.d.ts +5 -1
- package/dist/esm/zod-bigdecimal.d.ts.map +1 -1
- package/dist/esm/zod-bigdecimal.js +4 -1
- package/dist/esm/zod-bigdecimal.js.map +1 -1
- package/dist/esm/zod-code.d.ts +4 -1
- package/dist/esm/zod-code.d.ts.map +1 -1
- package/dist/esm/zod-code.js +4 -1
- package/dist/esm/zod-code.js.map +1 -1
- package/dist/esm/zod-color.d.ts.map +1 -1
- package/dist/esm/zod-color.js +1 -1
- package/dist/esm/zod-color.js.map +1 -1
- package/dist/esm/zod-decorators.types.d.ts +1410 -56
- package/dist/esm/zod-decorators.types.d.ts.map +1 -1
- package/dist/esm/zod-decorators.types.js +662 -16
- package/dist/esm/zod-decorators.types.js.map +1 -1
- package/dist/esm/zod-duration.d.ts +8 -4
- package/dist/esm/zod-duration.d.ts.map +1 -1
- package/dist/esm/zod-duration.js +2 -1
- package/dist/esm/zod-duration.js.map +1 -1
- package/dist/esm/zod-file.d.ts +75 -14
- package/dist/esm/zod-file.d.ts.map +1 -1
- package/dist/esm/zod-file.js +151 -8
- package/dist/esm/zod-file.js.map +1 -1
- package/dist/esm/zod-money.d.ts +18 -3
- package/dist/esm/zod-money.d.ts.map +1 -1
- package/dist/esm/zod-money.js +4 -1
- package/dist/esm/zod-money.js.map +1 -1
- package/dist/esm/zod-phone.d.ts +37 -1
- package/dist/esm/zod-phone.d.ts.map +1 -1
- package/dist/esm/zod-phone.js +66 -5
- package/dist/esm/zod-phone.js.map +1 -1
- package/dist/esm/zod-range.d.ts +4 -1
- package/dist/esm/zod-range.d.ts.map +1 -1
- package/dist/esm/zod-range.js +4 -1
- package/dist/esm/zod-range.js.map +1 -1
- package/dist/esm/zod-utils.shared.d.ts +411 -6
- package/dist/esm/zod-utils.shared.d.ts.map +1 -1
- package/dist/esm/zod-utils.shared.js +624 -9
- package/dist/esm/zod-utils.shared.js.map +1 -1
- package/dist/esm/zod-versioned.d.ts +1 -1
- package/dist/esm/zod-versioned.d.ts.map +1 -1
- package/dist/esm/zod-versioned.js +1 -1
- package/dist/esm/zod-versioned.js.map +1 -1
- package/dist/esm/zod.module-override.d.ts +458 -68
- package/dist/esm/zod.module-override.d.ts.map +1 -1
- package/dist/esm/zod.module-override.js +1214 -195
- package/dist/esm/zod.module-override.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +20 -11
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
1186
|
-
|
|
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
|
|
1189
|
-
|
|
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
|
-
|
|
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
|