@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.
Files changed (44) hide show
  1. package/.eslintrc.cjs +7 -0
  2. package/.github/workflows/cd.yml +54 -0
  3. package/.github/workflows/ci.yml +16 -0
  4. package/.vscode/launch.json +15 -0
  5. package/CODE_OF_CONDUCT.md +79 -0
  6. package/CONTRIBUTORS.md +27 -0
  7. package/LICENSE +203 -0
  8. package/README.md +45 -0
  9. package/SECURITY.md +17 -0
  10. package/legal/CLA-REGISTRY.csv +2 -0
  11. package/legal/CLA.md +22 -0
  12. package/legal/CORPORATE_CLA.md +55 -0
  13. package/legal/INDIVIDUAL_CLA.md +91 -0
  14. package/package.json +48 -0
  15. package/src/components.ts +39 -0
  16. package/src/field.builder.ts +119 -0
  17. package/src/field.ts +14 -0
  18. package/src/index.ts +7 -0
  19. package/src/infer.ts +34 -0
  20. package/src/pii.ts +165 -0
  21. package/src/schema.ts +757 -0
  22. package/src/types.ts +156 -0
  23. package/src/validation/countryCode.ISO3166.ts +256 -0
  24. package/src/validation/currencyCode.ISO4217.ts +191 -0
  25. package/src/validation/dateTime.ISO8601.ts +9 -0
  26. package/src/validation/email.RFC5322.ts +9 -0
  27. package/src/validation/generalText.OWASP.ts +39 -0
  28. package/src/validation/index.ts +13 -0
  29. package/src/validation/name.OWASP.ts +25 -0
  30. package/src/validation/percentage.ISO80000-1.ts +8 -0
  31. package/src/validation/phone.E.164.ts +9 -0
  32. package/src/validation/richtext.OWASP.ts +34 -0
  33. package/src/validation/url.WHATWG.ts +16 -0
  34. package/src/validation/user.MS-GOOGLE-APPLE.ts +31 -0
  35. package/src/validation/uuid.RFC4122.ts +10 -0
  36. package/src/validation/version.SEMVER2.0.0.ts +8 -0
  37. package/tests/pii.test.ts +139 -0
  38. package/tests/schema.test.ts +501 -0
  39. package/tests/test-utils.ts +97 -0
  40. package/tests/validate.test.ts +97 -0
  41. package/tests/validation.test.ts +98 -0
  42. package/tsconfig.build.json +19 -0
  43. package/tsconfig.json +7 -0
  44. 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
+ }