@plasius/schema 1.0.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/.eslintrc.cjs +7 -0
- package/.github/workflows/cd.yml +54 -0
- package/.github/workflows/ci.yml +16 -0
- package/.vscode/launch.json +15 -0
- package/CODE_OF_CONDUCT.md +79 -0
- package/CONTRIBUTORS.md +27 -0
- package/LICENSE +203 -0
- package/README.md +45 -0
- package/SECURITY.md +17 -0
- package/legal/CLA-REGISTRY.csv +2 -0
- package/legal/CLA.md +22 -0
- package/legal/CORPORATE_CLA.md +55 -0
- package/legal/INDIVIDUAL_CLA.md +91 -0
- package/package.json +48 -0
- package/src/components.ts +39 -0
- package/src/field.builder.ts +119 -0
- package/src/field.ts +14 -0
- package/src/index.ts +7 -0
- package/src/infer.ts +34 -0
- package/src/pii.ts +165 -0
- package/src/schema.ts +757 -0
- package/src/types.ts +156 -0
- package/src/validation/countryCode.ISO3166.ts +256 -0
- package/src/validation/currencyCode.ISO4217.ts +191 -0
- package/src/validation/dateTime.ISO8601.ts +9 -0
- package/src/validation/email.RFC5322.ts +9 -0
- package/src/validation/generalText.OWASP.ts +39 -0
- package/src/validation/index.ts +13 -0
- package/src/validation/name.OWASP.ts +25 -0
- package/src/validation/percentage.ISO80000-1.ts +8 -0
- package/src/validation/phone.E.164.ts +9 -0
- package/src/validation/richtext.OWASP.ts +34 -0
- package/src/validation/url.WHATWG.ts +16 -0
- package/src/validation/user.MS-GOOGLE-APPLE.ts +31 -0
- package/src/validation/uuid.RFC4122.ts +10 -0
- package/src/validation/version.SEMVER2.0.0.ts +8 -0
- package/tests/pii.test.ts +139 -0
- package/tests/schema.test.ts +501 -0
- package/tests/test-utils.ts +97 -0
- package/tests/validate.test.ts +97 -0
- package/tests/validation.test.ts +98 -0
- package/tsconfig.build.json +19 -0
- package/tsconfig.json +7 -0
- package/tsup.config.ts +10 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { Infer } from "./infer.js";
|
|
2
|
+
import { FieldType, Schema, SchemaOptions, SchemaShape } from "./types.js";
|
|
3
|
+
import { field } from "./field.js";
|
|
4
|
+
import { PIIAction, PIIClassification, PIILogHandling } from "./pii.js";
|
|
5
|
+
import {
|
|
6
|
+
enforcePIIField,
|
|
7
|
+
prepareForStorage as piiPrepareForStorage,
|
|
8
|
+
prepareForRead as piiPrepareForRead,
|
|
9
|
+
sanitizeForLog as piiSanitizeForLog,
|
|
10
|
+
getPiiAudit as piiGetPiiAudit,
|
|
11
|
+
scrubPiiForDelete as piiScrubPiiForDelete,
|
|
12
|
+
} from "./pii.js";
|
|
13
|
+
import { FieldBuilder } from "./field.builder.js";
|
|
14
|
+
|
|
15
|
+
const globalSchemaRegistry = new Map<string, Schema<any>>();
|
|
16
|
+
|
|
17
|
+
function validateEnum(
|
|
18
|
+
parentKey: string,
|
|
19
|
+
value: any,
|
|
20
|
+
enumValues?: Array<string | number>
|
|
21
|
+
): string | undefined {
|
|
22
|
+
if (!enumValues) return;
|
|
23
|
+
|
|
24
|
+
const values = Array.isArray(enumValues)
|
|
25
|
+
? enumValues
|
|
26
|
+
: Array.from(enumValues);
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
const invalid = value.filter((v) => !values.includes(v));
|
|
30
|
+
if (invalid.length > 0) {
|
|
31
|
+
return `Field ${parentKey} contains invalid enum values: ${invalid.join(
|
|
32
|
+
", "
|
|
33
|
+
)}`;
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
if (!values.includes(value)) {
|
|
37
|
+
return `Field ${parentKey} must be one of: ${values.join(", ")}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Helper: some builders may store enum/optional/validator under different keys
|
|
43
|
+
// schema.ts
|
|
44
|
+
function getEnumValues(def: any): Array<string | number> | undefined {
|
|
45
|
+
const src = Array.isArray(def?.enum)
|
|
46
|
+
? def.enum
|
|
47
|
+
: Array.isArray(def?.enumValues)
|
|
48
|
+
? def.enumValues
|
|
49
|
+
: Array.isArray(def?._enum)
|
|
50
|
+
? def._enum
|
|
51
|
+
: Array.isArray(def?._enumValues)
|
|
52
|
+
? def._enumValues
|
|
53
|
+
: def?._enumSet instanceof Set
|
|
54
|
+
? Array.from(def._enumSet)
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
if (!src) return undefined;
|
|
58
|
+
const ok = src.every(
|
|
59
|
+
(v: unknown) => typeof v === "string" || typeof v === "number"
|
|
60
|
+
);
|
|
61
|
+
return ok ? (src as Array<string | number>) : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isOptional(def: any): boolean {
|
|
65
|
+
return (def?.isRequired ?? false) === false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getValidator(def: any): ((v: any) => boolean) | undefined {
|
|
69
|
+
return def?._validator ?? undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getShape(def: any): Record<string, any> | undefined {
|
|
73
|
+
return def?._shape ?? def?.shape ?? undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
// Validation helpers (extracted from validate())
|
|
78
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function checkMissingRequired(
|
|
81
|
+
parentKey: string,
|
|
82
|
+
key: string,
|
|
83
|
+
value: any,
|
|
84
|
+
def: any,
|
|
85
|
+
errors: string[]
|
|
86
|
+
): { missing: boolean } {
|
|
87
|
+
if (value === undefined || value === null) {
|
|
88
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
89
|
+
if (!isOptional(def)) {
|
|
90
|
+
errors.push(`Missing required field: ${path}`);
|
|
91
|
+
}
|
|
92
|
+
return { missing: true };
|
|
93
|
+
}
|
|
94
|
+
return { missing: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkImmutable(
|
|
98
|
+
parentKey: string,
|
|
99
|
+
key: string,
|
|
100
|
+
value: any,
|
|
101
|
+
def: any,
|
|
102
|
+
existing: Record<string, any> | undefined,
|
|
103
|
+
errors: string[]
|
|
104
|
+
): { immutableViolation: boolean } {
|
|
105
|
+
if (
|
|
106
|
+
def.isImmutable &&
|
|
107
|
+
existing &&
|
|
108
|
+
existing[key] !== undefined &&
|
|
109
|
+
value !== existing[key]
|
|
110
|
+
) {
|
|
111
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
112
|
+
errors.push(`Field is immutable: ${path}`);
|
|
113
|
+
return { immutableViolation: true };
|
|
114
|
+
}
|
|
115
|
+
return { immutableViolation: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runCustomValidator(
|
|
119
|
+
parentKey: string,
|
|
120
|
+
key: string,
|
|
121
|
+
value: any,
|
|
122
|
+
def: any,
|
|
123
|
+
errors: string[]
|
|
124
|
+
): { invalid: boolean } {
|
|
125
|
+
const validator = getValidator(def);
|
|
126
|
+
if (validator && value !== undefined && value !== null) {
|
|
127
|
+
const valid = validator(value);
|
|
128
|
+
if (!valid) {
|
|
129
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
130
|
+
errors.push(`Invalid value for field: ${path}`);
|
|
131
|
+
return { invalid: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { invalid: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateStringField(
|
|
138
|
+
parentKey: string,
|
|
139
|
+
key: string,
|
|
140
|
+
value: any,
|
|
141
|
+
def: any,
|
|
142
|
+
errors: string[]
|
|
143
|
+
) {
|
|
144
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
145
|
+
if (typeof value !== "string") {
|
|
146
|
+
errors.push(`Field ${path} must be string`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const enumValues = getEnumValues(def);
|
|
151
|
+
if (Array.isArray(enumValues)) {
|
|
152
|
+
const enumError = validateEnum(path, value, enumValues);
|
|
153
|
+
if (enumError) {
|
|
154
|
+
errors.push(enumError);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validateNumberField(
|
|
160
|
+
parentKey: string,
|
|
161
|
+
key: string,
|
|
162
|
+
value: any,
|
|
163
|
+
_def: any,
|
|
164
|
+
errors: string[]
|
|
165
|
+
) {
|
|
166
|
+
const enumPath = parentKey ? `${parentKey}.${key}` : key;
|
|
167
|
+
if (typeof value !== "number") {
|
|
168
|
+
errors.push(`Field ${enumPath} must be number`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validateBooleanField(
|
|
173
|
+
parentKey: string,
|
|
174
|
+
key: string,
|
|
175
|
+
value: any,
|
|
176
|
+
_def: any,
|
|
177
|
+
errors: string[]
|
|
178
|
+
) {
|
|
179
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
180
|
+
if (typeof value !== "boolean") {
|
|
181
|
+
errors.push(`Field ${path} must be boolean`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateObjectChildren(
|
|
186
|
+
parentKey: string,
|
|
187
|
+
obj: any,
|
|
188
|
+
shape: Record<string, any>,
|
|
189
|
+
errors: string[]
|
|
190
|
+
) {
|
|
191
|
+
for (const [childKey, childDef] of Object.entries(shape) as [
|
|
192
|
+
string,
|
|
193
|
+
FieldBuilder<any>
|
|
194
|
+
][]) {
|
|
195
|
+
const childValue = obj[childKey];
|
|
196
|
+
|
|
197
|
+
// Required check
|
|
198
|
+
const { missing } = checkMissingRequired(
|
|
199
|
+
parentKey,
|
|
200
|
+
childKey,
|
|
201
|
+
childValue,
|
|
202
|
+
childDef,
|
|
203
|
+
errors
|
|
204
|
+
);
|
|
205
|
+
if (missing) continue;
|
|
206
|
+
|
|
207
|
+
// Custom validator (per-field)
|
|
208
|
+
const { invalid } = runCustomValidator(
|
|
209
|
+
parentKey,
|
|
210
|
+
childKey,
|
|
211
|
+
childValue,
|
|
212
|
+
childDef,
|
|
213
|
+
errors
|
|
214
|
+
);
|
|
215
|
+
if (invalid) continue;
|
|
216
|
+
|
|
217
|
+
// Type-specific validation (string/number/boolean/object/array/ref)
|
|
218
|
+
// This will recurse into nested objects via validateObjectField → validateObjectChildren
|
|
219
|
+
validateByType(parentKey, childKey, childValue, childDef, errors);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateObjectField(
|
|
224
|
+
parentKey: string,
|
|
225
|
+
key: string,
|
|
226
|
+
value: any,
|
|
227
|
+
def: any,
|
|
228
|
+
errors: string[]
|
|
229
|
+
) {
|
|
230
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
231
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
232
|
+
errors.push(`Field ${path} must be object`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const objShape = getShape(def);
|
|
236
|
+
if (objShape) validateObjectChildren(path, value, objShape, errors);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function validateArrayOfStrings(
|
|
240
|
+
parentKey: string,
|
|
241
|
+
key: string,
|
|
242
|
+
arr: any[],
|
|
243
|
+
itemDef: any,
|
|
244
|
+
errors: string[]
|
|
245
|
+
) {
|
|
246
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
247
|
+
if (!arr.every((v) => typeof v === "string")) {
|
|
248
|
+
errors.push(`Field ${path} must be string[]`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const enumValues = getEnumValues(itemDef);
|
|
252
|
+
if (Array.isArray(enumValues)) {
|
|
253
|
+
const enumError = validateEnum(path, arr, enumValues);
|
|
254
|
+
if (enumError) {
|
|
255
|
+
errors.push(enumError);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateArrayOfNumbers(
|
|
261
|
+
parentKey: string,
|
|
262
|
+
key: string,
|
|
263
|
+
arr: any[],
|
|
264
|
+
itemDef: any,
|
|
265
|
+
errors: string[]
|
|
266
|
+
) {
|
|
267
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
268
|
+
if (!arr.every((v) => typeof v === "number")) {
|
|
269
|
+
errors.push(`Field ${path} must be number[]`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const enumValues = getEnumValues(itemDef);
|
|
273
|
+
if (Array.isArray(enumValues)) {
|
|
274
|
+
const enumError = validateEnum(path, arr, enumValues);
|
|
275
|
+
if (enumError) {
|
|
276
|
+
errors.push(enumError);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validateArrayOfBooleans(
|
|
282
|
+
parentKey: string,
|
|
283
|
+
key: string,
|
|
284
|
+
arr: any[],
|
|
285
|
+
_itemDef: any,
|
|
286
|
+
errors: string[]
|
|
287
|
+
) {
|
|
288
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
289
|
+
if (!arr.every((v) => typeof v === "boolean")) {
|
|
290
|
+
errors.push(`Field ${path} must be boolean[]`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateArrayOfObjects(
|
|
295
|
+
parentKey: string,
|
|
296
|
+
key: string,
|
|
297
|
+
arr: any[],
|
|
298
|
+
itemDef: any,
|
|
299
|
+
errors: string[]
|
|
300
|
+
) {
|
|
301
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
!Array.isArray(arr) ||
|
|
305
|
+
!arr.every((v) => typeof v === "object" && v !== null && !Array.isArray(v))
|
|
306
|
+
) {
|
|
307
|
+
errors.push(`Field ${path} must be object[]`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const itemShape = getShape(itemDef);
|
|
312
|
+
if (!itemShape) return;
|
|
313
|
+
|
|
314
|
+
arr.forEach((item, idx) => {
|
|
315
|
+
const itemParent = `${path}[${idx}]`;
|
|
316
|
+
for (const [childKey, childDef] of Object.entries(itemShape) as [
|
|
317
|
+
string,
|
|
318
|
+
FieldBuilder<any>
|
|
319
|
+
][]) {
|
|
320
|
+
const childValue = (item as any)[childKey];
|
|
321
|
+
|
|
322
|
+
// Required check (path-aware)
|
|
323
|
+
const { missing } = checkMissingRequired(
|
|
324
|
+
itemParent,
|
|
325
|
+
childKey,
|
|
326
|
+
childValue,
|
|
327
|
+
childDef,
|
|
328
|
+
errors
|
|
329
|
+
);
|
|
330
|
+
if (missing) continue;
|
|
331
|
+
|
|
332
|
+
// Custom validator (path-aware)
|
|
333
|
+
const { invalid } = runCustomValidator(
|
|
334
|
+
itemParent,
|
|
335
|
+
childKey,
|
|
336
|
+
childValue,
|
|
337
|
+
childDef,
|
|
338
|
+
errors
|
|
339
|
+
);
|
|
340
|
+
if (invalid) continue;
|
|
341
|
+
|
|
342
|
+
// Type-specific validation (recurses into nested object/array/ref)
|
|
343
|
+
validateByType(itemParent, childKey, childValue, childDef, errors);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function validateArrayField(
|
|
349
|
+
parentKey: string,
|
|
350
|
+
key: string,
|
|
351
|
+
value: any,
|
|
352
|
+
def: any,
|
|
353
|
+
errors: string[]
|
|
354
|
+
) {
|
|
355
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
356
|
+
if (!Array.isArray(value)) {
|
|
357
|
+
errors.push(`Field ${key} must be an array`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const itemType = def.itemType?.type;
|
|
361
|
+
if (itemType === "string")
|
|
362
|
+
return validateArrayOfStrings(parentKey, key, value, def.itemType, errors);
|
|
363
|
+
if (itemType === "number")
|
|
364
|
+
return validateArrayOfNumbers(parentKey, key, value, def.itemType, errors);
|
|
365
|
+
if (itemType === "boolean")
|
|
366
|
+
return validateArrayOfBooleans(parentKey, key, value, def.itemType, errors);
|
|
367
|
+
if (itemType === "object")
|
|
368
|
+
return validateArrayOfObjects(parentKey, key, value, def.itemType, errors);
|
|
369
|
+
if (itemType === "ref") {
|
|
370
|
+
const expectedType = (def.itemType as any).refType;
|
|
371
|
+
value.forEach((ref: any, idx: number) => {
|
|
372
|
+
if (
|
|
373
|
+
!ref ||
|
|
374
|
+
typeof ref !== "object" ||
|
|
375
|
+
ref === null ||
|
|
376
|
+
typeof ref.type !== "string" ||
|
|
377
|
+
typeof ref.id !== "string" ||
|
|
378
|
+
(expectedType && ref.type !== expectedType)
|
|
379
|
+
) {
|
|
380
|
+
errors.push(
|
|
381
|
+
`Field ${path}[${idx}] must be a reference object with type: ${expectedType}`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
const refShape = getShape(def.itemType);
|
|
386
|
+
if (refShape) {
|
|
387
|
+
value.forEach((ref: any, idx: number) => {
|
|
388
|
+
if (ref && typeof ref === "object" && ref !== null) {
|
|
389
|
+
for (const [childKey, childDef] of Object.entries(refShape) as [
|
|
390
|
+
string,
|
|
391
|
+
FieldBuilder<any>
|
|
392
|
+
][]) {
|
|
393
|
+
const childValue = ref[childKey];
|
|
394
|
+
if (
|
|
395
|
+
(childValue === undefined || childValue === null) &&
|
|
396
|
+
!isOptional(childDef)
|
|
397
|
+
) {
|
|
398
|
+
errors.push(
|
|
399
|
+
`Missing required field: ${path}[${idx}].${childKey}`
|
|
400
|
+
);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const childValidator = getValidator(childDef);
|
|
404
|
+
if (
|
|
405
|
+
childValidator &&
|
|
406
|
+
childValue !== undefined &&
|
|
407
|
+
childValue !== null
|
|
408
|
+
) {
|
|
409
|
+
const valid = childValidator(childValue);
|
|
410
|
+
if (!valid)
|
|
411
|
+
errors.push(
|
|
412
|
+
`Invalid value for field: ${path}[${idx}].${childKey}`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
errors.push(`Field ${path} has unsupported array item type`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function validateRefField(
|
|
425
|
+
parentKey: string,
|
|
426
|
+
key: string,
|
|
427
|
+
value: any,
|
|
428
|
+
_def: any,
|
|
429
|
+
errors: string[]
|
|
430
|
+
) {
|
|
431
|
+
if (
|
|
432
|
+
typeof value !== "object" ||
|
|
433
|
+
value === null ||
|
|
434
|
+
typeof value.type !== "string" ||
|
|
435
|
+
typeof value.id !== "string"
|
|
436
|
+
) {
|
|
437
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
438
|
+
errors.push(`Field ${path} must be { type: string; id: string }`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function validateByType(
|
|
443
|
+
parentKey: string,
|
|
444
|
+
key: string,
|
|
445
|
+
value: any,
|
|
446
|
+
def: any,
|
|
447
|
+
errors: string[]
|
|
448
|
+
) {
|
|
449
|
+
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
450
|
+
switch (def.type) {
|
|
451
|
+
case "string":
|
|
452
|
+
return validateStringField(parentKey, key, value, def, errors);
|
|
453
|
+
case "number":
|
|
454
|
+
return validateNumberField(parentKey, key, value, def, errors);
|
|
455
|
+
case "boolean":
|
|
456
|
+
return validateBooleanField(parentKey, key, value, def, errors);
|
|
457
|
+
case "object":
|
|
458
|
+
return validateObjectField(parentKey, key, value, def, errors);
|
|
459
|
+
case "array":
|
|
460
|
+
return validateArrayField(parentKey, key, value, def, errors);
|
|
461
|
+
case "ref":
|
|
462
|
+
return validateRefField(parentKey, key, value, def, errors);
|
|
463
|
+
default:
|
|
464
|
+
errors.push(`Unknown type for field ${path}: ${def.type}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function createSchema<S extends SchemaShape>(
|
|
469
|
+
_shape: S,
|
|
470
|
+
entityType: string,
|
|
471
|
+
options: SchemaOptions = {
|
|
472
|
+
version: "1.0",
|
|
473
|
+
table: "",
|
|
474
|
+
schemaValidator: () => true,
|
|
475
|
+
piiEnforcement: "none",
|
|
476
|
+
}
|
|
477
|
+
): Schema<S> {
|
|
478
|
+
const systemFields = {
|
|
479
|
+
type: field.string().immutable().system(),
|
|
480
|
+
version: field.string().immutable().system(),
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const version = options.version || "1.0";
|
|
484
|
+
const store = options.table || "";
|
|
485
|
+
const schema: Schema<S> = {
|
|
486
|
+
// 🔗 Define the schema shape
|
|
487
|
+
_shape: {
|
|
488
|
+
...systemFields,
|
|
489
|
+
..._shape,
|
|
490
|
+
} as S,
|
|
491
|
+
|
|
492
|
+
// 🔗 Metadata about the schema
|
|
493
|
+
meta: { entityType, version },
|
|
494
|
+
|
|
495
|
+
// 🔗 Validate input against the schema
|
|
496
|
+
validate(input: unknown, existing?: Record<string, any>) {
|
|
497
|
+
const errors: string[] = [];
|
|
498
|
+
const result: any = {};
|
|
499
|
+
|
|
500
|
+
if (typeof input !== "object" || input === null) {
|
|
501
|
+
return { valid: false, errors: ["Input must be an object"] } as any;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!(input as any).type || !(input as any).version) {
|
|
505
|
+
(input as any).type = entityType;
|
|
506
|
+
(input as any).version = version;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
for (const key in schema._shape) {
|
|
510
|
+
const def = schema._shape[key];
|
|
511
|
+
const value = (input as any)[key];
|
|
512
|
+
|
|
513
|
+
if (!def) {
|
|
514
|
+
errors.push(`Field definition missing for: ${key}`);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 1) Required
|
|
519
|
+
const { missing } = checkMissingRequired("", key, value, def, errors);
|
|
520
|
+
if (missing) continue;
|
|
521
|
+
|
|
522
|
+
// 2) Immutable
|
|
523
|
+
const { immutableViolation } = checkImmutable(
|
|
524
|
+
"",
|
|
525
|
+
key,
|
|
526
|
+
value,
|
|
527
|
+
def,
|
|
528
|
+
existing,
|
|
529
|
+
errors
|
|
530
|
+
);
|
|
531
|
+
if (immutableViolation) continue;
|
|
532
|
+
|
|
533
|
+
// 3) PII enforcement (may short-circuit)
|
|
534
|
+
const { shortCircuit } = enforcePIIField(
|
|
535
|
+
"",
|
|
536
|
+
key,
|
|
537
|
+
value,
|
|
538
|
+
def,
|
|
539
|
+
options.piiEnforcement ?? "none",
|
|
540
|
+
errors,
|
|
541
|
+
console
|
|
542
|
+
);
|
|
543
|
+
if (shortCircuit) continue;
|
|
544
|
+
|
|
545
|
+
// 4) Custom validator
|
|
546
|
+
const { invalid } = runCustomValidator("", key, value, def, errors);
|
|
547
|
+
if (invalid) continue;
|
|
548
|
+
|
|
549
|
+
// 5) Type-specific validation
|
|
550
|
+
validateByType("", key, value, def, errors);
|
|
551
|
+
|
|
552
|
+
// Assign value regardless; storage transforms happen elsewhere
|
|
553
|
+
result[key] = value;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (errors.length === 0 && options.schemaValidator) {
|
|
557
|
+
const castValue = result as Infer<S>;
|
|
558
|
+
if (!options.schemaValidator(castValue)) {
|
|
559
|
+
errors.push("Schema-level validation failed.");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
valid: errors.length === 0,
|
|
565
|
+
value: result as Infer<S>,
|
|
566
|
+
errors,
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
// specific validator for a schema to allow conditional validation
|
|
571
|
+
schemaValidator: options.schemaValidator!, // <== expose it here!
|
|
572
|
+
|
|
573
|
+
// 🔗 Validate composition (references) recursively
|
|
574
|
+
async validateComposition(entity, options) {
|
|
575
|
+
const {
|
|
576
|
+
resolveEntity,
|
|
577
|
+
validatorContext = { visited: new Set() },
|
|
578
|
+
maxDepth = 5,
|
|
579
|
+
log,
|
|
580
|
+
} = options;
|
|
581
|
+
|
|
582
|
+
const entityKey = `${(entity as any).type}:${(entity as any).id}`;
|
|
583
|
+
if (validatorContext.visited.has(entityKey)) {
|
|
584
|
+
log?.(`Skipping already visited entity ${entityKey}`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
validatorContext.visited.add(entityKey);
|
|
588
|
+
log?.(`Validating composition for entity ${entityKey}`);
|
|
589
|
+
|
|
590
|
+
for (const [key, def] of Object.entries(schema._shape)) {
|
|
591
|
+
// NEW: skip if not in onlyFields
|
|
592
|
+
if (options.onlyFields && !options.onlyFields.includes(key)) {
|
|
593
|
+
log?.(`Skipping field ${key} (not in onlyFields)`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const refType = (def as any).refType as string;
|
|
598
|
+
const autoValidate = (def as any).autoValidate !== false;
|
|
599
|
+
const refPolicy = (def as any).refPolicy ?? "eager";
|
|
600
|
+
const value = (entity as any)[key];
|
|
601
|
+
if (!value) continue;
|
|
602
|
+
|
|
603
|
+
if (def.type === "ref") {
|
|
604
|
+
const ref = value as { type: string; id: string };
|
|
605
|
+
const target = await options.resolveEntity(refType, ref.id);
|
|
606
|
+
if (!target)
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Broken reference: ${refType} ${ref.id} in field ${key}`
|
|
609
|
+
);
|
|
610
|
+
log?.(`Resolved ${refType} ${ref.id} from field ${key}`);
|
|
611
|
+
|
|
612
|
+
if (autoValidate && refPolicy === "eager") {
|
|
613
|
+
const targetSchema = getSchemaForType(refType);
|
|
614
|
+
if (options.maxDepth! > 0 && targetSchema) {
|
|
615
|
+
await targetSchema.validateComposition(target, {
|
|
616
|
+
...options,
|
|
617
|
+
maxDepth: options.maxDepth! - 1,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// Handle array of refs: type === "array" and itemType?.type === "ref"
|
|
622
|
+
} else if (def.type === "array" && def.itemType?.type === "ref") {
|
|
623
|
+
const refs = value as Array<{ type: string; id: string }>;
|
|
624
|
+
for (const ref of refs) {
|
|
625
|
+
const target = await options.resolveEntity(refType, ref.id);
|
|
626
|
+
if (!target)
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Broken reference: ${refType} ${ref.id} in field ${key}`
|
|
629
|
+
);
|
|
630
|
+
log?.(`Resolved ${refType} ${ref.id} from field ${key}`);
|
|
631
|
+
|
|
632
|
+
if (autoValidate && refPolicy === "eager") {
|
|
633
|
+
const targetSchema = getSchemaForType(refType);
|
|
634
|
+
if (options.maxDepth! > 0 && targetSchema) {
|
|
635
|
+
await targetSchema.validateComposition(target, {
|
|
636
|
+
...options,
|
|
637
|
+
maxDepth: options.maxDepth! - 1,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
tableName(): string {
|
|
647
|
+
if (!store || store === "") {
|
|
648
|
+
throw new Error("Store is not defined for this schema");
|
|
649
|
+
}
|
|
650
|
+
return store;
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
// 🔒 Auto-prepare for storage (encrypt/hash PII)
|
|
654
|
+
prepareForStorage(
|
|
655
|
+
input: Record<string, any>,
|
|
656
|
+
encryptFn: (value: any) => string,
|
|
657
|
+
hashFn: (value: any) => string
|
|
658
|
+
): Record<string, any> {
|
|
659
|
+
return piiPrepareForStorage(_shape, input, encryptFn, hashFn);
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
prepareForRead(
|
|
663
|
+
stored: Record<string, any>,
|
|
664
|
+
decryptFn: (value: string) => any
|
|
665
|
+
): Record<string, any> {
|
|
666
|
+
return piiPrepareForRead(_shape, stored, decryptFn);
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
// 🔍 Sanitize for logging (redact/pseudonymize PII)
|
|
670
|
+
sanitizeForLog(
|
|
671
|
+
data: Record<string, any>,
|
|
672
|
+
pseudonymFn: (value: any) => string
|
|
673
|
+
): Record<string, any> {
|
|
674
|
+
return piiSanitizeForLog(_shape, data, pseudonymFn);
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
getPiiAudit(): Array<{
|
|
678
|
+
field: string;
|
|
679
|
+
classification: PIIClassification;
|
|
680
|
+
action: PIIAction;
|
|
681
|
+
logHandling?: PIILogHandling;
|
|
682
|
+
purpose?: string;
|
|
683
|
+
}> {
|
|
684
|
+
return piiGetPiiAudit(_shape);
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
scrubPiiForDelete(stored: Record<string, any>): Record<string, any> {
|
|
688
|
+
return piiScrubPiiForDelete(_shape, stored);
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
describe() {
|
|
692
|
+
const description: Record<string, any> = {};
|
|
693
|
+
for (const [key, def] of Object.entries(schema._shape)) {
|
|
694
|
+
description[key] = {
|
|
695
|
+
type: def.type,
|
|
696
|
+
optional: !!def.optional,
|
|
697
|
+
description: def._description ?? "",
|
|
698
|
+
version: def._version ?? "",
|
|
699
|
+
enum: getEnumValues(def as any),
|
|
700
|
+
refType: (def as any).refType ?? undefined,
|
|
701
|
+
pii: def._pii ?? undefined,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
entityType,
|
|
707
|
+
version,
|
|
708
|
+
shape: description,
|
|
709
|
+
};
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// Register the schema globally
|
|
714
|
+
globalSchemaRegistry.set(entityType, schema);
|
|
715
|
+
return schema;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 🔗 Retrieve a previously registered schema globally
|
|
719
|
+
export function getSchemaForType(type: string): Schema<any> | undefined {
|
|
720
|
+
return globalSchemaRegistry.get(type);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 🔗 Retrieve all registered schemas globally
|
|
724
|
+
export function getAllSchemas(): Schema<any>[] {
|
|
725
|
+
return Array.from(globalSchemaRegistry.values());
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Renders a schema description to a simplified frontend-consumable format.
|
|
730
|
+
* This can be used in UIs for schema explorers, documentation, or admin tools.
|
|
731
|
+
*/
|
|
732
|
+
export function renderSchemaDescription(
|
|
733
|
+
schema: Schema<any>
|
|
734
|
+
): {
|
|
735
|
+
title: string;
|
|
736
|
+
fields: Array<{
|
|
737
|
+
name: string;
|
|
738
|
+
type: FieldType;
|
|
739
|
+
optional: boolean;
|
|
740
|
+
description: string;
|
|
741
|
+
deprecated: boolean;
|
|
742
|
+
pii?: string;
|
|
743
|
+
}>;
|
|
744
|
+
} {
|
|
745
|
+
const meta = schema.describe();
|
|
746
|
+
return {
|
|
747
|
+
title: `${meta.entityType} (v${meta.version})`,
|
|
748
|
+
fields: Object.entries(meta.shape).map(([name, def]) => ({
|
|
749
|
+
name,
|
|
750
|
+
type: def.type,
|
|
751
|
+
optional: def.optional,
|
|
752
|
+
description: def.description,
|
|
753
|
+
deprecated: def.deprecated,
|
|
754
|
+
pii: def.pii ? def.pii.classification : undefined,
|
|
755
|
+
})),
|
|
756
|
+
};
|
|
757
|
+
}
|