@coherent.js/state 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -0,0 +1,621 @@
1
+ // src/state-validation.js
2
+ var SchemaValidator = class {
3
+ constructor(schema, options = {}) {
4
+ this.schema = schema;
5
+ this.options = {
6
+ coerce: false,
7
+ allowUnknown: true,
8
+ ...options
9
+ };
10
+ }
11
+ /**
12
+ * Validate value against schema
13
+ * @param {*} value - Value to validate
14
+ * @param {Object} schema - Schema to validate against
15
+ * @param {string} path - Current path in object
16
+ * @returns {ValidationResult} Validation result
17
+ */
18
+ validate(value, schema = this.schema, path = "") {
19
+ const errors = [];
20
+ let coercedValue = value;
21
+ if (schema.type) {
22
+ const typeResult = this.validateType(value, schema.type, path);
23
+ if (!typeResult.valid) {
24
+ errors.push(...typeResult.errors);
25
+ if (!this.options.coerce) {
26
+ return { valid: false, errors, value };
27
+ }
28
+ }
29
+ coercedValue = typeResult.value;
30
+ }
31
+ if (schema.enum) {
32
+ const enumResult = this.validateEnum(coercedValue, schema.enum, path);
33
+ if (!enumResult.valid) {
34
+ errors.push(...enumResult.errors);
35
+ }
36
+ }
37
+ if (schema.type === "string") {
38
+ const stringResult = this.validateString(coercedValue, schema, path);
39
+ if (!stringResult.valid) {
40
+ errors.push(...stringResult.errors);
41
+ }
42
+ }
43
+ if (schema.type === "number" || schema.type === "integer") {
44
+ const numberResult = this.validateNumber(coercedValue, schema, path);
45
+ if (!numberResult.valid) {
46
+ errors.push(...numberResult.errors);
47
+ }
48
+ }
49
+ if (schema.type === "array") {
50
+ const arrayResult = this.validateArray(coercedValue, schema, path);
51
+ if (!arrayResult.valid) {
52
+ errors.push(...arrayResult.errors);
53
+ }
54
+ coercedValue = arrayResult.value;
55
+ }
56
+ if (schema.type === "object") {
57
+ const objectResult = this.validateObject(coercedValue, schema, path);
58
+ if (!objectResult.valid) {
59
+ errors.push(...objectResult.errors);
60
+ }
61
+ coercedValue = objectResult.value;
62
+ }
63
+ if (schema.validate && typeof schema.validate === "function") {
64
+ const customResult = schema.validate(coercedValue);
65
+ if (customResult !== true) {
66
+ errors.push({
67
+ path,
68
+ message: typeof customResult === "string" ? customResult : "Custom validation failed",
69
+ type: "custom",
70
+ value: coercedValue
71
+ });
72
+ }
73
+ }
74
+ return {
75
+ valid: errors.length === 0,
76
+ errors,
77
+ value: coercedValue
78
+ };
79
+ }
80
+ validateType(value, type, path) {
81
+ const actualType = Array.isArray(value) ? "array" : typeof value;
82
+ const errors = [];
83
+ let coercedValue = value;
84
+ const types = Array.isArray(type) ? type : [type];
85
+ const isValid = types.some((t) => {
86
+ if (t === "array") return Array.isArray(value);
87
+ if (t === "null") return value === null;
88
+ if (t === "integer") return typeof value === "number" && Number.isInteger(value);
89
+ return typeof value === t;
90
+ });
91
+ if (!isValid) {
92
+ if (this.options.coerce) {
93
+ const primaryType = types[0];
94
+ try {
95
+ if (primaryType === "string") {
96
+ coercedValue = String(value);
97
+ } else if (primaryType === "number") {
98
+ coercedValue = Number(value);
99
+ if (isNaN(coercedValue)) {
100
+ errors.push({
101
+ path,
102
+ message: `Cannot coerce "${value}" to number`,
103
+ type: "type",
104
+ value,
105
+ expected: primaryType
106
+ });
107
+ }
108
+ } else if (primaryType === "boolean") {
109
+ coercedValue = Boolean(value);
110
+ } else if (primaryType === "integer") {
111
+ coercedValue = parseInt(value, 10);
112
+ if (isNaN(coercedValue)) {
113
+ errors.push({
114
+ path,
115
+ message: `Cannot coerce "${value}" to integer`,
116
+ type: "type",
117
+ value,
118
+ expected: primaryType
119
+ });
120
+ }
121
+ }
122
+ } catch {
123
+ errors.push({
124
+ path,
125
+ message: `Cannot coerce value to ${primaryType}`,
126
+ type: "type",
127
+ value,
128
+ expected: primaryType
129
+ });
130
+ }
131
+ } else {
132
+ errors.push({
133
+ path,
134
+ message: `Expected type ${types.join(" or ")}, got ${actualType}`,
135
+ type: "type",
136
+ value,
137
+ expected: type
138
+ });
139
+ }
140
+ }
141
+ return {
142
+ valid: errors.length === 0,
143
+ errors,
144
+ value: coercedValue
145
+ };
146
+ }
147
+ validateEnum(value, enumValues, path) {
148
+ const errors = [];
149
+ if (!enumValues.includes(value)) {
150
+ errors.push({
151
+ path,
152
+ message: `Value must be one of: ${enumValues.join(", ")}`,
153
+ type: "enum",
154
+ value,
155
+ expected: enumValues
156
+ });
157
+ }
158
+ return { valid: errors.length === 0, errors };
159
+ }
160
+ validateString(value, schema, path) {
161
+ const errors = [];
162
+ if (schema.minLength !== void 0 && value.length < schema.minLength) {
163
+ errors.push({
164
+ path,
165
+ message: `String length must be >= ${schema.minLength}`,
166
+ type: "minLength",
167
+ value
168
+ });
169
+ }
170
+ if (schema.maxLength !== void 0 && value.length > schema.maxLength) {
171
+ errors.push({
172
+ path,
173
+ message: `String length must be <= ${schema.maxLength}`,
174
+ type: "maxLength",
175
+ value
176
+ });
177
+ }
178
+ if (schema.pattern) {
179
+ const regex = new RegExp(schema.pattern);
180
+ if (!regex.test(value)) {
181
+ errors.push({
182
+ path,
183
+ message: `String does not match pattern: ${schema.pattern}`,
184
+ type: "pattern",
185
+ value
186
+ });
187
+ }
188
+ }
189
+ if (schema.format) {
190
+ const formatResult = this.validateFormat(value, schema.format, path);
191
+ if (!formatResult.valid) {
192
+ errors.push(...formatResult.errors);
193
+ }
194
+ }
195
+ return { valid: errors.length === 0, errors };
196
+ }
197
+ validateFormat(value, format, path) {
198
+ const errors = [];
199
+ const formats = {
200
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
201
+ url: /^https?:\/\/.+/,
202
+ uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
203
+ date: /^\d{4}-\d{2}-\d{2}$/,
204
+ "date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
205
+ };
206
+ if (formats[format] && !formats[format].test(value)) {
207
+ errors.push({
208
+ path,
209
+ message: `String does not match format: ${format}`,
210
+ type: "format",
211
+ value,
212
+ expected: format
213
+ });
214
+ }
215
+ return { valid: errors.length === 0, errors };
216
+ }
217
+ validateNumber(value, schema, path) {
218
+ const errors = [];
219
+ if (schema.minimum !== void 0 && value < schema.minimum) {
220
+ errors.push({
221
+ path,
222
+ message: `Number must be >= ${schema.minimum}`,
223
+ type: "minimum",
224
+ value
225
+ });
226
+ }
227
+ if (schema.maximum !== void 0 && value > schema.maximum) {
228
+ errors.push({
229
+ path,
230
+ message: `Number must be <= ${schema.maximum}`,
231
+ type: "maximum",
232
+ value
233
+ });
234
+ }
235
+ if (schema.exclusiveMinimum !== void 0 && value <= schema.exclusiveMinimum) {
236
+ errors.push({
237
+ path,
238
+ message: `Number must be > ${schema.exclusiveMinimum}`,
239
+ type: "exclusiveMinimum",
240
+ value
241
+ });
242
+ }
243
+ if (schema.exclusiveMaximum !== void 0 && value >= schema.exclusiveMaximum) {
244
+ errors.push({
245
+ path,
246
+ message: `Number must be < ${schema.exclusiveMaximum}`,
247
+ type: "exclusiveMaximum",
248
+ value
249
+ });
250
+ }
251
+ if (schema.multipleOf !== void 0 && value % schema.multipleOf !== 0) {
252
+ errors.push({
253
+ path,
254
+ message: `Number must be multiple of ${schema.multipleOf}`,
255
+ type: "multipleOf",
256
+ value
257
+ });
258
+ }
259
+ return { valid: errors.length === 0, errors };
260
+ }
261
+ validateArray(value, schema, path) {
262
+ const errors = [];
263
+ const coercedValue = [...value];
264
+ if (schema.minItems !== void 0 && value.length < schema.minItems) {
265
+ errors.push({
266
+ path,
267
+ message: `Array must have at least ${schema.minItems} items`,
268
+ type: "minItems",
269
+ value
270
+ });
271
+ }
272
+ if (schema.maxItems !== void 0 && value.length > schema.maxItems) {
273
+ errors.push({
274
+ path,
275
+ message: `Array must have at most ${schema.maxItems} items`,
276
+ type: "maxItems",
277
+ value
278
+ });
279
+ }
280
+ if (schema.uniqueItems) {
281
+ const seen = /* @__PURE__ */ new Set();
282
+ const duplicates = [];
283
+ value.forEach((item, index) => {
284
+ const key = JSON.stringify(item);
285
+ if (seen.has(key)) {
286
+ duplicates.push(index);
287
+ }
288
+ seen.add(key);
289
+ });
290
+ if (duplicates.length > 0) {
291
+ errors.push({
292
+ path,
293
+ message: "Array items must be unique",
294
+ type: "uniqueItems",
295
+ value
296
+ });
297
+ }
298
+ }
299
+ if (schema.items) {
300
+ value.forEach((item, index) => {
301
+ const itemPath = `${path}[${index}]`;
302
+ const itemResult = this.validate(item, schema.items, itemPath);
303
+ if (!itemResult.valid) {
304
+ errors.push(...itemResult.errors);
305
+ }
306
+ if (this.options.coerce) {
307
+ coercedValue[index] = itemResult.value;
308
+ }
309
+ });
310
+ }
311
+ return {
312
+ valid: errors.length === 0,
313
+ errors,
314
+ value: coercedValue
315
+ };
316
+ }
317
+ validateObject(value, schema, path) {
318
+ const errors = [];
319
+ const coercedValue = { ...value };
320
+ if (schema.required) {
321
+ schema.required.forEach((prop) => {
322
+ if (!(prop in value)) {
323
+ errors.push({
324
+ path: path ? `${path}.${prop}` : prop,
325
+ message: `Required property "${prop}" is missing`,
326
+ type: "required",
327
+ value: void 0
328
+ });
329
+ }
330
+ });
331
+ }
332
+ if (schema.properties) {
333
+ Object.entries(schema.properties).forEach(([prop, propSchema]) => {
334
+ if (prop in value) {
335
+ const propPath = path ? `${path}.${prop}` : prop;
336
+ const propResult = this.validate(value[prop], propSchema, propPath);
337
+ if (!propResult.valid) {
338
+ errors.push(...propResult.errors);
339
+ }
340
+ if (this.options.coerce) {
341
+ coercedValue[prop] = propResult.value;
342
+ }
343
+ }
344
+ });
345
+ }
346
+ if (schema.additionalProperties === false && !this.options.allowUnknown) {
347
+ const allowedProps = new Set(Object.keys(schema.properties || {}));
348
+ Object.keys(value).forEach((prop) => {
349
+ if (!allowedProps.has(prop)) {
350
+ errors.push({
351
+ path: path ? `${path}.${prop}` : prop,
352
+ message: `Unknown property "${prop}"`,
353
+ type: "additionalProperties",
354
+ value: value[prop]
355
+ });
356
+ }
357
+ });
358
+ }
359
+ const propCount = Object.keys(value).length;
360
+ if (schema.minProperties !== void 0 && propCount < schema.minProperties) {
361
+ errors.push({
362
+ path,
363
+ message: `Object must have at least ${schema.minProperties} properties`,
364
+ type: "minProperties",
365
+ value
366
+ });
367
+ }
368
+ if (schema.maxProperties !== void 0 && propCount > schema.maxProperties) {
369
+ errors.push({
370
+ path,
371
+ message: `Object must have at most ${schema.maxProperties} properties`,
372
+ type: "maxProperties",
373
+ value
374
+ });
375
+ }
376
+ return {
377
+ valid: errors.length === 0,
378
+ errors,
379
+ value: coercedValue
380
+ };
381
+ }
382
+ };
383
+ function createValidatedState(initialState = {}, options = {}) {
384
+ const opts = {
385
+ schema: null,
386
+ validators: {},
387
+ strict: false,
388
+ coerce: false,
389
+ onError: null,
390
+ validateOnSet: true,
391
+ validateOnGet: false,
392
+ required: [],
393
+ allowUnknown: true,
394
+ ...options
395
+ };
396
+ const schemaValidator = opts.schema ? new SchemaValidator(opts.schema, {
397
+ coerce: opts.coerce,
398
+ allowUnknown: opts.allowUnknown
399
+ }) : null;
400
+ let state = { ...initialState };
401
+ const listeners = /* @__PURE__ */ new Set();
402
+ const validationErrors = /* @__PURE__ */ new Map();
403
+ function validateState(value, key = null) {
404
+ const errors = [];
405
+ let validatedValue = value;
406
+ if (schemaValidator) {
407
+ const schema = key && opts.schema.properties ? opts.schema.properties[key] : opts.schema;
408
+ const result = schemaValidator.validate(value, schema, key || "");
409
+ if (!result.valid) {
410
+ errors.push(...result.errors);
411
+ }
412
+ validatedValue = result.value;
413
+ }
414
+ if (key && opts.validators[key]) {
415
+ const validator = opts.validators[key];
416
+ const result = validator(value);
417
+ if (result !== true) {
418
+ errors.push({
419
+ path: key,
420
+ message: typeof result === "string" ? result : "Validation failed",
421
+ type: "custom",
422
+ value
423
+ });
424
+ }
425
+ } else if (!key) {
426
+ Object.entries(opts.validators).forEach(([fieldKey, validator]) => {
427
+ if (fieldKey in value) {
428
+ const result = validator(value[fieldKey]);
429
+ if (result !== true) {
430
+ errors.push({
431
+ path: fieldKey,
432
+ message: typeof result === "string" ? result : "Validation failed",
433
+ type: "custom",
434
+ value: value[fieldKey]
435
+ });
436
+ }
437
+ }
438
+ });
439
+ }
440
+ if (opts.required.length > 0 && !key) {
441
+ opts.required.forEach((field) => {
442
+ if (!(field in value)) {
443
+ errors.push({
444
+ path: field,
445
+ message: `Required field "${field}" is missing`,
446
+ type: "required",
447
+ value: void 0
448
+ });
449
+ }
450
+ });
451
+ }
452
+ return {
453
+ valid: errors.length === 0,
454
+ errors,
455
+ value: validatedValue
456
+ };
457
+ }
458
+ function getState(key) {
459
+ const value = key ? state[key] : { ...state };
460
+ if (opts.validateOnGet) {
461
+ const result = validateState(value, key);
462
+ if (!result.valid) {
463
+ validationErrors.set(key || "__root__", result.errors);
464
+ if (opts.onError) {
465
+ opts.onError(result.errors);
466
+ }
467
+ }
468
+ }
469
+ return value;
470
+ }
471
+ function setState(updates) {
472
+ const oldState = { ...state };
473
+ if (typeof updates === "function") {
474
+ updates = updates(oldState);
475
+ }
476
+ const newState = { ...state, ...updates };
477
+ if (opts.validateOnSet) {
478
+ const result = validateState(newState);
479
+ if (!result.valid) {
480
+ validationErrors.set("__root__", result.errors);
481
+ if (opts.onError) {
482
+ opts.onError(result.errors);
483
+ }
484
+ if (opts.strict) {
485
+ const error = new Error("Validation failed");
486
+ error.validationErrors = result.errors;
487
+ throw error;
488
+ }
489
+ return;
490
+ }
491
+ if (opts.coerce) {
492
+ const updatedKeys = Object.keys(updates);
493
+ const newUpdates = {};
494
+ updatedKeys.forEach((key) => {
495
+ if (result.value[key] !== state[key]) {
496
+ newUpdates[key] = result.value[key];
497
+ }
498
+ });
499
+ updates = newUpdates;
500
+ }
501
+ validationErrors.clear();
502
+ }
503
+ state = { ...state, ...updates };
504
+ listeners.forEach((listener) => {
505
+ try {
506
+ listener(state, oldState);
507
+ } catch (error) {
508
+ console.error("Listener error:", error);
509
+ }
510
+ });
511
+ }
512
+ function subscribe(listener) {
513
+ listeners.add(listener);
514
+ return () => listeners.delete(listener);
515
+ }
516
+ function getErrors(key = "__root__") {
517
+ return validationErrors.get(key) || [];
518
+ }
519
+ function isValid() {
520
+ const result = validateState(state);
521
+ if (!result.valid) {
522
+ validationErrors.set("__root__", result.errors);
523
+ }
524
+ return result.valid;
525
+ }
526
+ function validateField(key, value) {
527
+ return validateState(value, key);
528
+ }
529
+ return {
530
+ getState,
531
+ setState,
532
+ subscribe,
533
+ getErrors,
534
+ isValid,
535
+ validateField,
536
+ validate: () => validateState(state)
537
+ };
538
+ }
539
+ var validators = {
540
+ /**
541
+ * Email validator
542
+ * @param {string} value - Email to validate
543
+ * @returns {boolean|string} True if valid, error message otherwise
544
+ */
545
+ email: (value) => {
546
+ if (typeof value !== "string") return "Email must be a string";
547
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Invalid email format";
548
+ return true;
549
+ },
550
+ /**
551
+ * URL validator
552
+ * @param {string} value - URL to validate
553
+ * @returns {boolean|string} True if valid, error message otherwise
554
+ */
555
+ url: (value) => {
556
+ if (typeof value !== "string") return "URL must be a string";
557
+ try {
558
+ new URL(value);
559
+ return true;
560
+ } catch {
561
+ return "Invalid URL format";
562
+ }
563
+ },
564
+ /**
565
+ * Range validator
566
+ * @param {number} min - Minimum value
567
+ * @param {number} max - Maximum value
568
+ * @returns {Function} Validator function
569
+ */
570
+ range: (min, max) => (value) => {
571
+ if (typeof value !== "number") return "Value must be a number";
572
+ if (value < min || value > max) return `Value must be between ${min} and ${max}`;
573
+ return true;
574
+ },
575
+ /**
576
+ * Length validator
577
+ * @param {number} min - Minimum length
578
+ * @param {number} max - Maximum length
579
+ * @returns {Function} Validator function
580
+ */
581
+ length: (min, max) => (value) => {
582
+ if (typeof value !== "string") return "Value must be a string";
583
+ if (value.length < min || value.length > max) {
584
+ return `Length must be between ${min} and ${max}`;
585
+ }
586
+ return true;
587
+ },
588
+ /**
589
+ * Pattern validator
590
+ * @param {RegExp|string} pattern - Pattern to match
591
+ * @returns {Function} Validator function
592
+ */
593
+ pattern: (pattern) => (value) => {
594
+ if (typeof value !== "string") return "Value must be a string";
595
+ const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
596
+ if (!regex.test(value)) return `Value does not match pattern: ${pattern}`;
597
+ return true;
598
+ },
599
+ /**
600
+ * Required validator
601
+ * @param {*} value - Value to validate
602
+ * @returns {boolean|string} True if valid, error message otherwise
603
+ */
604
+ required: (value) => {
605
+ if (value === void 0 || value === null || value === "") {
606
+ return "Value is required";
607
+ }
608
+ return true;
609
+ }
610
+ };
611
+ var state_validation_default = {
612
+ createValidatedState,
613
+ validators,
614
+ SchemaValidator
615
+ };
616
+ export {
617
+ createValidatedState,
618
+ state_validation_default as default,
619
+ validators
620
+ };
621
+ //# sourceMappingURL=state-validation.js.map