schema-dsl 1.2.5 → 2.0.1

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 (243) hide show
  1. package/CHANGELOG.md +130 -238
  2. package/LICENSE +21 -21
  3. package/README.md +628 -2486
  4. package/dist/DslBuilder-BIgQOAXp.d.ts +343 -0
  5. package/dist/DslBuilder-CjHTucNQ.d.cts +343 -0
  6. package/dist/Validator-CllRdrY0.d.ts +192 -0
  7. package/dist/Validator-D6okG9tr.d.cts +192 -0
  8. package/dist/index.cjs +6640 -0
  9. package/dist/index.d.cts +1151 -0
  10. package/dist/index.d.ts +1151 -0
  11. package/dist/index.js +6574 -0
  12. package/dist/plugin-CIKtTMtS.d.cts +246 -0
  13. package/dist/plugin-CIKtTMtS.d.ts +246 -0
  14. package/dist/plugins/custom-format.cjs +3818 -0
  15. package/dist/plugins/custom-format.d.cts +12 -0
  16. package/dist/plugins/custom-format.d.ts +12 -0
  17. package/dist/plugins/custom-format.js +3788 -0
  18. package/dist/plugins/custom-type-example.cjs +3811 -0
  19. package/dist/plugins/custom-type-example.d.cts +8 -0
  20. package/dist/plugins/custom-type-example.d.ts +8 -0
  21. package/dist/plugins/custom-type-example.js +3781 -0
  22. package/dist/plugins/custom-validator.cjs +144 -0
  23. package/dist/plugins/custom-validator.d.cts +10 -0
  24. package/dist/plugins/custom-validator.d.ts +10 -0
  25. package/dist/plugins/custom-validator.js +119 -0
  26. package/docs/FEATURE-INDEX.md +553 -519
  27. package/docs/add-custom-locale.md +496 -483
  28. package/docs/add-keyword.md +24 -0
  29. package/docs/api-reference.md +1047 -805
  30. package/docs/api.md +13 -0
  31. package/docs/best-practices-project-structure.md +417 -408
  32. package/docs/best-practices.md +712 -672
  33. package/docs/cache-manager.md +344 -336
  34. package/docs/compile.md +45 -0
  35. package/docs/conditional-api.md +1307 -1278
  36. package/docs/custom-extensions-guide.md +339 -411
  37. package/docs/design-philosophy.md +606 -601
  38. package/docs/doc-index.md +324 -0
  39. package/docs/dsl-syntax.md +714 -664
  40. package/docs/dynamic-locale.md +608 -598
  41. package/docs/enum.md +482 -475
  42. package/docs/error-handling.md +1975 -1966
  43. package/docs/export-guide.md +501 -462
  44. package/docs/export-limitations.md +567 -551
  45. package/docs/faq.md +596 -577
  46. package/docs/frontend-i18n-guide.md +307 -293
  47. package/docs/i18n-user-guide.md +487 -474
  48. package/docs/i18n.md +476 -457
  49. package/docs/index.md +48 -0
  50. package/docs/json-schema-basics.md +40 -0
  51. package/docs/label-vs-description.md +271 -262
  52. package/docs/markdown-exporter.md +406 -397
  53. package/docs/mongodb-exporter.md +302 -295
  54. package/docs/multi-language.md +26 -0
  55. package/docs/multi-type-support.md +322 -329
  56. package/docs/mysql-exporter.md +280 -273
  57. package/docs/number-operators.md +449 -442
  58. package/docs/optional-marker-guide.md +326 -321
  59. package/docs/performance-guide.md +49 -0
  60. package/docs/plugin-system.md +381 -542
  61. package/docs/plugin-type-registration.md +34 -0
  62. package/docs/postgresql-exporter.md +311 -304
  63. package/docs/public/favicon.svg +5 -0
  64. package/docs/quick-start.md +435 -761
  65. package/docs/runtime-locale-support.md +532 -521
  66. package/docs/schema-helper.md +345 -340
  67. package/docs/schema-utils-advanced-issues.md +23 -0
  68. package/docs/schema-utils-best-practices.md +20 -0
  69. package/docs/schema-utils-chaining.md +150 -143
  70. package/docs/schema-utils.md +524 -490
  71. package/docs/security-checklist.md +20 -0
  72. package/docs/string-extensions.md +488 -480
  73. package/docs/troubleshooting.md +486 -471
  74. package/docs/type-converter.md +310 -319
  75. package/docs/type-reference.md +242 -219
  76. package/docs/typescript-guide.md +584 -573
  77. package/docs/union-type-guide.md +157 -147
  78. package/docs/union-types.md +284 -277
  79. package/docs/validate-async.md +491 -480
  80. package/docs/validate-batch.md +49 -0
  81. package/docs/validate-dsl-object-support.md +578 -573
  82. package/docs/validate.md +506 -486
  83. package/docs/validation-guide.md +502 -484
  84. package/docs/validator.md +39 -0
  85. package/package.json +131 -73
  86. package/plugins/custom-format.cjs +8 -0
  87. package/plugins/custom-type-example.cjs +8 -0
  88. package/plugins/custom-validator.cjs +8 -0
  89. package/src/adapters/DslAdapter.ts +111 -0
  90. package/src/adapters/index.ts +1 -0
  91. package/src/config/constants.ts +83 -0
  92. package/src/config/index.ts +2 -0
  93. package/src/config/patterns.ts +77 -0
  94. package/src/core/CacheManager.ts +169 -0
  95. package/src/core/ConditionalBuilder.ts +382 -0
  96. package/src/core/ConditionalRuntime.ts +28 -0
  97. package/src/core/ConditionalValidator.ts +255 -0
  98. package/src/core/DslBuilder.ts +687 -0
  99. package/src/core/ErrorCodes.ts +38 -0
  100. package/src/core/ErrorFormatter.ts +271 -0
  101. package/src/core/JSONSchemaCore.ts +65 -0
  102. package/src/core/Locale.ts +187 -0
  103. package/src/core/MessageTemplate.ts +42 -0
  104. package/src/core/ObjectDslBuilder.ts +64 -0
  105. package/src/core/PluginManager.ts +326 -0
  106. package/src/core/StringExtensions.ts +140 -0
  107. package/src/core/TemplateEngine.ts +44 -0
  108. package/src/core/Validator.ts +448 -0
  109. package/src/errors/I18nError.ts +159 -0
  110. package/src/errors/ValidationError.ts +105 -0
  111. package/src/exporters/BaseExporter.ts +60 -0
  112. package/src/exporters/MarkdownExporter.ts +305 -0
  113. package/src/exporters/MongoDBExporter.ts +126 -0
  114. package/src/exporters/MySQLExporter.ts +156 -0
  115. package/src/exporters/PostgreSQLExporter.ts +222 -0
  116. package/src/exporters/index.ts +18 -0
  117. package/src/index.ts +651 -0
  118. package/{lib/locales/en-US.js → src/locales/en-US.ts} +160 -176
  119. package/{lib/locales/es-ES.js → src/locales/es-ES.ts} +160 -113
  120. package/{lib/locales/fr-FR.js → src/locales/fr-FR.ts} +160 -113
  121. package/src/locales/index.ts +103 -0
  122. package/{lib/locales/ja-JP.js → src/locales/ja-JP.ts} +160 -118
  123. package/src/locales/types.ts +156 -0
  124. package/{lib/locales/zh-CN.js → src/locales/zh-CN.ts} +160 -177
  125. package/src/parser/ConstraintParser.ts +101 -0
  126. package/src/parser/DslParser.ts +470 -0
  127. package/src/parser/SchemaCompiler.ts +66 -0
  128. package/src/parser/TypeRegistry.ts +250 -0
  129. package/src/parser/index.ts +6 -0
  130. package/src/plugins/custom-format.ts +124 -0
  131. package/src/plugins/custom-type-example.ts +106 -0
  132. package/src/plugins/custom-validator.ts +138 -0
  133. package/src/types/conditional.ts +28 -0
  134. package/src/types/config.ts +59 -0
  135. package/src/types/dsl.ts +131 -0
  136. package/src/types/error.ts +60 -0
  137. package/src/types/index.ts +17 -0
  138. package/src/types/infer.ts +128 -0
  139. package/src/types/plugin.ts +58 -0
  140. package/src/types/safe-regex.d.ts +9 -0
  141. package/src/types/schema.ts +66 -0
  142. package/src/types/validate.ts +71 -0
  143. package/src/utils/SchemaHelper.ts +196 -0
  144. package/src/utils/SchemaUtils.ts +365 -0
  145. package/src/utils/TypeConverter.ts +215 -0
  146. package/src/utils/index.ts +10 -0
  147. package/src/validators/CustomKeywords.ts +477 -0
  148. package/.eslintignore +0 -11
  149. package/.eslintrc.json +0 -27
  150. package/CONTRIBUTING.md +0 -368
  151. package/STATUS.md +0 -491
  152. package/changelogs/v1.0.0.md +0 -328
  153. package/changelogs/v1.0.9.md +0 -367
  154. package/changelogs/v1.1.0.md +0 -389
  155. package/changelogs/v1.1.1.md +0 -308
  156. package/changelogs/v1.1.2.md +0 -183
  157. package/changelogs/v1.1.3.md +0 -161
  158. package/changelogs/v1.1.4.md +0 -432
  159. package/changelogs/v1.1.5.md +0 -493
  160. package/changelogs/v1.1.6.md +0 -211
  161. package/changelogs/v1.1.8.md +0 -376
  162. package/changelogs/v1.2.3.md +0 -124
  163. package/docs/INDEX.md +0 -252
  164. package/docs/issues-resolved-summary.md +0 -196
  165. package/docs/performance-benchmark-report.md +0 -179
  166. package/docs/performance-quick-reference.md +0 -123
  167. package/docs/user-questions-answered.md +0 -353
  168. package/docs/validation-rules-v1.0.2.md +0 -1608
  169. package/examples/README.md +0 -81
  170. package/examples/array-dsl-example.js +0 -227
  171. package/examples/conditional-example.js +0 -288
  172. package/examples/conditional-non-object.js +0 -129
  173. package/examples/conditional-validate-example.js +0 -321
  174. package/examples/custom-extension.js +0 -85
  175. package/examples/dsl-match-example.js +0 -74
  176. package/examples/dsl-style.js +0 -118
  177. package/examples/dynamic-locale-configuration.js +0 -348
  178. package/examples/dynamic-locale-example.js +0 -287
  179. package/examples/enum.examples.js +0 -324
  180. package/examples/export-demo.js +0 -130
  181. package/examples/express-integration.js +0 -376
  182. package/examples/i18n-error-handling-complete.js +0 -381
  183. package/examples/i18n-error-handling-quickstart.md +0 -0
  184. package/examples/i18n-error.examples.js +0 -181
  185. package/examples/i18n-full-demo.js +0 -301
  186. package/examples/i18n-memory-safety.examples.js +0 -268
  187. package/examples/markdown-export.js +0 -71
  188. package/examples/middleware-usage.js +0 -93
  189. package/examples/new-features-comparison.js +0 -315
  190. package/examples/password-reset/README.md +0 -153
  191. package/examples/password-reset/schema.js +0 -26
  192. package/examples/password-reset/test.js +0 -101
  193. package/examples/plugin-system.examples.js +0 -205
  194. package/examples/schema-utils-chaining.examples.js +0 -250
  195. package/examples/simple-example.js +0 -122
  196. package/examples/slug.examples.js +0 -179
  197. package/examples/string-extensions.js +0 -297
  198. package/examples/union-type-example.js +0 -127
  199. package/examples/union-types-example.js +0 -77
  200. package/examples/user-registration/README.md +0 -156
  201. package/examples/user-registration/routes.js +0 -92
  202. package/examples/user-registration/schema.js +0 -150
  203. package/examples/user-registration/server.js +0 -74
  204. package/index.d.ts +0 -3658
  205. package/index.js +0 -475
  206. package/index.mjs +0 -60
  207. package/lib/adapters/DslAdapter.js +0 -995
  208. package/lib/adapters/index.js +0 -20
  209. package/lib/config/constants.js +0 -286
  210. package/lib/config/patterns/common.js +0 -47
  211. package/lib/config/patterns/creditCard.js +0 -9
  212. package/lib/config/patterns/idCard.js +0 -9
  213. package/lib/config/patterns/index.js +0 -9
  214. package/lib/config/patterns/licensePlate.js +0 -4
  215. package/lib/config/patterns/passport.js +0 -4
  216. package/lib/config/patterns/phone.js +0 -9
  217. package/lib/config/patterns/postalCode.js +0 -5
  218. package/lib/core/CacheManager.js +0 -376
  219. package/lib/core/ConditionalBuilder.js +0 -503
  220. package/lib/core/DslBuilder.js +0 -1589
  221. package/lib/core/ErrorCodes.js +0 -233
  222. package/lib/core/ErrorFormatter.js +0 -445
  223. package/lib/core/JSONSchemaCore.js +0 -347
  224. package/lib/core/Locale.js +0 -130
  225. package/lib/core/MessageTemplate.js +0 -98
  226. package/lib/core/PluginManager.js +0 -448
  227. package/lib/core/StringExtensions.js +0 -240
  228. package/lib/core/Validator.js +0 -654
  229. package/lib/errors/I18nError.js +0 -328
  230. package/lib/errors/ValidationError.js +0 -191
  231. package/lib/exporters/MarkdownExporter.js +0 -420
  232. package/lib/exporters/MongoDBExporter.js +0 -162
  233. package/lib/exporters/MySQLExporter.js +0 -212
  234. package/lib/exporters/PostgreSQLExporter.js +0 -289
  235. package/lib/exporters/index.js +0 -24
  236. package/lib/locales/index.js +0 -8
  237. package/lib/utils/LRUCache.js +0 -174
  238. package/lib/utils/SchemaHelper.js +0 -240
  239. package/lib/utils/SchemaUtils.js +0 -445
  240. package/lib/utils/TypeConverter.js +0 -245
  241. package/lib/utils/index.js +0 -13
  242. package/lib/validators/CustomKeywords.js +0 -616
  243. package/lib/validators/index.js +0 -11
@@ -0,0 +1,169 @@
1
+ import { MemoryCache } from 'cache-hub'
2
+ import { CACHE } from '../config/constants.js'
3
+
4
+ type CacheValue = unknown
5
+
6
+ export interface CacheStats {
7
+ hits: number
8
+ misses: number
9
+ sets: number
10
+ deletes: number
11
+ evictions: number
12
+ clears: number
13
+ hitRate: string
14
+ size: number
15
+ maxSize: number
16
+ enabled: boolean
17
+ }
18
+
19
+ /**
20
+ * CacheManager — LRU cache for compiled AJV schemas.
21
+ *
22
+ * v2 delegates to cache-hub's MemoryCache (fix BD-04: miss returns undefined → normalized to null).
23
+ *
24
+ * cache-hub MemoryCache actual API:
25
+ * get(key) → value | undefined
26
+ * set(key, value, opts?) — opts.ttl in ms
27
+ * del(key) → boolean ← note: del, not delete
28
+ * has(key) → boolean
29
+ * clear() → void
30
+ * keys() → string[]
31
+ * getStats() → { hits, misses, hitRate, entries, sets, deletes, evictions, memoryUsage }
32
+ */
33
+ export class CacheManager {
34
+ private _enabled: boolean
35
+ private _maxSize: number
36
+ private _ttl: number
37
+ private _cache: MemoryCache
38
+ private _statsEnabled: boolean = true
39
+ private _clears = 0
40
+
41
+ constructor(options: {
42
+ maxSize?: number
43
+ ttl?: number
44
+ enabled?: boolean
45
+ statsEnabled?: boolean
46
+ } = {}) {
47
+ this._maxSize = options.maxSize ?? CACHE.SCHEMA_CACHE.MAX_SIZE
48
+ this._ttl = options.ttl ?? CACHE.SCHEMA_CACHE.TTL
49
+ this._enabled = options.enabled !== false
50
+ this._cache = new MemoryCache({ maxEntries: this._maxSize })
51
+ this._statsEnabled = options.statsEnabled !== false
52
+ }
53
+
54
+ get options(): { maxSize: number; ttl: number; enabled: boolean; statsEnabled: boolean } {
55
+ return {
56
+ maxSize: this._maxSize,
57
+ ttl: this._ttl,
58
+ enabled: this._enabled,
59
+ statsEnabled: this._statsEnabled,
60
+ }
61
+ }
62
+
63
+ set options(opts: Partial<{ maxSize: number; ttl: number; enabled: boolean; statsEnabled: boolean }>) {
64
+ if (opts.maxSize !== undefined && opts.maxSize !== this._maxSize) {
65
+ this._maxSize = opts.maxSize
66
+ // Rebuild MemoryCache so the new capacity actually takes effect
67
+ const oldKeys = this._cache.keys()
68
+ const newCache = new MemoryCache({ maxEntries: this._maxSize })
69
+ for (const key of oldKeys) {
70
+ const val = this._cache.get(key)
71
+ if (val !== undefined) newCache.set(key, val)
72
+ }
73
+ this._cache = newCache
74
+ }
75
+ if (opts.ttl !== undefined) this._ttl = opts.ttl
76
+ if (opts.enabled !== undefined) this._enabled = opts.enabled
77
+ if (opts.statsEnabled !== undefined) this._statsEnabled = opts.statsEnabled
78
+ }
79
+
80
+ /**
81
+ * Retrieve a cached AJV compile function.
82
+ * @returns cached compile function, or null on miss (BD-04: undefined → null)
83
+ */
84
+ get(key: string): CacheValue | null {
85
+ if (!this._enabled || key == null) return null
86
+ try {
87
+ const result = this._cache.get(String(key)) as CacheValue | undefined
88
+ return result !== undefined ? result : null
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Write a value to the cache.
96
+ */
97
+ set(key: string, value: CacheValue, ttl?: number): void {
98
+ if (!this._enabled || key == null) return
99
+ try {
100
+ this._cache.set(String(key), value, ttl ?? this._ttl)
101
+ } catch {
102
+ // Silently ignore invalid keys for v1 compat
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Delete a single cache entry.
108
+ */
109
+ delete(key: string): boolean {
110
+ return this._cache.del(key)
111
+ }
112
+
113
+ /**
114
+ * Check whether a key exists in the cache.
115
+ */
116
+ has(key: string): boolean {
117
+ if (!this._enabled) return false
118
+ return this._cache.has(key)
119
+ }
120
+
121
+ /**
122
+ * Clear all cache entries.
123
+ */
124
+ clear(): void {
125
+ this._cache.clear()
126
+ this._clears++
127
+ }
128
+
129
+ /**
130
+ * Return the current number of cache entries.
131
+ */
132
+ size(): number {
133
+ return this._cache.keys().length
134
+ }
135
+
136
+ /**
137
+ * Return cache statistics.
138
+ */
139
+ getStats(): CacheStats {
140
+ if (!this._statsEnabled) {
141
+ return {
142
+ hits: 0, misses: 0, sets: 0, deletes: 0, evictions: 0,
143
+ clears: 0, hitRate: '0.00', size: 0, maxSize: this._maxSize, enabled: this._enabled,
144
+ }
145
+ }
146
+ const inner = this._cache.getStats()
147
+ const total = inner.hits + inner.misses
148
+ return {
149
+ hits: inner.hits,
150
+ misses: inner.misses,
151
+ sets: inner.sets,
152
+ deletes: inner.deletes,
153
+ evictions: (inner as unknown as Record<string, unknown>).evictions as number ?? 0,
154
+ clears: this._clears,
155
+ hitRate: total > 0 ? ((inner.hits / total) * 100).toFixed(2) : '0.00',
156
+ size: inner.entries,
157
+ maxSize: this._maxSize,
158
+ enabled: this._enabled,
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Reset all hit/miss/eviction counters.
164
+ */
165
+ resetStats(): void {
166
+ this._cache.resetStats()
167
+ this._clears = 0
168
+ }
169
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * ConditionalBuilder — chainable condition builder.
3
+ *
4
+ * v2 fixes:
5
+ * C-03: assert() throws ValidationError instead of plain Error
6
+ * C-Y01: elseIf semantics correct
7
+ * C-Y02: build() as toSchema() alias (IConditionalBuilder interface compat)
8
+ */
9
+
10
+ import type { JSONSchema } from '../types/schema.js'
11
+ import type { IConditionalBuilder } from '../types/conditional.js'
12
+ import type { ValidateOptions, ValidationResult } from '../types/validate.js'
13
+ import { ValidationError } from '../errors/ValidationError.js'
14
+ import { Validator } from './Validator.js'
15
+ import { Locale } from './Locale.js'
16
+ import { attachConditionalRuntime } from './ConditionalRuntime.js'
17
+
18
+ const RUNTIME_ONLY_VALIDATE_OPTION_KEYS = new Set(['locale', 'messages', 'format'])
19
+
20
+ // ==================== Internal Data Structures ====================
21
+
22
+ type ConditionFn = (data: unknown) => boolean
23
+
24
+ interface CombinedCondition {
25
+ op: 'root' | 'and' | 'or'
26
+ fn: ConditionFn
27
+ message: string | null
28
+ }
29
+
30
+ interface ConditionEntry {
31
+ type: 'if' | 'elseIf'
32
+ condition: ConditionFn
33
+ combinedConditions: CombinedCondition[]
34
+ message?: string
35
+ action?: 'throw'
36
+ then?: string | JSONSchema | null
37
+ }
38
+
39
+ interface EvaluateResult {
40
+ result: boolean
41
+ failedMessage: string | null
42
+ requirementFailed?: boolean
43
+ }
44
+
45
+ // ==================== ConditionalBuilder ====================
46
+
47
+ export class ConditionalBuilder implements IConditionalBuilder {
48
+ private _conditions: ConditionEntry[]
49
+ private _elseSchema: string | JSONSchema | null | undefined
50
+
51
+ constructor() {
52
+ this._conditions = []
53
+ this._elseSchema = undefined
54
+ }
55
+
56
+ // ==================== Condition Chain Methods ====================
57
+
58
+ if(conditionFn: ConditionFn | string): this {
59
+ // v1 compat: accept string field name and convert to function
60
+ if (typeof conditionFn === 'string') {
61
+ const fieldName = conditionFn
62
+ conditionFn = ((data: unknown) => Boolean((data as Record<string, unknown>)[fieldName])) as ConditionFn
63
+ }
64
+ if (typeof conditionFn !== 'function') {
65
+ throw new Error('[schema-dsl] Condition must be a function')
66
+ }
67
+ this._conditions.push({
68
+ type: 'if',
69
+ condition: conditionFn,
70
+ combinedConditions: [{ op: 'root', fn: conditionFn, message: null }],
71
+ })
72
+ return this
73
+ }
74
+
75
+ and(conditionFn: ConditionFn): this {
76
+ if (typeof conditionFn !== 'function') {
77
+ throw new Error('[schema-dsl] Condition must be a function')
78
+ }
79
+ const last = this._conditions[this._conditions.length - 1]
80
+ if (!last) throw new Error('[schema-dsl] .and() must follow .if() or .elseIf()')
81
+ last.combinedConditions.push({ op: 'and', fn: conditionFn, message: null })
82
+ return this
83
+ }
84
+
85
+ /**
86
+ * require(field) — v1 compat: require the specified field to be truthy.
87
+ * Equivalent to .and(data => Boolean(data[field])).
88
+ * BC-5 fix.
89
+ */
90
+ require(field: string): this {
91
+ return this.and((data: unknown) => Boolean((data as Record<string, unknown>)[field]))
92
+ }
93
+
94
+ or(conditionFn: ConditionFn): this {
95
+ if (typeof conditionFn !== 'function') {
96
+ throw new Error('[schema-dsl] Condition must be a function')
97
+ }
98
+ const last = this._conditions[this._conditions.length - 1]
99
+ if (!last) throw new Error('[schema-dsl] .or() must follow .if() or .elseIf()')
100
+ last.combinedConditions.push({ op: 'or', fn: conditionFn, message: null })
101
+ return this
102
+ }
103
+
104
+ elseIf(conditionFn: ConditionFn): this {
105
+ if (typeof conditionFn !== 'function') {
106
+ throw new Error('[schema-dsl] Condition must be a function')
107
+ }
108
+ if (this._conditions.length === 0) {
109
+ throw new Error('[schema-dsl] .elseIf() must follow .if()')
110
+ }
111
+ this._conditions.push({
112
+ type: 'elseIf',
113
+ condition: conditionFn,
114
+ combinedConditions: [{ op: 'root', fn: conditionFn, message: null }],
115
+ })
116
+ return this
117
+ }
118
+
119
+ message(msg: string): this {
120
+ if (typeof msg !== 'string') {
121
+ throw new Error('[schema-dsl] Message must be a string')
122
+ }
123
+ const last = this._conditions[this._conditions.length - 1]
124
+ if (!last) throw new Error('[schema-dsl] .message() must follow .if() or .elseIf()')
125
+
126
+ const lastCombined = last.combinedConditions[last.combinedConditions.length - 1]
127
+ if (lastCombined) {
128
+ lastCombined.message = msg
129
+ }
130
+ last.message = msg
131
+ last.action = 'throw'
132
+ return this
133
+ }
134
+
135
+ then(schema: string | JSONSchema | null): this {
136
+ const last = this._conditions[this._conditions.length - 1]
137
+ if (!last) throw new Error('[schema-dsl] .then() must follow .if() or .elseIf()')
138
+ last.then = schema
139
+ return this
140
+ }
141
+
142
+ else(schema: string | JSONSchema | null): this {
143
+ this._elseSchema = schema
144
+ return this
145
+ }
146
+
147
+ // ==================== Output Methods ====================
148
+
149
+ /**
150
+ * Produce a schema object carrying serialisable conditional metadata plus non-enumerable runtime state.
151
+ */
152
+ toSchema(): JSONSchema {
153
+ return attachConditionalRuntime({
154
+ _isConditional: true,
155
+ _runtimeOnlyConditional: true,
156
+ else: this._elseSchema,
157
+ } as unknown as JSONSchema, {
158
+ conditions: this._conditions,
159
+ elseSchema: this._elseSchema,
160
+ evaluateCondition: (conditionObj: unknown, data: unknown) =>
161
+ this._evaluateCondition(conditionObj as ConditionEntry, data),
162
+ }) as unknown as JSONSchema
163
+ }
164
+
165
+ /**
166
+ * build() — alias for toSchema() (IConditionalBuilder interface compat).
167
+ */
168
+ build(): JSONSchema {
169
+ return this.toSchema()
170
+ }
171
+
172
+ // ==================== Validation Methods ====================
173
+
174
+ private readonly _validatorCache = new Map<string, Validator>()
175
+ private static readonly _VALIDATOR_CACHE_MAX = 20
176
+
177
+ validate(data: unknown, options: Record<string, unknown> = {}): ValidationResult<unknown> {
178
+ const validator = this._getValidator(options)
179
+ return validator.validate(this.toSchema(), data, options)
180
+ }
181
+
182
+ async validateAsync(data: unknown, options: Record<string, unknown> = {}): Promise<ValidationResult<unknown>> {
183
+ const validator = this._getValidator(options)
184
+ // validator.validateAsync() throws ValidationError on failure and returns data on success.
185
+ // Adapt to the ValidationResult contract expected by ConditionalBuilder callers.
186
+ try {
187
+ const resultData = await validator.validateAsync(this.toSchema(), data, options)
188
+ return { valid: true, data: resultData as unknown, errors: [] }
189
+ } catch (err) {
190
+ if (err instanceof ValidationError) {
191
+ return { valid: false, errors: err.errors, data: undefined }
192
+ }
193
+ throw err
194
+ }
195
+ }
196
+
197
+ /**
198
+ * assert() — synchronous assertion; throws ValidationError on failure (fixes C-03: v1 threw plain Error).
199
+ * Evaluates conditions synchronously without going through Validator (which is async).
200
+ */
201
+ assert(data: unknown, options: Record<string, unknown> = {}): unknown {
202
+ const locale = (options.locale as string) ?? null
203
+ for (const cond of this._conditions) {
204
+ const { result: matched, failedMessage } = this._evaluateCondition(cond, data)
205
+ if (matched && cond.action === 'throw') {
206
+ const rawMsg = failedMessage ?? cond.message ?? 'Condition failed'
207
+ const message = Locale.getMessageText(rawMsg, {}, locale)
208
+ throw new ValidationError(
209
+ [{ message, path: '', keyword: 'conditional', params: {} }],
210
+ data,
211
+ )
212
+ }
213
+ }
214
+ return data
215
+ }
216
+
217
+ check(data: unknown): boolean {
218
+ try {
219
+ const conditions = this._conditions
220
+ for (const cond of conditions) {
221
+ const { result: matched } = this._evaluateCondition(cond, data)
222
+ if (matched && cond.action === 'throw') return false
223
+ }
224
+ return true
225
+ } catch {
226
+ return false
227
+ }
228
+ }
229
+
230
+ // ==================== Static Factory Methods ====================
231
+
232
+ static start(conditionFn: ConditionFn | string): ConditionalBuilder {
233
+ return new ConditionalBuilder().if(conditionFn)
234
+ }
235
+
236
+ // ==================== Internal Evaluation Logic ====================
237
+
238
+ private _evaluateCondition(conditionObj: ConditionEntry, data: unknown): EvaluateResult {
239
+ try {
240
+ const isMessageMode = conditionObj.action === 'throw'
241
+ const hasOrConditions = conditionObj.combinedConditions.some(c => c.op === 'or')
242
+
243
+ // Chain check mode (v1 compat): message mode + root has own message
244
+ // Each condition checked left-to-right, first TRUE = fail with its message
245
+ const rootHasMessage = conditionObj.combinedConditions[0]?.message !== null
246
+ const isChainCheckMode = isMessageMode && rootHasMessage
247
+
248
+ if (isChainCheckMode) {
249
+ for (const combined of conditionObj.combinedConditions) {
250
+ try {
251
+ const conditionResult = combined.fn(data)
252
+ if (conditionResult) {
253
+ return { result: true, failedMessage: combined.message ?? conditionObj.message ?? null }
254
+ }
255
+ } catch {
256
+ // Condition threw — treat as not matched
257
+ }
258
+ }
259
+ return { result: false, failedMessage: null }
260
+ }
261
+
262
+ // Message mode with AND only (no OR, shared message, root has no own message):
263
+ // ALL conditions must be true to trigger
264
+ if (isMessageMode && !hasOrConditions && conditionObj.combinedConditions.length > 1) {
265
+ let allTrue = true
266
+ for (const combined of conditionObj.combinedConditions) {
267
+ if (!combined.fn(data)) {
268
+ allTrue = false
269
+ break
270
+ }
271
+ }
272
+ if (allTrue) {
273
+ return { result: true, failedMessage: conditionObj.message ?? null }
274
+ }
275
+ return { result: false, failedMessage: null }
276
+ }
277
+
278
+ // Message mode with OR (and possibly AND, shared message): AND/OR boolean with precedence
279
+ if (isMessageMode && hasOrConditions) {
280
+ let andGroupResult = true
281
+ let finalResult = false
282
+ for (const combined of conditionObj.combinedConditions) {
283
+ const conditionResult = combined.fn(data)
284
+ if (combined.op === 'root' || combined.op === 'and') {
285
+ andGroupResult = andGroupResult && conditionResult
286
+ } else if (combined.op === 'or') {
287
+ finalResult = finalResult || andGroupResult
288
+ andGroupResult = conditionResult
289
+ }
290
+ }
291
+ finalResult = finalResult || andGroupResult
292
+ if (finalResult) {
293
+ return { result: true, failedMessage: conditionObj.message ?? null }
294
+ }
295
+ return { result: false, failedMessage: null }
296
+ }
297
+
298
+ // Message mode without AND/OR (single condition)
299
+ if (isMessageMode) {
300
+ const root = conditionObj.combinedConditions[0]
301
+ if (root && root.fn(data)) {
302
+ return { result: true, failedMessage: root.message ?? conditionObj.message ?? null }
303
+ }
304
+ return { result: false, failedMessage: null }
305
+ }
306
+
307
+ // Non-message (then/else) mode: standard AND/OR boolean evaluation
308
+ let andGroupResult = true
309
+ let finalResult = false
310
+ for (const combined of conditionObj.combinedConditions) {
311
+ const conditionResult = combined.fn(data)
312
+ if (combined.op === 'root' || combined.op === 'and') {
313
+ andGroupResult = andGroupResult && conditionResult
314
+ } else if (combined.op === 'or') {
315
+ finalResult = finalResult || andGroupResult
316
+ andGroupResult = conditionResult
317
+ }
318
+ }
319
+ const result = finalResult || andGroupResult
320
+ return { result, failedMessage: null }
321
+ } catch {
322
+ return { result: false, failedMessage: null }
323
+ }
324
+ }
325
+
326
+ private _getValidator(options: Record<string, unknown>): Validator {
327
+ const constructorOptions = this._getConstructorOptions(options)
328
+ const cacheKey = this._getValidatorCacheKey(constructorOptions)
329
+
330
+ let validator = this._validatorCache.get(cacheKey)
331
+ if (!validator) {
332
+ if (this._validatorCache.size >= ConditionalBuilder._VALIDATOR_CACHE_MAX) {
333
+ const firstKey = this._validatorCache.keys().next().value
334
+ if (firstKey !== undefined) this._validatorCache.delete(firstKey)
335
+ }
336
+ validator = new Validator(constructorOptions)
337
+ this._validatorCache.set(cacheKey, validator)
338
+ }
339
+
340
+ return validator
341
+ }
342
+
343
+ private _getConstructorOptions(options: Record<string, unknown>): ValidateOptions {
344
+ const constructorOptions: Record<string, unknown> = {}
345
+
346
+ for (const [key, value] of Object.entries(options)) {
347
+ if (!RUNTIME_ONLY_VALIDATE_OPTION_KEYS.has(key)) {
348
+ constructorOptions[key] = value
349
+ }
350
+ }
351
+
352
+ return constructorOptions as ValidateOptions
353
+ }
354
+
355
+ private _getValidatorCacheKey(options: ValidateOptions): string {
356
+ return JSON.stringify(this._normalizeOptionValue(options))
357
+ }
358
+
359
+ private _normalizeOptionValue(value: unknown): unknown {
360
+ if (Array.isArray(value)) {
361
+ return value.map(item => this._normalizeOptionValue(item))
362
+ }
363
+
364
+ if (value && typeof value === 'object') {
365
+ return Object.fromEntries(
366
+ Object.entries(value as Record<string, unknown>)
367
+ .sort(([left], [right]) => left.localeCompare(right))
368
+ .map(([key, nestedValue]) => [key, this._normalizeOptionValue(nestedValue)])
369
+ )
370
+ }
371
+
372
+ if (typeof value === 'function') {
373
+ return `__fn__:${value.name || 'anonymous'}`
374
+ }
375
+
376
+ if (typeof value === 'bigint') {
377
+ return `__bigint__:${value.toString()}`
378
+ }
379
+
380
+ return value
381
+ }
382
+ }
@@ -0,0 +1,28 @@
1
+ import type { JSONSchema } from '../types/schema.js'
2
+
3
+ export const CONDITIONAL_RUNTIME_STATE: unique symbol = Symbol('schema-dsl.conditionalRuntimeState')
4
+
5
+ export interface ConditionalRuntimeState {
6
+ conditions: unknown[]
7
+ elseSchema: string | JSONSchema | null | undefined
8
+ evaluateCondition: (conditionObj: unknown, data: unknown) => {
9
+ result: boolean
10
+ failedMessage?: string | null
11
+ requirementFailed?: boolean
12
+ }
13
+ }
14
+
15
+ export type ConditionalRuntimeSchema = JSONSchema & {
16
+ [CONDITIONAL_RUNTIME_STATE]?: ConditionalRuntimeState
17
+ }
18
+
19
+ export function attachConditionalRuntime(schema: JSONSchema, state: ConditionalRuntimeState): ConditionalRuntimeSchema {
20
+ Object.defineProperty(schema, CONDITIONAL_RUNTIME_STATE, {
21
+ value: state,
22
+ enumerable: false,
23
+ configurable: false,
24
+ writable: false,
25
+ })
26
+
27
+ return schema as ConditionalRuntimeSchema
28
+ }