@plasius/schema 1.1.0 → 1.2.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 (59) hide show
  1. package/README.md +31 -18
  2. package/dist/index.cjs +2370 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +627 -0
  5. package/dist/index.d.ts +627 -0
  6. package/dist/index.js +2308 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +18 -6
  9. package/.eslintrc.cjs +0 -7
  10. package/.github/workflows/cd.yml +0 -236
  11. package/.github/workflows/ci.yml +0 -16
  12. package/.nvmrc +0 -1
  13. package/.vscode/launch.json +0 -15
  14. package/CHANGELOG.md +0 -120
  15. package/CODE_OF_CONDUCT.md +0 -79
  16. package/CONTRIBUTING.md +0 -201
  17. package/CONTRIBUTORS.md +0 -27
  18. package/SECURITY.md +0 -17
  19. package/docs/adrs/adr-0001: schema.md +0 -45
  20. package/docs/adrs/adr-template.md +0 -67
  21. package/legal/CLA-REGISTRY.csv +0 -2
  22. package/legal/CLA.md +0 -22
  23. package/legal/CORPORATE_CLA.md +0 -57
  24. package/legal/INDIVIDUAL_CLA.md +0 -91
  25. package/sbom.cdx.json +0 -66
  26. package/src/components.ts +0 -39
  27. package/src/field.builder.ts +0 -239
  28. package/src/field.ts +0 -153
  29. package/src/index.ts +0 -7
  30. package/src/infer.ts +0 -34
  31. package/src/pii.ts +0 -165
  32. package/src/schema.ts +0 -893
  33. package/src/types.ts +0 -156
  34. package/src/validation/countryCode.ISO3166.ts +0 -256
  35. package/src/validation/currencyCode.ISO4217.ts +0 -191
  36. package/src/validation/dateTime.ISO8601.ts +0 -60
  37. package/src/validation/email.RFC5322.ts +0 -9
  38. package/src/validation/generalText.OWASP.ts +0 -39
  39. package/src/validation/index.ts +0 -13
  40. package/src/validation/languageCode.BCP47.ts +0 -299
  41. package/src/validation/name.OWASP.ts +0 -25
  42. package/src/validation/percentage.ISO80000-1.ts +0 -8
  43. package/src/validation/phone.E.164.ts +0 -9
  44. package/src/validation/richtext.OWASP.ts +0 -34
  45. package/src/validation/url.WHATWG.ts +0 -16
  46. package/src/validation/user.MS-GOOGLE-APPLE.ts +0 -31
  47. package/src/validation/uuid.RFC4122.ts +0 -10
  48. package/src/validation/version.SEMVER2.0.0.ts +0 -10
  49. package/tests/field.builder.test.ts +0 -81
  50. package/tests/fields.test.ts +0 -213
  51. package/tests/pii.test.ts +0 -139
  52. package/tests/schema.test.ts +0 -501
  53. package/tests/test-utils.ts +0 -97
  54. package/tests/validate.test.ts +0 -97
  55. package/tests/validation.test.ts +0 -98
  56. package/tsconfig.build.json +0 -19
  57. package/tsconfig.json +0 -7
  58. package/tsup.config.ts +0 -10
  59. package/vitest.config.js +0 -20
package/src/schema.ts DELETED
@@ -1,893 +0,0 @@
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
- import { validateSemVer } from "./validation/version.SEMVER2.0.0.js";
15
-
16
- const globalSchemaRegistry = new Map<string, Schema<any>>();
17
-
18
- function cmpSemver(a: string, b: string): number {
19
- const pa = a.split(".").map((n) => parseInt(n, 10));
20
- const pb = b.split(".").map((n) => parseInt(n, 10));
21
- for (let i = 0; i < 3; i++) {
22
- const ai = pa[i] ?? 0;
23
- const bi = pb[i] ?? 0;
24
- if (ai > bi) return 1;
25
- if (ai < bi) return -1;
26
- }
27
- return 0;
28
- }
29
-
30
- function validateEnum(
31
- parentKey: string,
32
- value: any,
33
- enumValues?: Array<string | number>
34
- ): string | undefined {
35
- if (!enumValues) return;
36
-
37
- const values = Array.isArray(enumValues)
38
- ? enumValues
39
- : Array.from(enumValues);
40
-
41
- if (Array.isArray(value)) {
42
- const invalid = value.filter((v) => !values.includes(v));
43
- if (invalid.length > 0) {
44
- return `Field ${parentKey} contains invalid enum values: ${invalid.join(
45
- ", "
46
- )}`;
47
- }
48
- } else {
49
- if (!values.includes(value)) {
50
- return `Field ${parentKey} must be one of: ${values.join(", ")}`;
51
- }
52
- }
53
- }
54
-
55
- // Helper: some builders may store enum/optional/validator under different keys
56
- // schema.ts
57
- function getEnumValues(def: any): Array<string | number> | undefined {
58
- const src = Array.isArray(def?.enum)
59
- ? def.enum
60
- : Array.isArray(def?.enumValues)
61
- ? def.enumValues
62
- : Array.isArray(def?._enum)
63
- ? def._enum
64
- : Array.isArray(def?._enumValues)
65
- ? def._enumValues
66
- : def?._enumSet instanceof Set
67
- ? Array.from(def._enumSet)
68
- : undefined;
69
-
70
- if (!src) return undefined;
71
- const ok = src.every(
72
- (v: unknown) => typeof v === "string" || typeof v === "number"
73
- );
74
- return ok ? (src as Array<string | number>) : undefined;
75
- }
76
-
77
- function isOptional(def: any): boolean {
78
- return (def?.isRequired ?? false) === false;
79
- }
80
-
81
- function getValidator(def: any): ((v: any) => boolean) | undefined {
82
- return def?._validator ?? undefined;
83
- }
84
-
85
- function getShape(def: any): Record<string, any> | undefined {
86
- return def?._shape ?? def?.shape ?? undefined;
87
- }
88
-
89
- // ──────────────────────────────────────────────────────────────────────────────
90
- // Validation helpers (extracted from validate())
91
- // ──────────────────────────────────────────────────────────────────────────────
92
-
93
- function checkMissingRequired(
94
- parentKey: string,
95
- key: string,
96
- value: any,
97
- def: any,
98
- errors: string[]
99
- ): { missing: boolean } {
100
- if (value === undefined || value === null) {
101
- const path = parentKey ? `${parentKey}.${key}` : key;
102
- if (!isOptional(def)) {
103
- errors.push(`Missing required field: ${path}`);
104
- }
105
- return { missing: true };
106
- }
107
- return { missing: false };
108
- }
109
-
110
- function checkImmutable(
111
- parentKey: string,
112
- key: string,
113
- value: any,
114
- def: any,
115
- existing: Record<string, any> | undefined,
116
- errors: string[]
117
- ): { immutableViolation: boolean } {
118
- if (
119
- def.isImmutable &&
120
- existing &&
121
- existing[key] !== undefined &&
122
- value !== existing[key]
123
- ) {
124
- const path = parentKey ? `${parentKey}.${key}` : key;
125
- errors.push(`Field is immutable: ${path}`);
126
- return { immutableViolation: true };
127
- }
128
- return { immutableViolation: false };
129
- }
130
-
131
- function runCustomValidator(
132
- parentKey: string,
133
- key: string,
134
- value: any,
135
- def: any,
136
- errors: string[]
137
- ): { invalid: boolean } {
138
- const validator = getValidator(def);
139
- if (validator && value !== undefined && value !== null) {
140
- const valid = validator(value);
141
- if (!valid) {
142
- const path = parentKey ? `${parentKey}.${key}` : key;
143
- errors.push(`Invalid value for field: ${path}`);
144
- return { invalid: true };
145
- }
146
- }
147
- return { invalid: false };
148
- }
149
-
150
- function validateStringField(
151
- parentKey: string,
152
- key: string,
153
- value: any,
154
- def: any,
155
- errors: string[]
156
- ) {
157
- const path = parentKey ? `${parentKey}.${key}` : key;
158
- if (typeof value !== "string") {
159
- errors.push(`Field ${path} must be string`);
160
- return;
161
- }
162
-
163
- const enumValues = getEnumValues(def);
164
- if (Array.isArray(enumValues)) {
165
- const enumError = validateEnum(path, value, enumValues);
166
- if (enumError) {
167
- errors.push(enumError);
168
- }
169
- }
170
- }
171
-
172
- function validateNumberField(
173
- parentKey: string,
174
- key: string,
175
- value: any,
176
- _def: any,
177
- errors: string[]
178
- ) {
179
- const enumPath = parentKey ? `${parentKey}.${key}` : key;
180
- if (typeof value !== "number") {
181
- errors.push(`Field ${enumPath} must be number`);
182
- }
183
- }
184
-
185
- function validateBooleanField(
186
- parentKey: string,
187
- key: string,
188
- value: any,
189
- _def: any,
190
- errors: string[]
191
- ) {
192
- const path = parentKey ? `${parentKey}.${key}` : key;
193
- if (typeof value !== "boolean") {
194
- errors.push(`Field ${path} must be boolean`);
195
- }
196
- }
197
-
198
- function validateObjectChildren(
199
- parentKey: string,
200
- obj: any,
201
- shape: Record<string, any>,
202
- errors: string[]
203
- ) {
204
- for (const [childKey, childDef] of Object.entries(shape) as [
205
- string,
206
- FieldBuilder<any>
207
- ][]) {
208
- const childValue = obj[childKey];
209
-
210
- // Required check
211
- const { missing } = checkMissingRequired(
212
- parentKey,
213
- childKey,
214
- childValue,
215
- childDef,
216
- errors
217
- );
218
- if (missing) continue;
219
-
220
- // Custom validator (per-field)
221
- const { invalid } = runCustomValidator(
222
- parentKey,
223
- childKey,
224
- childValue,
225
- childDef,
226
- errors
227
- );
228
- if (invalid) continue;
229
-
230
- // Type-specific validation (string/number/boolean/object/array/ref)
231
- // This will recurse into nested objects via validateObjectField → validateObjectChildren
232
- validateByType(parentKey, childKey, childValue, childDef, errors);
233
- }
234
- }
235
-
236
- function validateObjectField(
237
- parentKey: string,
238
- key: string,
239
- value: any,
240
- def: any,
241
- errors: string[]
242
- ) {
243
- const path = parentKey ? `${parentKey}.${key}` : key;
244
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
245
- errors.push(`Field ${path} must be object`);
246
- return;
247
- }
248
- const objShape = getShape(def);
249
- if (objShape) validateObjectChildren(path, value, objShape, errors);
250
- }
251
-
252
- function validateArrayOfStrings(
253
- parentKey: string,
254
- key: string,
255
- arr: any[],
256
- itemDef: any,
257
- errors: string[]
258
- ) {
259
- const path = parentKey ? `${parentKey}.${key}` : key;
260
- if (!arr.every((v) => typeof v === "string")) {
261
- errors.push(`Field ${path} must be string[]`);
262
- return;
263
- }
264
- const enumValues = getEnumValues(itemDef);
265
- if (Array.isArray(enumValues)) {
266
- const enumError = validateEnum(path, arr, enumValues);
267
- if (enumError) {
268
- errors.push(enumError);
269
- }
270
- }
271
- }
272
-
273
- function validateArrayOfNumbers(
274
- parentKey: string,
275
- key: string,
276
- arr: any[],
277
- itemDef: any,
278
- errors: string[]
279
- ) {
280
- const path = parentKey ? `${parentKey}.${key}` : key;
281
- if (!arr.every((v) => typeof v === "number")) {
282
- errors.push(`Field ${path} must be number[]`);
283
- }
284
-
285
- const enumValues = getEnumValues(itemDef);
286
- if (Array.isArray(enumValues)) {
287
- const enumError = validateEnum(path, arr, enumValues);
288
- if (enumError) {
289
- errors.push(enumError);
290
- }
291
- }
292
- }
293
-
294
- function validateArrayOfBooleans(
295
- parentKey: string,
296
- key: string,
297
- arr: any[],
298
- _itemDef: any,
299
- errors: string[]
300
- ) {
301
- const path = parentKey ? `${parentKey}.${key}` : key;
302
- if (!arr.every((v) => typeof v === "boolean")) {
303
- errors.push(`Field ${path} must be boolean[]`);
304
- }
305
- }
306
-
307
- function validateArrayOfObjects(
308
- parentKey: string,
309
- key: string,
310
- arr: any[],
311
- itemDef: any,
312
- errors: string[]
313
- ) {
314
- const path = parentKey ? `${parentKey}.${key}` : key;
315
-
316
- if (
317
- !Array.isArray(arr) ||
318
- !arr.every((v) => typeof v === "object" && v !== null && !Array.isArray(v))
319
- ) {
320
- errors.push(`Field ${path} must be object[]`);
321
- return;
322
- }
323
-
324
- const itemShape = getShape(itemDef);
325
- if (!itemShape) return;
326
-
327
- arr.forEach((item, idx) => {
328
- const itemParent = `${path}[${idx}]`;
329
- for (const [childKey, childDef] of Object.entries(itemShape) as [
330
- string,
331
- FieldBuilder<any>
332
- ][]) {
333
- const childValue = (item as any)[childKey];
334
-
335
- // Required check (path-aware)
336
- const { missing } = checkMissingRequired(
337
- itemParent,
338
- childKey,
339
- childValue,
340
- childDef,
341
- errors
342
- );
343
- if (missing) continue;
344
-
345
- // Custom validator (path-aware)
346
- const { invalid } = runCustomValidator(
347
- itemParent,
348
- childKey,
349
- childValue,
350
- childDef,
351
- errors
352
- );
353
- if (invalid) continue;
354
-
355
- // Type-specific validation (recurses into nested object/array/ref)
356
- validateByType(itemParent, childKey, childValue, childDef, errors);
357
- }
358
- });
359
- }
360
-
361
- function validateArrayField(
362
- parentKey: string,
363
- key: string,
364
- value: any,
365
- def: any,
366
- errors: string[]
367
- ) {
368
- const path = parentKey ? `${parentKey}.${key}` : key;
369
- if (!Array.isArray(value)) {
370
- errors.push(`Field ${key} must be an array`);
371
- return;
372
- }
373
- const itemType = def.itemType?.type;
374
- if (itemType === "string")
375
- return validateArrayOfStrings(parentKey, key, value, def.itemType, errors);
376
- if (itemType === "number")
377
- return validateArrayOfNumbers(parentKey, key, value, def.itemType, errors);
378
- if (itemType === "boolean")
379
- return validateArrayOfBooleans(parentKey, key, value, def.itemType, errors);
380
- if (itemType === "object")
381
- return validateArrayOfObjects(parentKey, key, value, def.itemType, errors);
382
- if (itemType === "ref") {
383
- const expectedType = (def.itemType as any).refType;
384
- value.forEach((ref: any, idx: number) => {
385
- if (
386
- !ref ||
387
- typeof ref !== "object" ||
388
- ref === null ||
389
- typeof ref.type !== "string" ||
390
- typeof ref.id !== "string" ||
391
- (expectedType && ref.type !== expectedType)
392
- ) {
393
- errors.push(
394
- `Field ${path}[${idx}] must be a reference object with type: ${expectedType}`
395
- );
396
- }
397
- });
398
- const refShape = getShape(def.itemType);
399
- if (refShape) {
400
- value.forEach((ref: any, idx: number) => {
401
- if (ref && typeof ref === "object" && ref !== null) {
402
- for (const [childKey, childDef] of Object.entries(refShape) as [
403
- string,
404
- FieldBuilder<any>
405
- ][]) {
406
- const childValue = ref[childKey];
407
- if (
408
- (childValue === undefined || childValue === null) &&
409
- !isOptional(childDef)
410
- ) {
411
- errors.push(
412
- `Missing required field: ${path}[${idx}].${childKey}`
413
- );
414
- continue;
415
- }
416
- const childValidator = getValidator(childDef);
417
- if (
418
- childValidator &&
419
- childValue !== undefined &&
420
- childValue !== null
421
- ) {
422
- const valid = childValidator(childValue);
423
- if (!valid)
424
- errors.push(
425
- `Invalid value for field: ${path}[${idx}].${childKey}`
426
- );
427
- }
428
- }
429
- }
430
- });
431
- }
432
- return;
433
- }
434
- errors.push(`Field ${path} has unsupported array item type`);
435
- }
436
-
437
- function validateRefField(
438
- parentKey: string,
439
- key: string,
440
- value: any,
441
- _def: any,
442
- errors: string[]
443
- ) {
444
- if (
445
- typeof value !== "object" ||
446
- value === null ||
447
- typeof value.type !== "string" ||
448
- typeof value.id !== "string"
449
- ) {
450
- const path = parentKey ? `${parentKey}.${key}` : key;
451
- errors.push(`Field ${path} must be { type: string; id: string }`);
452
- }
453
- }
454
-
455
- function validateByType(
456
- parentKey: string,
457
- key: string,
458
- value: any,
459
- def: any,
460
- errors: string[]
461
- ) {
462
- const path = parentKey ? `${parentKey}.${key}` : key;
463
- switch (def.type) {
464
- case "string":
465
- return validateStringField(parentKey, key, value, def, errors);
466
- case "number":
467
- return validateNumberField(parentKey, key, value, def, errors);
468
- case "boolean":
469
- return validateBooleanField(parentKey, key, value, def, errors);
470
- case "object":
471
- return validateObjectField(parentKey, key, value, def, errors);
472
- case "array":
473
- return validateArrayField(parentKey, key, value, def, errors);
474
- case "ref":
475
- return validateRefField(parentKey, key, value, def, errors);
476
- default:
477
- errors.push(`Unknown type for field ${path}: ${def.type}`);
478
- }
479
- }
480
-
481
- export function createSchema<S extends SchemaShape>(
482
- _shape: S,
483
- entityType: string,
484
- options: SchemaOptions = {
485
- version: "1.0.0",
486
- table: "",
487
- schemaValidator: () => true,
488
- piiEnforcement: "none",
489
- }
490
- ): Schema<S> {
491
- const systemFields = {
492
- type: field.string().immutable().system(),
493
- version: field.string().immutable().system().validator(validateSemVer),
494
- };
495
-
496
- const version = options.version || "1.0.0";
497
- const store = options.table || "";
498
- const schema: Schema<S> = {
499
- // 🔗 Define the schema shape
500
- _shape: {
501
- ...systemFields,
502
- ..._shape,
503
- } as S,
504
-
505
- // 🔗 Metadata about the schema
506
- meta: { entityType, version },
507
-
508
- // 🔗 Validate input against the schema
509
- validate(input: unknown, existing?: Record<string, any>) {
510
- const errors: string[] = [];
511
- const result: any = {};
512
-
513
- if (typeof input !== "object" || input === null) {
514
- return { valid: false, errors: ["Input must be an object"] } as any;
515
- }
516
- // Work on a non-mutating copy that includes system defaults for first-time objects
517
- const working: Record<string, any> = { ...(input as any) };
518
- if (working.type == null) working.type = entityType;
519
- if (working.version == null) working.version = version;
520
-
521
- for (const key in schema._shape) {
522
- const def = schema._shape[key];
523
- const value = working[key];
524
-
525
- if (!def) {
526
- errors.push(`Field definition missing for: ${key}`);
527
- continue;
528
- }
529
-
530
- // 1) Required
531
- const { missing } = checkMissingRequired("", key, value, def, errors);
532
- if (missing) continue;
533
-
534
- // 2) Immutable
535
- const { immutableViolation } = checkImmutable(
536
- "",
537
- key,
538
- value,
539
- def,
540
- existing,
541
- errors
542
- );
543
- if (immutableViolation) continue;
544
-
545
- // 3) PII enforcement (may short-circuit)
546
- const { shortCircuit } = enforcePIIField(
547
- "",
548
- key,
549
- value,
550
- def,
551
- options.piiEnforcement ?? "none",
552
- errors,
553
- console
554
- );
555
- if (shortCircuit) continue;
556
-
557
- // A local validator for this field that returns errors instead of pushing into the outer array
558
- const validateField = (val: any): string[] => {
559
- const localErrors: string[] = [];
560
- const { invalid } = runCustomValidator(
561
- "",
562
- key,
563
- val,
564
- def,
565
- localErrors
566
- );
567
- if (!invalid) {
568
- validateByType("", key, val, def, localErrors);
569
- }
570
- return localErrors;
571
- };
572
-
573
- // First pass validation
574
- let fieldValue = value;
575
- let fieldErrors = validateField(fieldValue);
576
-
577
- // Attempt upgrade if invalid and an upgrader exists and entity is older than schema
578
- const entityFrom = String(working.version ?? "0.0.0");
579
- const entityTo = String(version);
580
- const fieldTo = String((def as any)._version ?? entityTo);
581
- const hasUpgrader = typeof (def as any)._upgrade === "function";
582
-
583
- if (
584
- fieldErrors.length > 0 &&
585
- hasUpgrader &&
586
- cmpSemver(entityFrom, entityTo) < 0
587
- ) {
588
- const up = (def as any)._upgrade as (
589
- value: any,
590
- ctx: {
591
- entityFrom: string;
592
- entityTo: string;
593
- fieldTo: string;
594
- fieldName: string;
595
- }
596
- ) => { ok: boolean; value?: any; error?: string };
597
-
598
- const res = up(fieldValue, {
599
- entityFrom,
600
- entityTo,
601
- fieldTo,
602
- fieldName: key,
603
- });
604
- if (res && res.ok) {
605
- fieldValue = res.value;
606
- fieldErrors = validateField(fieldValue);
607
- } else {
608
- fieldErrors.push(
609
- res?.error ||
610
- `Failed to upgrade field ${key} from v${entityFrom} to v${entityTo}`
611
- );
612
- }
613
- }
614
-
615
- result[key] = fieldValue;
616
- // Apply or record errors
617
- if (fieldErrors.length !== 0) {
618
- errors.push(...fieldErrors);
619
- continue;
620
- }
621
- }
622
-
623
- if (errors.length === 0 && options.schemaValidator) {
624
- const castValue = result as Infer<S>;
625
- if (!options.schemaValidator(castValue)) {
626
- errors.push("Schema-level validation failed.");
627
- }
628
- }
629
-
630
- return {
631
- valid: errors.length === 0,
632
- value: result as Infer<S>,
633
- errors,
634
- };
635
- },
636
-
637
- // specific validator for a schema to allow conditional validation
638
- schemaValidator: options.schemaValidator!, // <== expose it here!
639
-
640
- /**
641
- * Recursively validates entity references defined in this schema.
642
- *
643
- * Traverses fields of type `ref` and arrays of `ref` and resolves each target
644
- * entity using the provided `resolveEntity` function. When `autoValidate` is
645
- * enabled (default) and the field's `refPolicy` is `eager`, the referenced
646
- * entity's schema is fetched and validated via `validateComposition` up to
647
- * `maxDepth` levels.
648
- *
649
- * Skips fields not listed in `onlyFields` when provided. Prevents cycles via
650
- * a `visited` set in `validatorContext`.
651
- *
652
- * @param entity The root entity to validate (must include `type` and `id`).
653
- * @param options Options controlling traversal and resolution behavior.
654
- * @param options.resolveEntity Function to resolve a referenced entity by type and id.
655
- * @param options.validatorContext Internal context (visited set) to prevent cycles.
656
- * @param options.maxDepth Maximum depth for recursive validation (default: 5).
657
- * @param options.onlyFields Optional whitelist of field names to validate.
658
- * @param options.log Optional logger for traversal/debug output.
659
- *
660
- * @throws Error if a broken reference is encountered (target cannot be resolved).
661
- */
662
- async validateComposition(entity, options) {
663
- const {
664
- resolveEntity,
665
- validatorContext = { visited: new Set() },
666
- maxDepth = 5,
667
- log,
668
- } = options;
669
-
670
- const entityKey = `${(entity as any).type}:${(entity as any).id}`;
671
- if (validatorContext.visited.has(entityKey)) {
672
- log?.(`Skipping already visited entity ${entityKey}`);
673
- return;
674
- }
675
- validatorContext.visited.add(entityKey);
676
- log?.(`Validating composition for entity ${entityKey}`);
677
-
678
- for (const [key, def] of Object.entries(schema._shape)) {
679
- // NEW: skip if not in onlyFields
680
- if (options.onlyFields && !options.onlyFields.includes(key)) {
681
- log?.(`Skipping field ${key} (not in onlyFields)`);
682
- continue;
683
- }
684
-
685
- const refType = (def as any).refType as string;
686
- const autoValidate = (def as any).autoValidate !== false;
687
- const refPolicy = (def as any).refPolicy ?? "eager";
688
- const value = (entity as any)[key];
689
- if (!value) continue;
690
-
691
- if (def.type === "ref") {
692
- const ref = value as { type: string; id: string };
693
- const target = await options.resolveEntity(refType, ref.id);
694
- if (!target)
695
- throw new Error(
696
- `Broken reference: ${refType} ${ref.id} in field ${key}`
697
- );
698
- log?.(`Resolved ${refType} ${ref.id} from field ${key}`);
699
-
700
- if (autoValidate && refPolicy === "eager") {
701
- const targetSchema = getSchemaForType(refType);
702
- if (options.maxDepth! > 0 && targetSchema) {
703
- await targetSchema.validateComposition(target, {
704
- ...options,
705
- maxDepth: options.maxDepth! - 1,
706
- });
707
- }
708
- }
709
- // Handle array of refs: type === "array" and itemType?.type === "ref"
710
- } else if (def.type === "array" && def.itemType?.type === "ref") {
711
- const refs = value as Array<{ type: string; id: string }>;
712
- for (const ref of refs) {
713
- const target = await options.resolveEntity(refType, ref.id);
714
- if (!target)
715
- throw new Error(
716
- `Broken reference: ${refType} ${ref.id} in field ${key}`
717
- );
718
- log?.(`Resolved ${refType} ${ref.id} from field ${key}`);
719
-
720
- if (autoValidate && refPolicy === "eager") {
721
- const targetSchema = getSchemaForType(refType);
722
- if (options.maxDepth! > 0 && targetSchema) {
723
- await targetSchema.validateComposition(target, {
724
- ...options,
725
- maxDepth: options.maxDepth! - 1,
726
- });
727
- }
728
- }
729
- }
730
- }
731
- }
732
- },
733
-
734
- /**
735
- * Returns the configured table name for this schema.
736
- *
737
- * @throws Error if no store/table name has been defined for this schema.
738
- */
739
- tableName(): string {
740
- if (!store || store === "") {
741
- throw new Error("Store is not defined for this schema");
742
- }
743
- return store;
744
- },
745
-
746
- /**
747
- * Transforms an input object for persistence by applying PII protection
748
- * according to field annotations (e.g., encryption and hashing).
749
- *
750
- * @param input The raw entity data.
751
- * @param encryptFn Function used to encrypt sensitive values.
752
- * @param hashFn Function used to hash sensitive values.
753
- * @returns A new object safe to store.
754
- */
755
- prepareForStorage(
756
- input: Record<string, any>,
757
- encryptFn: (value: any) => string,
758
- hashFn: (value: any) => string
759
- ): Record<string, any> {
760
- return piiPrepareForStorage(_shape, input, encryptFn, hashFn);
761
- },
762
-
763
- /**
764
- * Reverses storage transformations for read paths (e.g., decrypts values)
765
- * according to PII annotations, returning a consumer-friendly object.
766
- *
767
- * @param stored Data retrieved from storage.
768
- * @param decryptFn Function used to decrypt values that were encrypted on write.
769
- * @returns A new object suitable for application consumption.
770
- */
771
- prepareForRead(
772
- stored: Record<string, any>,
773
- decryptFn: (value: string) => any
774
- ): Record<string, any> {
775
- return piiPrepareForRead(_shape, stored, decryptFn);
776
- },
777
-
778
- /**
779
- * Produces a log-safe copy of the provided data by redacting or pseudonymizing
780
- * PII fields in accordance with field annotations.
781
- *
782
- * @param data Arbitrary data to sanitize for logging.
783
- * @param pseudonymFn Function producing stable pseudonyms for sensitive values.
784
- * @returns A copy safe to emit to logs.
785
- */
786
- sanitizeForLog(
787
- data: Record<string, any>,
788
- pseudonymFn: (value: any) => string
789
- ): Record<string, any> {
790
- return piiSanitizeForLog(_shape, data, pseudonymFn);
791
- },
792
-
793
- /**
794
- * Returns a list of fields annotated with PII metadata for auditing purposes.
795
- * Each entry includes classification, required action, and optional log policy.
796
- */
797
- getPiiAudit(): Array<{
798
- field: string;
799
- classification: PIIClassification;
800
- action: PIIAction;
801
- logHandling?: PIILogHandling;
802
- purpose?: string;
803
- }> {
804
- return piiGetPiiAudit(_shape);
805
- },
806
-
807
- /**
808
- * Produces a copy of stored data suitable for data deletion flows by scrubbing
809
- * or blanking PII per field annotations.
810
- *
811
- * @param stored Data as persisted.
812
- * @returns A copy with PII removed or neutralized for deletion.
813
- */
814
- scrubPiiForDelete(stored: Record<string, any>): Record<string, any> {
815
- return piiScrubPiiForDelete(_shape, stored);
816
- },
817
-
818
- /**
819
- * Returns a normalized description of the schema suitable for documentation
820
- * or UI rendering (type, optionality, enum values, PII flags, etc.).
821
- */
822
- describe() {
823
- const description: Record<string, any> = {};
824
- for (const [key, def] of Object.entries(schema._shape)) {
825
- description[key] = {
826
- type: def.type,
827
- optional: !!def.optional,
828
- description: def._description ?? "",
829
- version: def._version ?? "",
830
- enum: getEnumValues(def as any),
831
- refType: (def as any).refType ?? undefined,
832
- pii: def._pii ?? undefined,
833
- };
834
- }
835
-
836
- return {
837
- entityType,
838
- version,
839
- shape: description,
840
- };
841
- },
842
- };
843
-
844
- // Register the schema globally
845
- globalSchemaRegistry.set(entityType, schema);
846
- return schema;
847
- }
848
-
849
- /**
850
- * Retrieves a previously registered schema by its `entityType` from the
851
- * in-process global schema registry.
852
- */
853
- export function getSchemaForType(type: string): Schema<any> | undefined {
854
- return globalSchemaRegistry.get(type);
855
- }
856
-
857
- /**
858
- * Returns all schemas registered in the in-process global registry.
859
- */
860
- export function getAllSchemas(): Schema<any>[] {
861
- return Array.from(globalSchemaRegistry.values());
862
- }
863
-
864
- /**
865
- * Renders a schema into a simplified descriptor for front-end consumption.
866
- * Intended for documentation and admin tooling rather than validation.
867
- */
868
- export function renderSchemaDescription(
869
- schema: Schema<any>
870
- ): {
871
- title: string;
872
- fields: Array<{
873
- name: string;
874
- type: FieldType;
875
- optional: boolean;
876
- description: string;
877
- deprecated: boolean;
878
- pii?: string;
879
- }>;
880
- } {
881
- const meta = schema.describe();
882
- return {
883
- title: `${meta.entityType} (v${meta.version})`,
884
- fields: Object.entries(meta.shape).map(([name, def]) => ({
885
- name,
886
- type: def.type,
887
- optional: def.optional,
888
- description: def.description,
889
- deprecated: def.deprecated,
890
- pii: def.pii ? def.pii.classification : undefined,
891
- })),
892
- };
893
- }