@atscript/typescript 0.1.31 → 0.1.33

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/dist/utils.mjs CHANGED
@@ -1,22 +1,4 @@
1
1
 
2
- //#region packages/typescript/src/traverse.ts
3
- function forAnnotatedType(def, handlers) {
4
- switch (def.type.kind) {
5
- case "": {
6
- const typed = def;
7
- if (handlers.phantom && typed.type.designType === "phantom") return handlers.phantom(typed);
8
- return handlers.final(typed);
9
- }
10
- case "object": return handlers.object(def);
11
- case "array": return handlers.array(def);
12
- case "union": return handlers.union(def);
13
- case "intersection": return handlers.intersection(def);
14
- case "tuple": return handlers.tuple(def);
15
- default: throw new Error(`Unknown type kind "${def.type.kind}"`);
16
- }
17
- }
18
-
19
- //#endregion
20
2
  //#region packages/typescript/src/validator.ts
21
3
  function _define_property(obj, key, value) {
22
4
  if (key in obj) Object.defineProperty(obj, key, {
@@ -31,28 +13,35 @@ else obj[key] = value;
31
13
  const regexCache = new Map();
32
14
  var Validator = class {
33
15
  isLimitExceeded() {
34
- if (this.stackErrors.length > 0) return this.stackErrors[this.stackErrors.length - 1].length >= this.opts.errorLimit;
16
+ if (this.stackErrors.length > 0) {
17
+ const top = this.stackErrors[this.stackErrors.length - 1];
18
+ return top !== null && top.length >= this.opts.errorLimit;
19
+ }
35
20
  return this.errors.length >= this.opts.errorLimit;
36
21
  }
37
22
  push(name) {
38
23
  this.stackPath.push(name);
39
- this.stackErrors.push([]);
24
+ this.stackErrors.push(null);
25
+ this.cachedPath = this.stackPath.length <= 1 ? "" : this.stackPath[1] + (this.stackPath.length > 2 ? "." + this.stackPath.slice(2).join(".") : "");
40
26
  }
41
27
  pop(saveErrors) {
42
28
  this.stackPath.pop();
43
29
  const popped = this.stackErrors.pop();
44
- if (saveErrors && popped?.length) popped.forEach((error) => {
45
- this.error(error.message, error.path, error.details);
46
- });
30
+ if (saveErrors && popped !== null && popped !== undefined && popped.length > 0) for (const err of popped) this.error(err.message, err.path, err.details);
31
+ this.cachedPath = this.stackPath.length <= 1 ? "" : this.stackPath[1] + (this.stackPath.length > 2 ? "." + this.stackPath.slice(2).join(".") : "");
47
32
  return popped;
48
33
  }
49
34
  clear() {
50
- this.stackErrors[this.stackErrors.length - 1] = [];
35
+ this.stackErrors[this.stackErrors.length - 1] = null;
51
36
  }
52
37
  error(message, path, details) {
53
- const errors = this.stackErrors[this.stackErrors.length - 1] || this.errors;
38
+ let errors = this.stackErrors[this.stackErrors.length - 1];
39
+ if (!errors) if (this.stackErrors.length > 0) {
40
+ errors = [];
41
+ this.stackErrors[this.stackErrors.length - 1] = errors;
42
+ } else errors = this.errors;
54
43
  const error = {
55
- path: path || this.path,
44
+ path: path || this.cachedPath,
56
45
  message
57
46
  };
58
47
  if (details?.length) error.details = details;
@@ -72,9 +61,10 @@ var Validator = class {
72
61
  * @returns `true` if the value matches the type definition.
73
62
  * @throws {ValidatorError} When validation fails and `safe` is not `true`.
74
63
  */ validate(value, safe, context) {
75
- this.push("");
76
64
  this.errors = [];
77
65
  this.stackErrors = [];
66
+ this.stackPath = [""];
67
+ this.cachedPath = "";
78
68
  this.context = context;
79
69
  const passed = this.validateSafe(this.def, value);
80
70
  this.pop(!passed);
@@ -88,7 +78,7 @@ var Validator = class {
88
78
  validateSafe(def, value) {
89
79
  if (this.isLimitExceeded()) return false;
90
80
  if (!isAnnotatedType(def)) throw new Error("Can not validate not-annotated type");
91
- if (typeof this.opts.replace === "function") def = this.opts.replace(def, this.path);
81
+ if (typeof this.opts.replace === "function") def = this.opts.replace(def, this.cachedPath);
92
82
  if (def.optional && value === undefined) return true;
93
83
  for (const plugin of this.opts.plugins) {
94
84
  const result = plugin(this, def, value);
@@ -97,18 +87,21 @@ var Validator = class {
97
87
  return this.validateAnnotatedType(def, value);
98
88
  }
99
89
  get path() {
100
- return this.stackPath.slice(1).join(".");
90
+ return this.cachedPath;
101
91
  }
102
92
  validateAnnotatedType(def, value) {
103
- return forAnnotatedType(def, {
104
- final: (d) => this.validatePrimitive(d, value),
105
- phantom: () => true,
106
- object: (d) => this.validateObject(d, value),
107
- array: (d) => this.validateArray(d, value),
108
- union: (d) => this.validateUnion(d, value),
109
- intersection: (d) => this.validateIntersection(d, value),
110
- tuple: (d) => this.validateTuple(d, value)
111
- });
93
+ switch (def.type.kind) {
94
+ case "": {
95
+ if (def.type.designType === "phantom") return true;
96
+ return this.validatePrimitive(def, value);
97
+ }
98
+ case "object": return this.validateObject(def, value);
99
+ case "array": return this.validateArray(def, value);
100
+ case "union": return this.validateUnion(def, value);
101
+ case "intersection": return this.validateIntersection(def, value);
102
+ case "tuple": return this.validateTuple(def, value);
103
+ default: throw new Error(`Unknown type kind "${def.type.kind}"`);
104
+ }
112
105
  }
113
106
  validateUnion(def, value) {
114
107
  let i = 0;
@@ -172,6 +165,30 @@ var Validator = class {
172
165
  return false;
173
166
  }
174
167
  }
168
+ const uniqueItems = def.metadata.get("expect.array.uniqueItems");
169
+ if (uniqueItems) {
170
+ const separator = "▼↩";
171
+ const seen = new Set();
172
+ const keyProps = new Set();
173
+ if (def.type.of.type.kind === "object") {
174
+ for (const [key, val] of def.type.of.type.props.entries()) if (val.metadata.get("expect.array.key")) keyProps.add(key);
175
+ }
176
+ for (let idx = 0; idx < value.length; idx++) {
177
+ const item = value[idx];
178
+ let key;
179
+ if (keyProps.size > 0) {
180
+ key = "";
181
+ for (const prop of keyProps) key += JSON.stringify(item[prop]) + separator;
182
+ } else key = JSON.stringify(item);
183
+ if (seen.has(key)) {
184
+ this.push(String(idx));
185
+ this.error(uniqueItems.message || "Duplicate items are not allowed");
186
+ this.pop(true);
187
+ return false;
188
+ }
189
+ seen.add(key);
190
+ }
191
+ }
175
192
  let i = 0;
176
193
  let passed = true;
177
194
  for (const item of value) {
@@ -193,21 +210,20 @@ var Validator = class {
193
210
  let passed = true;
194
211
  const valueKeys = new Set(Object.keys(value));
195
212
  const typeKeys = new Set();
196
- const skipList = new Set();
213
+ let skipList;
197
214
  if (this.opts.skipList) {
198
- const path = this.stackPath.length > 1 ? `${this.path}.` : "";
199
- this.opts.skipList.forEach((item) => {
200
- if (item.startsWith(path)) {
201
- const key = item.slice(path.length);
202
- skipList.add(key);
203
- valueKeys.delete(key);
204
- }
205
- });
215
+ const path = this.stackPath.length > 1 ? `${this.cachedPath}.` : "";
216
+ for (const item of this.opts.skipList) if (item.startsWith(path)) {
217
+ const key = item.slice(path.length);
218
+ if (!skipList) skipList = new Set();
219
+ skipList.add(key);
220
+ valueKeys.delete(key);
221
+ }
206
222
  }
207
223
  let partialFunctionMatched = false;
208
- if (typeof this.opts.partial === "function") partialFunctionMatched = this.opts.partial(def, this.path);
224
+ if (typeof this.opts.partial === "function") partialFunctionMatched = this.opts.partial(def, this.cachedPath);
209
225
  for (const [key, item] of def.type.props.entries()) {
210
- if (skipList.has(key) || isPhantomType(item)) continue;
226
+ if (skipList && skipList.has(key) || isPhantomType(item)) continue;
211
227
  typeKeys.add(key);
212
228
  if (value[key] === undefined) {
213
229
  if (partialFunctionMatched || this.opts.partial === "deep" || this.opts.partial === true && this.stackPath.length <= 1) continue;
@@ -228,19 +244,21 @@ else {
228
244
  def: propDef
229
245
  });
230
246
  if (matched.length > 0) {
247
+ this.push(key);
231
248
  let keyPassed = false;
232
- for (const { def: def$1 } of matched) if (this.validateSafe(def$1, value[key])) {
233
- this.pop(false);
234
- keyPassed = true;
235
- break;
249
+ for (const { def: propDef } of matched) {
250
+ if (this.validateSafe(propDef, value[key])) {
251
+ keyPassed = true;
252
+ break;
253
+ }
254
+ this.clear();
236
255
  }
237
256
  if (!keyPassed) {
238
- this.push(key);
239
257
  this.validateSafe(matched[0].def, value[key]);
240
258
  this.pop(true);
241
259
  passed = false;
242
260
  if (this.isLimitExceeded()) return false;
243
- }
261
+ } else this.pop(false);
244
262
  } else if (this.opts.unknownProps !== "ignore") {
245
263
  if (this.opts.unknownProps === "error") {
246
264
  this.push(key);
@@ -393,11 +411,13 @@ else {
393
411
  /** Validation errors collected during the last {@link validate} call. */ _define_property(this, "errors", void 0);
394
412
  _define_property(this, "stackErrors", void 0);
395
413
  _define_property(this, "stackPath", void 0);
414
+ _define_property(this, "cachedPath", void 0);
396
415
  _define_property(this, "context", void 0);
397
416
  this.def = def;
398
417
  this.errors = [];
399
418
  this.stackErrors = [];
400
419
  this.stackPath = [];
420
+ this.cachedPath = "";
401
421
  this.opts = {
402
422
  partial: false,
403
423
  unknownProps: "error",
@@ -415,6 +435,25 @@ var ValidatorError = class extends Error {
415
435
 
416
436
  //#endregion
417
437
  //#region packages/typescript/src/annotated-type.ts
438
+ const COMPLEX_KINDS = new Set([
439
+ "union",
440
+ "intersection",
441
+ "tuple"
442
+ ]);
443
+ const NON_PRIMITIVE_KINDS = new Set(["array", "object"]);
444
+ /** Shared validator method reused by all annotated type nodes. */ function validatorMethod(opts) {
445
+ return new Validator(this, opts);
446
+ }
447
+ function createAnnotatedTypeNode(type, metadata, opts) {
448
+ return {
449
+ __is_atscript_annotated_type: true,
450
+ type,
451
+ metadata,
452
+ validator: validatorMethod,
453
+ id: opts?.id,
454
+ optional: opts?.optional
455
+ };
456
+ }
418
457
  function isAnnotatedType(type) {
419
458
  return type && type.__is_atscript_annotated_type;
420
459
  }
@@ -433,32 +472,22 @@ function cloneRefProp(parentType, propName) {
433
472
  const existing = objType.props.get(propName);
434
473
  if (!existing) return;
435
474
  const clonedType = cloneTypeDef(existing.type);
436
- objType.props.set(propName, {
437
- __is_atscript_annotated_type: true,
438
- type: clonedType,
439
- metadata: new Map(existing.metadata),
475
+ objType.props.set(propName, createAnnotatedTypeNode(clonedType, new Map(existing.metadata), {
440
476
  id: existing.id,
441
- optional: existing.optional,
442
- validator(opts) {
443
- return new Validator(this, opts);
444
- }
445
- });
477
+ optional: existing.optional
478
+ }));
446
479
  }
447
480
  function cloneTypeDef(type) {
448
481
  if (type.kind === "object") {
449
482
  const obj = type;
483
+ const props = new Map();
484
+ for (const [k, v] of obj.props) props.set(k, createAnnotatedTypeNode(v.type, new Map(v.metadata), {
485
+ id: v.id,
486
+ optional: v.optional
487
+ }));
450
488
  return {
451
489
  kind: "object",
452
- props: new Map(Array.from(obj.props.entries()).map(([k, v]) => [k, {
453
- __is_atscript_annotated_type: true,
454
- type: v.type,
455
- metadata: new Map(v.metadata),
456
- id: v.id,
457
- optional: v.optional,
458
- validator(opts) {
459
- return new Validator(this, opts);
460
- }
461
- }])),
490
+ props,
462
491
  propsPatterns: [...obj.propsPatterns],
463
492
  tags: new Set(obj.tags)
464
493
  };
@@ -488,38 +517,24 @@ function defineAnnotatedType(_kind, base) {
488
517
  const kind = _kind || "";
489
518
  const type = base?.type || {};
490
519
  type.kind = kind;
491
- if ([
492
- "union",
493
- "intersection",
494
- "tuple"
495
- ].includes(kind)) type.items = [];
520
+ if (COMPLEX_KINDS.has(kind)) type.items = [];
496
521
  if (kind === "object") {
497
522
  type.props = new Map();
498
523
  type.propsPatterns = [];
499
524
  }
500
525
  type.tags = new Set();
501
526
  const metadata = base?.metadata || new Map();
502
- if (base) Object.assign(base, {
527
+ const payload = {
503
528
  __is_atscript_annotated_type: true,
504
529
  metadata,
505
530
  type,
506
- validator(opts) {
507
- return new Validator(this, opts);
508
- }
509
- });
510
- else base = {
511
- __is_atscript_annotated_type: true,
512
- metadata,
513
- type,
514
- validator(opts) {
515
- return new Validator(this, opts);
516
- }
531
+ validator: validatorMethod
517
532
  };
533
+ base = base ? Object.assign(base, payload) : payload;
518
534
  const handle = {
519
535
  $type: base,
520
536
  $def: type,
521
537
  $metadata: metadata,
522
- _existingObject: undefined,
523
538
  tags(...tags) {
524
539
  for (const tag of tags) this.$def.tags.add(tag);
525
540
  return this;
@@ -560,27 +575,18 @@ else base = {
560
575
  return this;
561
576
  },
562
577
  refTo(type$1, chain) {
578
+ if (!isAnnotatedType(type$1)) throw new Error(`${type$1} is not annotated type`);
563
579
  let newBase = type$1;
564
580
  const typeName = type$1.name || "Unknown";
565
- if (isAnnotatedType(newBase)) {
566
- let keys = "";
567
- for (const c of chain || []) {
568
- keys += `["${c}"]`;
569
- if (newBase.type.kind === "object" && newBase.type.props.has(c)) newBase = newBase.type.props.get(c);
570
- else throw new Error(`Can't find prop ${typeName}${keys}`);
581
+ if (chain) for (let i = 0; i < chain.length; i++) {
582
+ const c = chain[i];
583
+ if (newBase.type.kind === "object" && newBase.type.props.has(c)) newBase = newBase.type.props.get(c);
584
+ else {
585
+ const keys = chain.slice(0, i + 1).map((k) => `["${k}"]`).join("");
586
+ throw new Error(`Can't find prop ${typeName}${keys}`);
571
587
  }
572
- if (!newBase && keys) throw new Error(`Can't find prop ${typeName}${keys}`);
573
- else if (!newBase) throw new Error(`"${typeName}" is not annotated type`);
574
- this.$type = {
575
- __is_atscript_annotated_type: true,
576
- type: newBase.type,
577
- metadata,
578
- id: newBase.id,
579
- validator(opts) {
580
- return new Validator(this, opts);
581
- }
582
- };
583
- } else throw new Error(`${type$1} is not annotated type`);
588
+ }
589
+ this.$type = createAnnotatedTypeNode(newBase.type, metadata, { id: newBase.id });
584
590
  return this;
585
591
  },
586
592
  annotate(key, value, asArray) {
@@ -598,19 +604,33 @@ function isPhantomType(def) {
598
604
  return def.type.kind === "" && def.type.designType === "phantom";
599
605
  }
600
606
  function isAnnotatedTypeOfPrimitive(t) {
601
- if (["array", "object"].includes(t.type.kind)) return false;
607
+ if (NON_PRIMITIVE_KINDS.has(t.type.kind)) return false;
602
608
  if (!t.type.kind) return true;
603
- if ([
604
- "union",
605
- "tuple",
606
- "intersection"
607
- ].includes(t.type.kind)) {
609
+ if (COMPLEX_KINDS.has(t.type.kind)) {
608
610
  for (const item of t.type.items) if (!isAnnotatedTypeOfPrimitive(item)) return false;
609
611
  return true;
610
612
  }
611
613
  return false;
612
614
  }
613
615
 
616
+ //#endregion
617
+ //#region packages/typescript/src/traverse.ts
618
+ function forAnnotatedType(def, handlers) {
619
+ switch (def.type.kind) {
620
+ case "": {
621
+ const typed = def;
622
+ if (handlers.phantom && typed.type.designType === "phantom") return handlers.phantom(typed);
623
+ return handlers.final(typed);
624
+ }
625
+ case "object": return handlers.object(def);
626
+ case "array": return handlers.array(def);
627
+ case "union": return handlers.union(def);
628
+ case "intersection": return handlers.intersection(def);
629
+ case "tuple": return handlers.tuple(def);
630
+ default: throw new Error(`Unknown type kind "${def.type.kind}"`);
631
+ }
632
+ }
633
+
614
634
  //#endregion
615
635
  //#region packages/typescript/src/json-schema.ts
616
636
  /**
@@ -625,10 +645,10 @@ function isAnnotatedTypeOfPrimitive(t) {
625
645
  const firstObj = items[0].type;
626
646
  const candidates = [];
627
647
  for (const [propName, propType] of firstObj.props.entries()) if (propType.type.kind === "" && propType.type.value !== undefined) candidates.push(propName);
628
- const validCandidates = [];
648
+ let result = null;
629
649
  for (const candidate of candidates) {
630
650
  const values = new Set();
631
- const mapping = {};
651
+ const indexMapping = {};
632
652
  let valid = true;
633
653
  for (let i = 0; i < items.length; i++) {
634
654
  const obj = items[i].type;
@@ -643,19 +663,21 @@ function isAnnotatedTypeOfPrimitive(t) {
643
663
  break;
644
664
  }
645
665
  values.add(val);
646
- mapping[String(val)] = `#/oneOf/${i}`;
666
+ indexMapping[String(val)] = i;
667
+ }
668
+ if (valid) {
669
+ if (result) return null;
670
+ result = {
671
+ propertyName: candidate,
672
+ indexMapping
673
+ };
647
674
  }
648
- if (valid) validCandidates.push({
649
- propertyName: candidate,
650
- mapping
651
- });
652
675
  }
653
- if (validCandidates.length === 1) return validCandidates[0];
654
- return null;
676
+ return result;
655
677
  }
656
678
  function buildJsonSchema(type) {
657
679
  const defs = {};
658
- let isRoot = true;
680
+ let hasDefs = false;
659
681
  const buildObject = (d) => {
660
682
  const properties = {};
661
683
  const required = [];
@@ -672,15 +694,15 @@ function buildJsonSchema(type) {
672
694
  return schema$1;
673
695
  };
674
696
  const build$1 = (def) => {
675
- if (def.id && def.type.kind === "object" && !isRoot) {
697
+ if (def.id && def.type.kind === "object" && def !== type) {
676
698
  const name = def.id;
677
699
  if (!defs[name]) {
700
+ hasDefs = true;
678
701
  defs[name] = {};
679
702
  defs[name] = buildObject(def);
680
703
  }
681
704
  return { $ref: `#/$defs/${name}` };
682
705
  }
683
- isRoot = false;
684
706
  const meta = def.metadata;
685
707
  return forAnnotatedType(def, {
686
708
  phantom() {
@@ -705,11 +727,9 @@ function buildJsonSchema(type) {
705
727
  if (disc) {
706
728
  const oneOf = d.type.items.map(build$1);
707
729
  const mapping = {};
708
- for (const [val, origPath] of Object.entries(disc.mapping)) {
709
- const idx = Number.parseInt(origPath.split("/").pop(), 10);
730
+ for (const [val, idx] of Object.entries(disc.indexMapping)) {
710
731
  const item = d.type.items[idx];
711
- if (item.id && defs[item.id]) mapping[val] = `#/$defs/${item.id}`;
712
- else mapping[val] = origPath;
732
+ mapping[val] = item.id && defs[item.id] ? `#/$defs/${item.id}` : `#/oneOf/${idx}`;
713
733
  }
714
734
  return {
715
735
  oneOf,
@@ -759,7 +779,7 @@ else schema$1.allOf = (schema$1.allOf || []).concat(patterns.map((p) => ({ patte
759
779
  });
760
780
  };
761
781
  const schema = build$1(type);
762
- if (Object.keys(defs).length > 0) return {
782
+ if (hasDefs) return {
763
783
  ...schema,
764
784
  $defs: defs
765
785
  };
@@ -774,6 +794,8 @@ function fromJsonSchema(schema) {
774
794
  const refName = s.$ref.replace(/^#\/(\$defs|definitions)\//, "");
775
795
  if (resolved.has(refName)) return resolved.get(refName);
776
796
  if (defsSource[refName]) {
797
+ const placeholder = defineAnnotatedType().designType("any").$type;
798
+ resolved.set(refName, placeholder);
777
799
  const type = convert(defsSource[refName]);
778
800
  resolved.set(refName, type);
779
801
  return type;
@@ -1184,16 +1206,10 @@ function deserializeAnnotatedType(data) {
1184
1206
  function deserializeNode(data) {
1185
1207
  const metadata = new Map(Object.entries(data.metadata));
1186
1208
  const type = deserializeTypeDef(data.type);
1187
- const result = {
1188
- __is_atscript_annotated_type: true,
1189
- type,
1190
- metadata,
1191
- validator(opts) {
1192
- return new Validator(this, opts);
1193
- }
1194
- };
1195
- if (data.optional) result.optional = true;
1196
- if (data.id) result.id = data.id;
1209
+ const result = createAnnotatedTypeNode(type, metadata, {
1210
+ optional: data.optional || undefined,
1211
+ id: data.id || undefined
1212
+ });
1197
1213
  return result;
1198
1214
  }
1199
1215
  function deserializeTypeDef(t) {
@@ -1239,4 +1255,4 @@ function deserializeTypeDef(t) {
1239
1255
  }
1240
1256
 
1241
1257
  //#endregion
1242
- export { SERIALIZE_VERSION, Validator, ValidatorError, annotate, buildJsonSchema, cloneRefProp, createDataFromAnnotatedType, defineAnnotatedType, deserializeAnnotatedType, flattenAnnotatedType, forAnnotatedType, fromJsonSchema, isAnnotatedType, isAnnotatedTypeOfPrimitive, isPhantomType, mergeJsonSchemas, serializeAnnotatedType, throwFeatureDisabled };
1258
+ export { SERIALIZE_VERSION, Validator, ValidatorError, annotate, buildJsonSchema, cloneRefProp, createAnnotatedTypeNode, createDataFromAnnotatedType, defineAnnotatedType, deserializeAnnotatedType, flattenAnnotatedType, forAnnotatedType, fromJsonSchema, isAnnotatedType, isAnnotatedTypeOfPrimitive, isPhantomType, mergeJsonSchemas, serializeAnnotatedType, throwFeatureDisabled };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/typescript",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Atscript: typescript-gen support.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -64,7 +64,7 @@
64
64
  "vitest": "3.2.4"
65
65
  },
66
66
  "peerDependencies": {
67
- "@atscript/core": "^0.1.31"
67
+ "@atscript/core": "^0.1.33"
68
68
  },
69
69
  "build": [
70
70
  {},
@@ -17,7 +17,7 @@
17
17
  | `@meta.required` | `message?: string` | Required field. Strings: non-whitespace. Booleans: must be `true` |
18
18
  | `@meta.default` | `value: string` | Default value (strings as-is, others parsed as JSON) |
19
19
  | `@meta.example` | `value: string` | Example value (strings as-is, others parsed as JSON) |
20
- | `@expect.array.key` | _(none)_ | Mark field as key inside array (string/number types only) |
20
+ | `@expect.array.key` | `message?: string` | Mark field as key inside array (string/number only, non-optional). Multiple = composite key |
21
21
 
22
22
  ### `@expect.*` — Validation Constraints
23
23
 
@@ -29,6 +29,7 @@
29
29
  | `@expect.max` | `maxValue: number`, `message?: string` | number | Maximum value |
30
30
  | `@expect.int` | _(none)_ | number | Must be integer |
31
31
  | `@expect.pattern` | `pattern: string`, `flags?: string`, `message?: string` | string | Regex validation. **Multiple allowed** (all must pass) |
32
+ | `@expect.array.uniqueItems` | `message?: string` | array | No duplicate items (by key fields or deep equality) |
32
33
 
33
34
  ### `@ui.*` — UI / Presentation Hints
34
35