schema-dsl 2.0.0 → 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 (145) hide show
  1. package/CHANGELOG.md +130 -113
  2. package/LICENSE +21 -21
  3. package/README.md +628 -628
  4. package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
  5. package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
  6. package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
  7. package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
  8. package/dist/index.cjs +75 -29
  9. package/dist/index.d.cts +10 -4
  10. package/dist/index.d.ts +10 -4
  11. package/dist/index.js +75 -29
  12. package/dist/plugins/custom-format.cjs +33 -17
  13. package/dist/plugins/custom-format.d.cts +1 -1
  14. package/dist/plugins/custom-format.d.ts +1 -1
  15. package/dist/plugins/custom-format.js +33 -17
  16. package/dist/plugins/custom-type-example.cjs +33 -17
  17. package/dist/plugins/custom-type-example.d.cts +1 -1
  18. package/dist/plugins/custom-type-example.d.ts +1 -1
  19. package/dist/plugins/custom-type-example.js +33 -17
  20. package/dist/plugins/custom-validator.cjs +0 -2
  21. package/dist/plugins/custom-validator.d.cts +1 -1
  22. package/dist/plugins/custom-validator.d.ts +1 -1
  23. package/dist/plugins/custom-validator.js +0 -2
  24. package/docs/FEATURE-INDEX.md +553 -553
  25. package/docs/add-custom-locale.md +496 -496
  26. package/docs/add-keyword.md +24 -24
  27. package/docs/api-reference.md +1047 -1047
  28. package/docs/api.md +13 -13
  29. package/docs/best-practices-project-structure.md +417 -417
  30. package/docs/best-practices.md +712 -712
  31. package/docs/cache-manager.md +344 -344
  32. package/docs/compile.md +45 -45
  33. package/docs/conditional-api.md +1307 -1307
  34. package/docs/custom-extensions-guide.md +339 -339
  35. package/docs/design-philosophy.md +606 -606
  36. package/docs/doc-index.md +324 -324
  37. package/docs/dsl-syntax.md +714 -714
  38. package/docs/dynamic-locale.md +608 -608
  39. package/docs/enum.md +482 -482
  40. package/docs/error-handling.md +1975 -1975
  41. package/docs/export-guide.md +501 -501
  42. package/docs/export-limitations.md +567 -567
  43. package/docs/faq.md +596 -596
  44. package/docs/frontend-i18n-guide.md +307 -307
  45. package/docs/i18n-user-guide.md +487 -487
  46. package/docs/i18n.md +476 -476
  47. package/docs/index.md +48 -48
  48. package/docs/json-schema-basics.md +40 -40
  49. package/docs/label-vs-description.md +271 -271
  50. package/docs/markdown-exporter.md +406 -406
  51. package/docs/mongodb-exporter.md +302 -302
  52. package/docs/multi-language.md +26 -26
  53. package/docs/multi-type-support.md +322 -322
  54. package/docs/mysql-exporter.md +280 -280
  55. package/docs/number-operators.md +449 -449
  56. package/docs/optional-marker-guide.md +326 -326
  57. package/docs/performance-guide.md +49 -49
  58. package/docs/plugin-system.md +381 -381
  59. package/docs/plugin-type-registration.md +34 -34
  60. package/docs/postgresql-exporter.md +311 -311
  61. package/docs/public/favicon.svg +4 -4
  62. package/docs/quick-start.md +435 -435
  63. package/docs/runtime-locale-support.md +532 -532
  64. package/docs/schema-helper.md +345 -345
  65. package/docs/schema-utils-advanced-issues.md +23 -23
  66. package/docs/schema-utils-best-practices.md +20 -20
  67. package/docs/schema-utils-chaining.md +150 -150
  68. package/docs/schema-utils.md +524 -524
  69. package/docs/security-checklist.md +20 -20
  70. package/docs/string-extensions.md +488 -488
  71. package/docs/troubleshooting.md +486 -486
  72. package/docs/type-converter.md +310 -310
  73. package/docs/type-reference.md +242 -242
  74. package/docs/typescript-guide.md +584 -584
  75. package/docs/union-type-guide.md +157 -157
  76. package/docs/union-types.md +284 -284
  77. package/docs/validate-async.md +491 -491
  78. package/docs/validate-batch.md +49 -49
  79. package/docs/validate-dsl-object-support.md +578 -578
  80. package/docs/validate.md +506 -506
  81. package/docs/validation-guide.md +502 -502
  82. package/docs/validator.md +39 -39
  83. package/package.json +131 -131
  84. package/plugins/custom-format.cjs +8 -8
  85. package/plugins/custom-type-example.cjs +8 -8
  86. package/plugins/custom-validator.cjs +8 -8
  87. package/src/adapters/DslAdapter.ts +111 -111
  88. package/src/adapters/index.ts +1 -1
  89. package/src/config/constants.ts +83 -83
  90. package/src/config/index.ts +2 -2
  91. package/src/config/patterns.ts +77 -77
  92. package/src/core/CacheManager.ts +169 -159
  93. package/src/core/ConditionalBuilder.ts +382 -382
  94. package/src/core/ConditionalRuntime.ts +27 -27
  95. package/src/core/ConditionalValidator.ts +254 -254
  96. package/src/core/DslBuilder.ts +687 -677
  97. package/src/core/ErrorCodes.ts +38 -38
  98. package/src/core/ErrorFormatter.ts +271 -271
  99. package/src/core/JSONSchemaCore.ts +65 -65
  100. package/src/core/Locale.ts +187 -187
  101. package/src/core/MessageTemplate.ts +42 -42
  102. package/src/core/ObjectDslBuilder.ts +64 -64
  103. package/src/core/PluginManager.ts +326 -326
  104. package/src/core/StringExtensions.ts +140 -140
  105. package/src/core/TemplateEngine.ts +44 -44
  106. package/src/core/Validator.ts +448 -448
  107. package/src/errors/I18nError.ts +159 -159
  108. package/src/errors/ValidationError.ts +105 -105
  109. package/src/exporters/BaseExporter.ts +60 -60
  110. package/src/exporters/MarkdownExporter.ts +305 -305
  111. package/src/exporters/MongoDBExporter.ts +126 -126
  112. package/src/exporters/MySQLExporter.ts +156 -155
  113. package/src/exporters/PostgreSQLExporter.ts +222 -222
  114. package/src/exporters/index.ts +18 -18
  115. package/src/index.ts +651 -633
  116. package/src/locales/en-US.ts +160 -160
  117. package/src/locales/es-ES.ts +160 -160
  118. package/src/locales/fr-FR.ts +160 -160
  119. package/src/locales/index.ts +103 -103
  120. package/src/locales/ja-JP.ts +160 -160
  121. package/src/locales/types.ts +156 -156
  122. package/src/locales/zh-CN.ts +160 -160
  123. package/src/parser/ConstraintParser.ts +101 -101
  124. package/src/parser/DslParser.ts +470 -470
  125. package/src/parser/SchemaCompiler.ts +66 -66
  126. package/src/parser/TypeRegistry.ts +250 -250
  127. package/src/parser/index.ts +6 -6
  128. package/src/plugins/custom-format.ts +124 -126
  129. package/src/plugins/custom-type-example.ts +106 -108
  130. package/src/plugins/custom-validator.ts +138 -140
  131. package/src/types/conditional.ts +28 -28
  132. package/src/types/config.ts +59 -59
  133. package/src/types/dsl.ts +131 -131
  134. package/src/types/error.ts +60 -60
  135. package/src/types/index.ts +17 -17
  136. package/src/types/infer.ts +127 -127
  137. package/src/types/plugin.ts +58 -58
  138. package/src/types/safe-regex.d.ts +9 -9
  139. package/src/types/schema.ts +66 -66
  140. package/src/types/validate.ts +71 -71
  141. package/src/utils/SchemaHelper.ts +196 -196
  142. package/src/utils/SchemaUtils.ts +365 -346
  143. package/src/utils/TypeConverter.ts +215 -215
  144. package/src/utils/index.ts +10 -10
  145. package/src/validators/CustomKeywords.ts +477 -477
@@ -1,382 +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
- }
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
+ }