svstate 1.1.0 โ†’ 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,9 +70,11 @@ const customer = $state({
70
70
 
71
71
  - ๐Ÿ” **Detects changes** at any nesting level (`customer.billing.bankAccount.iban`)
72
72
  - โœ… **Validates** with a structure that mirrors your data
73
+ - ๐ŸŒ **Async validation** for server-side checks (username availability, email verification)
73
74
  - โšก **Fires effects** when any property changes (with full context)
74
75
  - โช **Snapshots & undo** for complex editing workflows
75
76
  - ๐ŸŽฏ **Tracks dirty state** automatically
77
+ - ๐Ÿ”ง **Supports methods** on state objects for computed values and formatting
76
78
 
77
79
  ```typescript
78
80
  import { createSvState, stringValidator, numberValidator } from 'svstate';
@@ -104,6 +106,8 @@ npm install svstate
104
106
 
105
107
  **Requirements:** Node.js โ‰ฅ20, Svelte 5
106
108
 
109
+ **Note:** This package is distributed as ESM (ES Modules) only.
110
+
107
111
  ---
108
112
 
109
113
  ## ๐ŸŽฏ Core Features
@@ -130,7 +134,8 @@ const {
130
134
  {
131
135
  validator: (source) => ({
132
136
  // Fluent API: chain validations, get first error
133
- email: stringValidator(source.email, 'trim') // 'trim' preprocesses input
137
+ email: stringValidator(source.email)
138
+ .prepare('trim') // preprocessing applied before validation
134
139
  .required()
135
140
  .email()
136
141
  .maxLength(100)
@@ -154,8 +159,66 @@ const {
154
159
 
155
160
  - ๐Ÿ”„ Automatic re-validation on any change (debounced via microtask)
156
161
  - ๐Ÿ“ Error structure matches data structure exactly
157
- - ๐Ÿงน String preprocessing: `'trim'`, `'normalize'`, `'upper'`, `'lower'`
162
+ - ๐Ÿงน String preprocessing via `.prepare()`: `'trim'`, `'normalize'`, `'upper'`, `'lower'`, `'localeUpper'`, `'localeLower'`
158
163
  - โšก First-error-wins: `getError()` returns the first failure
164
+ - ๐Ÿ”€ Conditional validation: `requiredIf(condition)` on all validators
165
+
166
+ #### Async Validation
167
+
168
+ For server-side validation (checking username availability, email verification, etc.), svstate supports async validators that run after sync validation passes:
169
+
170
+ ```typescript
171
+ import { createSvState, stringValidator, type AsyncValidator } from 'svstate';
172
+
173
+ type UserForm = { username: string; email: string };
174
+
175
+ const asyncValidators: AsyncValidator<UserForm> = {
176
+ username: async (value, source, signal) => {
177
+ // Skip if empty (let sync validation handle required)
178
+ if (!value) return '';
179
+
180
+ const response = await fetch(`/api/check-username?name=${value}`, { signal });
181
+ const { available } = await response.json();
182
+ return available ? '' : 'Username already taken';
183
+ },
184
+ email: async (value, source, signal) => {
185
+ if (!value) return '';
186
+
187
+ const response = await fetch(`/api/check-email?email=${value}`, { signal });
188
+ const { valid } = await response.json();
189
+ return valid ? '' : 'Email not deliverable';
190
+ }
191
+ };
192
+
193
+ const {
194
+ data,
195
+ state: { errors, asyncErrors, asyncValidating, hasAsyncErrors, hasCombinedErrors }
196
+ } = createSvState(
197
+ { username: '', email: '' },
198
+ {
199
+ validator: (source) => ({
200
+ username: stringValidator(source.username).required().minLength(3).getError(),
201
+ email: stringValidator(source.email).required().email().getError()
202
+ }),
203
+ asyncValidator: asyncValidators
204
+ },
205
+ { debounceAsyncValidation: 500 }
206
+ );
207
+
208
+ // In template:
209
+ // {#if $asyncValidating.includes('username')}Checking...{/if}
210
+ // {#if $asyncErrors.username}{$asyncErrors.username}{/if}
211
+ // <button disabled={$hasCombinedErrors}>Submit</button>
212
+ ```
213
+
214
+ **Key features:**
215
+
216
+ - ๐ŸŒ Async validators receive `AbortSignal` for automatic cancellation
217
+ - โฑ๏ธ Debounced by default (300ms) to avoid excessive API calls
218
+ - ๐Ÿ”„ Auto-cancels on property change or new validation
219
+ - ๐Ÿšซ Skipped if sync validation fails for the same path
220
+ - ๐ŸŽฏ `asyncValidating` shows which paths are currently checking
221
+ - ๐Ÿ”’ `maxConcurrentAsyncValidations` limits parallel requests (default: 4)
159
222
 
160
223
  ---
161
224
 
@@ -312,23 +375,103 @@ const { data } = createSvState(formData, actuators, {
312
375
  // Reset isDirty after successful action (default: true)
313
376
  resetDirtyOnAction: true,
314
377
 
315
- // Debounce validation in ms (default: 0 = microtask)
378
+ // Debounce sync validation in ms (default: 0 = microtask)
316
379
  debounceValidation: 300,
317
380
 
318
381
  // Allow concurrent action executions (default: false)
319
382
  allowConcurrentActions: false,
320
383
 
321
384
  // Keep actionError until next action (default: false)
322
- persistActionError: false
385
+ persistActionError: false,
386
+
387
+ // Debounce async validation in ms (default: 300)
388
+ debounceAsyncValidation: 500,
389
+
390
+ // Run async validators on state creation (default: false)
391
+ runAsyncValidationOnInit: false,
392
+
393
+ // Clear async error when property changes (default: true)
394
+ clearAsyncErrorsOnChange: true,
395
+
396
+ // Max concurrent async validators (default: 4)
397
+ maxConcurrentAsyncValidations: 4
398
+ });
399
+ ```
400
+
401
+ | Option | Default | Description |
402
+ | ------------------------------- | ------- | ------------------------------------------ |
403
+ | `resetDirtyOnAction` | `true` | Clear dirty flag after successful action |
404
+ | `debounceValidation` | `0` | Delay sync validation (0 = next microtask) |
405
+ | `allowConcurrentActions` | `false` | Block execute() while action runs |
406
+ | `persistActionError` | `false` | Clear error on next change or action |
407
+ | `debounceAsyncValidation` | `300` | Delay async validation in ms |
408
+ | `runAsyncValidationOnInit` | `false` | Run async validators on creation |
409
+ | `clearAsyncErrorsOnChange` | `true` | Clear async error when property changes |
410
+ | `maxConcurrentAsyncValidations` | `4` | Max concurrent async validators |
411
+
412
+ ---
413
+
414
+ ### 6๏ธโƒฃ State Objects with Methods
415
+
416
+ State objects can include methods that operate on `this`. Methods are preserved through snapshots and undo operations, making it easy to encapsulate computed values and formatting logic:
417
+
418
+ ```typescript
419
+ import { createSvState, numberValidator } from 'svstate';
420
+
421
+ // Define state with methods
422
+ type InvoiceData = {
423
+ unitPrice: number;
424
+ quantity: number;
425
+ subtotal: number;
426
+ tax: number;
427
+ total: number;
428
+ calculateTotals: (taxRate?: number) => void;
429
+ formatCurrency: (value: number) => string;
430
+ };
431
+
432
+ const createInvoice = (): InvoiceData => ({
433
+ unitPrice: 0,
434
+ quantity: 1,
435
+ subtotal: 0,
436
+ tax: 0,
437
+ total: 0,
438
+ calculateTotals(taxRate = 0.08) {
439
+ this.subtotal = this.unitPrice * this.quantity;
440
+ this.tax = this.subtotal * taxRate;
441
+ this.total = this.subtotal + this.tax;
442
+ },
443
+ formatCurrency(value: number) {
444
+ return `$${value.toFixed(2)}`;
445
+ }
323
446
  });
447
+
448
+ const {
449
+ data,
450
+ state: { errors }
451
+ } = createSvState(createInvoice(), {
452
+ validator: (source) => ({
453
+ unitPrice: numberValidator(source.unitPrice).required().positive().getError(),
454
+ quantity: numberValidator(source.quantity).required().integer().min(1).getError()
455
+ }),
456
+ effect: ({ property }) => {
457
+ // Call method directly on state when inputs change
458
+ if (property === 'unitPrice' || property === 'quantity') {
459
+ data.calculateTotals();
460
+ }
461
+ }
462
+ });
463
+
464
+ // In template: use methods for formatting
465
+ // {data.formatCurrency(data.subtotal)} โ†’ "$99.00"
466
+ // {data.formatCurrency(data.total)} โ†’ "$106.92"
324
467
  ```
325
468
 
326
- | Option | Default | Description |
327
- | ------------------------ | ------- | ---------------------------------------- |
328
- | `resetDirtyOnAction` | `true` | Clear dirty flag after successful action |
329
- | `debounceValidation` | `0` | Delay validation (0 = next microtask) |
330
- | `allowConcurrentActions` | `false` | Block execute() while action runs |
331
- | `persistActionError` | `false` | Clear error on next change or action |
469
+ **Key features:**
470
+
471
+ - ๐Ÿ”ง Methods can modify `this` properties (triggers validation/effects)
472
+ - ๐Ÿ“ธ Methods preserved through `rollback()` and `reset()`
473
+ - ๐ŸŽฏ Call methods from effects to compute derived values
474
+ - ๐Ÿ“ Encapsulate formatting and business logic in state object
332
475
 
333
476
  ---
334
477
 
@@ -384,13 +527,15 @@ const {
384
527
 
385
528
  // โœ… Validator mirrors data structure exactly
386
529
  validator: (source) => ({
387
- name: stringValidator(source.name, 'trim')
530
+ name: stringValidator(source.name)
531
+ .prepare('trim')
388
532
  .required()
389
533
  .minLength(2)
390
534
  .maxLength(100)
391
535
  .getError(),
392
536
 
393
- taxId: stringValidator(source.taxId, 'trim', 'upper')
537
+ taxId: stringValidator(source.taxId)
538
+ .prepare('trim', 'upper')
394
539
  .required()
395
540
  .regexp(/^[A-Z]{2}-\d{8}$/, 'Format: XX-12345678')
396
541
  .getError(),
@@ -403,20 +548,23 @@ const {
403
548
 
404
549
  // ๐Ÿ“ Nested address validation
405
550
  address: {
406
- street: stringValidator(source.address.street, 'trim')
551
+ street: stringValidator(source.address.street)
552
+ .prepare('trim')
407
553
  .required()
408
554
  .minLength(5)
409
555
  .getError(),
410
- city: stringValidator(source.address.city, 'trim')
556
+ city: stringValidator(source.address.city)
557
+ .prepare('trim')
411
558
  .required()
412
559
  .getError(),
413
- zip: stringValidator(source.address.zip, 'trim')
560
+ zip: stringValidator(source.address.zip)
561
+ .prepare('trim')
414
562
  .required()
415
563
  .minLength(5)
416
564
  .getError(),
417
565
  country: stringValidator(source.address.country)
418
566
  .required()
419
- .inArray(['US', 'CA', 'UK', 'DE', 'FR'])
567
+ .in(['US', 'CA', 'UK', 'DE', 'FR'])
420
568
  .getError()
421
569
  },
422
570
 
@@ -430,19 +578,21 @@ const {
430
578
  billing: {
431
579
  paymentTerms: stringValidator(source.billing.paymentTerms)
432
580
  .required()
433
- .inArray(['NET15', 'NET30', 'NET60', 'COD'])
581
+ .in(['NET15', 'NET30', 'NET60', 'COD'])
434
582
  .getError(),
435
583
  currency: stringValidator(source.billing.currency)
436
584
  .required()
437
- .inArray(['USD', 'EUR', 'GBP'])
585
+ .in(['USD', 'EUR', 'GBP'])
438
586
  .getError(),
439
587
  bankAccount: {
440
- iban: stringValidator(source.billing.bankAccount.iban, 'trim', 'upper')
588
+ iban: stringValidator(source.billing.bankAccount.iban)
589
+ .prepare('trim', 'upper')
441
590
  .required()
442
591
  .minLength(15)
443
592
  .maxLength(34)
444
593
  .getError(),
445
- swift: stringValidator(source.billing.bankAccount.swift, 'trim', 'upper')
594
+ swift: stringValidator(source.billing.bankAccount.swift)
595
+ .prepare('trim', 'upper')
446
596
  .required()
447
597
  .minLength(8)
448
598
  .maxLength(11)
@@ -552,18 +702,21 @@ const {
552
702
  } = createSvState(initialProduct, {
553
703
 
554
704
  validator: (source) => ({
555
- sku: stringValidator(source.sku, 'trim', 'upper')
705
+ sku: stringValidator(source.sku)
706
+ .prepare('trim', 'upper')
556
707
  .required()
557
708
  .regexp(/^[A-Z]{3}-\d{4}$/, 'Format: ABC-1234')
558
709
  .getError(),
559
710
 
560
- name: stringValidator(source.name, 'trim')
711
+ name: stringValidator(source.name)
712
+ .prepare('trim')
561
713
  .required()
562
714
  .minLength(3)
563
715
  .maxLength(100)
564
716
  .getError(),
565
717
 
566
- description: stringValidator(source.description, 'trim')
718
+ description: stringValidator(source.description)
719
+ .prepare('trim')
567
720
  .maxLength(500)
568
721
  .getError(),
569
722
 
@@ -696,16 +849,20 @@ Creates a supercharged state object.
696
849
  **Returns:**
697
850
  | Property | Type | Description |
698
851
  |----------|------|-------------|
699
- | `data` | `T` | Deep reactive proxy โ€” bind directly |
852
+ | `data` | `T` | Deep reactive proxy โ€” bind directly, methods preserved |
700
853
  | `execute(params?)` | `(P?) => Promise<void>` | Run the configured action |
701
854
  | `rollback(steps?)` | `(n?: number) => void` | Undo N changes (default: 1) |
702
855
  | `reset()` | `() => void` | Return to initial state |
703
- | `state.errors` | `Readable<V>` | Validation errors store |
704
- | `state.hasErrors` | `Readable<boolean>` | Quick error check |
856
+ | `state.errors` | `Readable<V>` | Sync validation errors store |
857
+ | `state.hasErrors` | `Readable<boolean>` | Has sync errors? |
705
858
  | `state.isDirty` | `Readable<boolean>` | Has state changed? |
706
859
  | `state.actionInProgress` | `Readable<boolean>` | Is action running? |
707
860
  | `state.actionError` | `Readable<Error>` | Last action error |
708
861
  | `state.snapshots` | `Readable<Snapshot[]>` | Undo history |
862
+ | `state.asyncErrors` | `Readable<AsyncErrors>` | Async validation errors (keyed by path) |
863
+ | `state.hasAsyncErrors` | `Readable<boolean>` | Has async errors? |
864
+ | `state.asyncValidating` | `Readable<string[]>` | Paths currently validating |
865
+ | `state.hasCombinedErrors` | `Readable<boolean>` | Has sync OR async errors? |
709
866
 
710
867
  ### Built-in Validators
711
868
 
@@ -713,28 +870,40 @@ svstate ships with four fluent validator builders that cover the most common val
713
870
 
714
871
  String validators support optional preprocessing (`'trim'`, `'normalize'`, `'upper'`, `'lower'`) applied before validation. All validators return descriptive error messages that you can customize or use as-is.
715
872
 
716
- | Validator | Methods |
717
- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
718
- | `stringValidator(input, ...prepares)` | `required()`, `minLength(n)`, `maxLength(n)`, `email()`, `regexp(re)`, `inArray(arr)`, `startsWith(s)`, `endsWith(s)`, `contains(s)`, `noSpace()`, `uppercase()`, `lowercase()`, `alphanumeric()`, `numeric()`, `website(mode)` |
719
- | `numberValidator(input)` | `required()`, `min(n)`, `max(n)`, `between(min, max)`, `integer()`, `positive()`, `negative()`, `nonNegative()`, `multipleOf(n)`, `decimal(places)`, `percentage()` |
720
- | `arrayValidator(input)` | `required()`, `minLength(n)`, `maxLength(n)`, `unique()` |
721
- | `dateValidator(input)` | `required()`, `before(date)`, `after(date)`, `between(start, end)`, `past()`, `future()`, `weekday()`, `weekend()`, `minAge(years)`, `maxAge(years)` |
873
+ | Validator | Methods |
874
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
875
+ | `stringValidator(input)` | `prepare(...ops)`, `required()`, `requiredIf(cond)`, `minLength(n)`, `maxLength(n)`, `email()`, `regexp(re, msg?)`, `in(arr)`, `notIn(arr)`, `startsWith(s)`, `endsWith(s)`, `contains(s)`, `noSpace()`, `notBlank()`, `uppercase()`, `lowercase()`, `alphanumeric()`, `numeric()`, `slug()`, `identifier()`, `website(mode)` |
876
+ | `numberValidator(input)` | `required()`, `requiredIf(cond)`, `min(n)`, `max(n)`, `between(min, max)`, `integer()`, `positive()`, `negative()`, `nonNegative()`, `notZero()`, `multipleOf(n)`, `step(n)`, `decimal(places)`, `percentage()` |
877
+ | `arrayValidator(input)` | `required()`, `requiredIf(cond)`, `minLength(n)`, `maxLength(n)`, `ofLength(n)`, `unique()`, `includes(item)`, `includesAny(items)`, `includesAll(items)` |
878
+ | `dateValidator(input)` | `required()`, `requiredIf(cond)`, `before(date)`, `after(date)`, `between(start, end)`, `past()`, `future()`, `weekday()`, `weekend()`, `minAge(years)`, `maxAge(years)` |
722
879
 
723
880
  ### TypeScript Types
724
881
 
725
882
  svstate exports TypeScript types to help you write type-safe external validator and effect functions. This is useful when you want to define these functions outside the `createSvState` call or reuse them across multiple state instances.
726
883
 
727
884
  ```typescript
728
- import type { Validator, EffectContext, Snapshot, SnapshotFunction, SvStateOptions } from 'svstate';
885
+ import type {
886
+ Validator,
887
+ EffectContext,
888
+ Snapshot,
889
+ SnapshotFunction,
890
+ SvStateOptions,
891
+ AsyncValidator,
892
+ AsyncValidatorFunction,
893
+ AsyncErrors
894
+ } from 'svstate';
729
895
  ```
730
896
 
731
- | Type | Description |
732
- | ------------------ | --------------------------------------------------------------------------------------------------- |
733
- | `Validator` | Nested object type for validation errors โ€” leaf values are error strings (empty = valid) |
734
- | `EffectContext<T>` | Context object passed to effect callbacks: `{ snapshot, target, property, currentValue, oldValue }` |
735
- | `SnapshotFunction` | Type for the `snapshot(title, replace?)` function used in effects |
736
- | `Snapshot<T>` | Shape of a snapshot entry: `{ title: string; data: T }` |
737
- | `SvStateOptions` | Configuration options type for `createSvState` |
897
+ | Type | Description |
898
+ | --------------------------- | --------------------------------------------------------------------------------------------------- |
899
+ | `Validator` | Nested object type for validation errors โ€” leaf values are error strings (empty = valid) |
900
+ | `EffectContext<T>` | Context object passed to effect callbacks: `{ snapshot, target, property, currentValue, oldValue }` |
901
+ | `SnapshotFunction` | Type for the `snapshot(title, replace?)` function used in effects |
902
+ | `Snapshot<T>` | Shape of a snapshot entry: `{ title: string; data: T }` |
903
+ | `SvStateOptions` | Configuration options type for `createSvState` |
904
+ | `AsyncValidator<T>` | Object mapping property paths to async validator functions |
905
+ | `AsyncValidatorFunction<T>` | Async function: `(value, source, signal) => Promise<string>` |
906
+ | `AsyncErrors` | Object mapping property paths to error strings |
738
907
 
739
908
  **Example: External validator and effect functions**
740
909
 
@@ -781,9 +950,11 @@ const { data, state } = createSvState<UserData, UserErrors, object>(
781
950
  | Deep nested objects | โš ๏ธ Manual tracking | โœ… Automatic |
782
951
  | Property change events | โŒ Not available | โœ… Full context |
783
952
  | Structured validation | โŒ DIY | โœ… Mirrors data |
953
+ | Async validation | โŒ DIY | โœ… Built-in |
784
954
  | Undo/Redo | โŒ DIY | โœ… Built-in |
785
955
  | Dirty tracking | โŒ DIY | โœ… Automatic |
786
956
  | Action loading states | โŒ DIY | โœ… Built-in |
957
+ | State with methods | โš ๏ธ Manual cloning | โœ… Automatic |
787
958
 
788
959
  **svstate is for:**
789
960
 
@@ -798,6 +969,7 @@ const { data, state } = createSvState<UserData, UserErrors, object>(
798
969
  ## ๐Ÿ“š Resources
799
970
 
800
971
  - ๐ŸŽฎ [Live Demo](https://bcsabaengine.github.io/svstate/) โ€” Try it in your browser
972
+ - ๐Ÿ› ๏ธ [SvelteKit Example](https://github.com/BCsabaEngine/svstate-kit) โ€” Example SvelteKit application using svstate
801
973
  - ๐Ÿ“– [Documentation](https://github.com/BCsabaEngine/svstate)
802
974
  - ๐Ÿ› [Report Issues](https://github.com/BCsabaEngine/svstate/issues)
803
975
  - ๐Ÿ’ฌ [Discussions](https://github.com/BCsabaEngine/svstate/discussions)
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createSvState, type EffectContext, type Snapshot, type SnapshotFunction, type SvStateOptions, type Validator } from './state.svelte';
1
+ export { type AsyncErrors, type AsyncValidator, type AsyncValidatorFunction, createSvState, type EffectContext, type Snapshot, type SnapshotFunction, type SvStateOptions, type Validator } from './state.svelte';
2
2
  export { arrayValidator, dateValidator, numberValidator, stringValidator } from './validators';
@@ -15,11 +15,19 @@ export type EffectContext<T> = {
15
15
  currentValue: unknown;
16
16
  oldValue: unknown;
17
17
  };
18
+ export type AsyncValidatorFunction<T> = (value: unknown, source: T, signal: AbortSignal) => Promise<string>;
19
+ export type AsyncValidator<T> = {
20
+ [propertyPath: string]: AsyncValidatorFunction<T>;
21
+ };
22
+ export type AsyncErrors = {
23
+ [propertyPath: string]: string;
24
+ };
18
25
  type Actuators<T extends Record<string, unknown>, V extends Validator, P extends object> = {
19
26
  validator?: (source: T) => V;
20
27
  effect?: (context: EffectContext<T>) => void;
21
28
  action?: Action<P>;
22
29
  actionCompleted?: (error?: unknown) => void | Promise<void>;
30
+ asyncValidator?: AsyncValidator<T>;
23
31
  };
24
32
  type StateResult<T, V> = {
25
33
  errors: Readable<V | undefined>;
@@ -28,12 +36,20 @@ type StateResult<T, V> = {
28
36
  actionInProgress: Readable<boolean>;
29
37
  actionError: Readable<Error | undefined>;
30
38
  snapshots: Readable<Snapshot<T>[]>;
39
+ asyncErrors: Readable<AsyncErrors>;
40
+ hasAsyncErrors: Readable<boolean>;
41
+ asyncValidating: Readable<string[]>;
42
+ hasCombinedErrors: Readable<boolean>;
31
43
  };
32
44
  export type SvStateOptions = {
33
45
  resetDirtyOnAction: boolean;
34
46
  debounceValidation: number;
35
47
  allowConcurrentActions: boolean;
36
48
  persistActionError: boolean;
49
+ debounceAsyncValidation: number;
50
+ runAsyncValidationOnInit: boolean;
51
+ clearAsyncErrorsOnChange: boolean;
52
+ maxConcurrentAsyncValidations: number;
37
53
  };
38
54
  export declare function createSvState<T extends Record<string, unknown>, V extends Validator, P extends object>(init: T, actuators?: Actuators<T, V, P>, options?: Partial<SvStateOptions>): {
39
55
  data: T;
@@ -9,26 +9,70 @@ const deepClone = (object) => {
9
9
  return new Date(object);
10
10
  if (Array.isArray(object))
11
11
  return object.map((item) => deepClone(item));
12
- const cloned = {};
12
+ const cloned = Object.create(Object.getPrototypeOf(object));
13
13
  for (const key of Object.keys(object))
14
14
  cloned[key] = deepClone(object[key]);
15
15
  return cloned;
16
16
  };
17
+ const getValueAtPath = (source, path) => {
18
+ const parts = path.split('.');
19
+ let current = source;
20
+ for (const part of parts) {
21
+ if (current === null || current === undefined)
22
+ return undefined;
23
+ current = current[part];
24
+ }
25
+ return current;
26
+ };
27
+ const getSyncErrorForPath = (errors, path) => {
28
+ if (!errors)
29
+ return '';
30
+ const parts = path.split('.');
31
+ let current = errors;
32
+ for (const part of parts) {
33
+ if (typeof current === 'string')
34
+ return '';
35
+ if (current[part] === undefined)
36
+ return '';
37
+ current = current[part];
38
+ }
39
+ return typeof current === 'string' ? current : '';
40
+ };
41
+ const getMatchingAsyncValidatorPaths = (asyncValidator, changedPath) => {
42
+ const matches = [];
43
+ for (const registeredPath of Object.keys(asyncValidator))
44
+ if (registeredPath === changedPath || registeredPath.startsWith(changedPath + '.'))
45
+ matches.push(registeredPath);
46
+ else if (changedPath.startsWith(registeredPath + '.'))
47
+ matches.push(registeredPath);
48
+ return matches;
49
+ };
17
50
  const defaultOptions = {
18
51
  resetDirtyOnAction: true,
19
52
  debounceValidation: 0,
20
53
  allowConcurrentActions: false,
21
- persistActionError: false
54
+ persistActionError: false,
55
+ debounceAsyncValidation: 300,
56
+ runAsyncValidationOnInit: false,
57
+ clearAsyncErrorsOnChange: true,
58
+ maxConcurrentAsyncValidations: 4
22
59
  };
23
60
  export function createSvState(init, actuators, options) {
24
61
  const usedOptions = { ...defaultOptions, ...options };
25
- const { validator, effect } = actuators ?? {};
62
+ const { validator, effect, asyncValidator } = actuators ?? {};
26
63
  const errors = writable();
27
64
  const hasErrors = derived(errors, hasAnyErrors);
28
65
  const isDirty = writable(false);
29
66
  const actionInProgress = writable(false);
30
67
  const actionError = writable();
31
68
  const snapshots = writable([{ title: 'Initial', data: deepClone(init) }]);
69
+ const asyncErrorsStore = writable({});
70
+ const asyncValidatingSet = writable(new Set());
71
+ const asyncValidating = derived(asyncValidatingSet, ($set) => [...$set]);
72
+ const hasAsyncErrors = derived(asyncErrorsStore, ($asyncErrors) => Object.values($asyncErrors).some((error) => !!error));
73
+ const hasCombinedErrors = derived([hasErrors, hasAsyncErrors], ([$hasErrors, $hasAsyncErrors]) => $hasErrors || $hasAsyncErrors);
74
+ const asyncValidationTrackers = new Map();
75
+ const asyncValidationQueue = [];
32
76
  const stateObject = $state(init);
33
77
  const createSnapshot = (title, replace = true) => {
34
78
  const currentSnapshots = get(snapshots);
@@ -60,6 +104,110 @@ export function createSvState(init, actuators, options) {
60
104
  });
61
105
  }
62
106
  };
107
+ const removeFromQueue = (path) => {
108
+ const index = asyncValidationQueue.indexOf(path);
109
+ if (index !== -1)
110
+ asyncValidationQueue.splice(index, 1);
111
+ };
112
+ const cancelAsyncValidation = (path) => {
113
+ removeFromQueue(path);
114
+ const tracker = asyncValidationTrackers.get(path);
115
+ if (tracker) {
116
+ clearTimeout(tracker.timeoutId);
117
+ tracker.controller.abort();
118
+ asyncValidationTrackers.delete(path);
119
+ asyncValidatingSet.update(($set) => {
120
+ $set.delete(path);
121
+ return new Set($set);
122
+ });
123
+ }
124
+ };
125
+ const cancelAllAsyncValidations = () => {
126
+ asyncValidationQueue.length = 0;
127
+ for (const path of asyncValidationTrackers.keys())
128
+ cancelAsyncValidation(path);
129
+ asyncErrorsStore.set({});
130
+ };
131
+ const executeAsyncValidation = async (path, onComplete) => {
132
+ if (!asyncValidator) {
133
+ onComplete();
134
+ return;
135
+ }
136
+ const asyncValidatorForPath = asyncValidator[path];
137
+ if (!asyncValidatorForPath) {
138
+ onComplete();
139
+ return;
140
+ }
141
+ const syncError = getSyncErrorForPath(get(errors), path);
142
+ if (syncError) {
143
+ onComplete();
144
+ return;
145
+ }
146
+ const controller = new AbortController();
147
+ asyncValidationTrackers.set(path, { controller, timeoutId: 0 });
148
+ asyncValidatingSet.update(($set) => new Set([...$set, path]));
149
+ try {
150
+ const value = getValueAtPath(data, path);
151
+ const error = await asyncValidatorForPath(value, data, controller.signal);
152
+ if (!controller.signal.aborted)
153
+ asyncErrorsStore.update(($asyncErrors) => ({
154
+ ...$asyncErrors,
155
+ [path]: error
156
+ }));
157
+ }
158
+ catch (error) {
159
+ if (error instanceof Error && error.name !== 'AbortError')
160
+ throw error;
161
+ }
162
+ finally {
163
+ asyncValidationTrackers.delete(path);
164
+ asyncValidatingSet.update(($set) => {
165
+ $set.delete(path);
166
+ return new Set($set);
167
+ });
168
+ onComplete();
169
+ }
170
+ };
171
+ const processAsyncValidationQueue = () => {
172
+ while (asyncValidationQueue.length > 0) {
173
+ const currentActiveCount = get(asyncValidatingSet).size;
174
+ if (currentActiveCount >= usedOptions.maxConcurrentAsyncValidations)
175
+ break;
176
+ const path = asyncValidationQueue.shift();
177
+ if (path)
178
+ executeAsyncValidation(path, processAsyncValidationQueue);
179
+ }
180
+ };
181
+ const scheduleAsyncValidation = (path) => {
182
+ if (!asyncValidator || !asyncValidator[path])
183
+ return;
184
+ cancelAsyncValidation(path);
185
+ if (usedOptions.clearAsyncErrorsOnChange)
186
+ asyncErrorsStore.update(($asyncErrors) => {
187
+ const updated = { ...$asyncErrors };
188
+ delete updated[path];
189
+ return updated;
190
+ });
191
+ const controller = new AbortController();
192
+ const timeoutId = setTimeout(() => {
193
+ asyncValidationTrackers.delete(path);
194
+ const activeCount = get(asyncValidatingSet).size;
195
+ if (activeCount < usedOptions.maxConcurrentAsyncValidations)
196
+ executeAsyncValidation(path, processAsyncValidationQueue);
197
+ else {
198
+ removeFromQueue(path);
199
+ asyncValidationQueue.push(path);
200
+ }
201
+ }, usedOptions.debounceAsyncValidation);
202
+ asyncValidationTrackers.set(path, { controller, timeoutId });
203
+ };
204
+ const scheduleAsyncValidationsForPath = (changedPath) => {
205
+ if (!asyncValidator)
206
+ return;
207
+ const matchingPaths = getMatchingAsyncValidatorPaths(asyncValidator, changedPath);
208
+ for (const path of matchingPaths)
209
+ scheduleAsyncValidation(path);
210
+ };
63
211
  const data = ChangeProxy(stateObject, (target, property, currentValue, oldValue) => {
64
212
  if (!usedOptions.persistActionError)
65
213
  actionError.set(undefined);
@@ -68,9 +216,13 @@ export function createSvState(init, actuators, options) {
68
216
  if (effectResult instanceof Promise)
69
217
  throw new Error('svstate: effect callback must be synchronous. Use action for async operations.');
70
218
  scheduleValidation();
219
+ scheduleAsyncValidationsForPath(property);
71
220
  });
72
221
  if (validator)
73
222
  errors.set(validator(data));
223
+ if (asyncValidator && usedOptions.runAsyncValidationOnInit)
224
+ for (const path of Object.keys(asyncValidator))
225
+ scheduleAsyncValidation(path);
74
226
  const execute = async (parameters) => {
75
227
  if (!usedOptions.allowConcurrentActions && get(actionInProgress))
76
228
  return;
@@ -99,6 +251,7 @@ export function createSvState(init, actuators, options) {
99
251
  const targetSnapshot = currentSnapshots[targetIndex];
100
252
  if (!targetSnapshot)
101
253
  return;
254
+ cancelAllAsyncValidations();
102
255
  Object.assign(stateObject, deepClone(targetSnapshot.data));
103
256
  snapshots.set(currentSnapshots.slice(0, targetIndex + 1));
104
257
  if (validator)
@@ -109,6 +262,7 @@ export function createSvState(init, actuators, options) {
109
262
  const initialSnapshot = currentSnapshots[0];
110
263
  if (!initialSnapshot)
111
264
  return;
265
+ cancelAllAsyncValidations();
112
266
  Object.assign(stateObject, deepClone(initialSnapshot.data));
113
267
  snapshots.set([initialSnapshot]);
114
268
  if (validator)
@@ -120,7 +274,11 @@ export function createSvState(init, actuators, options) {
120
274
  isDirty,
121
275
  actionInProgress,
122
276
  actionError,
123
- snapshots
277
+ snapshots,
278
+ asyncErrors: asyncErrorsStore,
279
+ hasAsyncErrors,
280
+ asyncValidating,
281
+ hasCombinedErrors
124
282
  };
125
283
  return { data, execute, state, rollback, reset };
126
284
  }
@@ -1,28 +1,37 @@
1
1
  type BaseOption = 'trim' | 'normalize';
2
- export declare function stringValidator(input: string, ...prepares: (BaseOption | 'upper')[]): StringValidatorBuilder;
3
- export declare function stringValidator(input: string, ...prepares: (BaseOption | 'lower')[]): StringValidatorBuilder;
4
- export declare function stringValidator(input: string, ...prepares: BaseOption[]): StringValidatorBuilder;
2
+ export declare function stringValidator(input: string | null | undefined): StringValidatorBuilder;
5
3
  type StringValidatorBuilder = {
4
+ prepare(...prepares: (BaseOption | 'upper')[]): StringValidatorBuilder;
5
+ prepare(...prepares: (BaseOption | 'lower')[]): StringValidatorBuilder;
6
+ prepare(...prepares: (BaseOption | 'localeUpper')[]): StringValidatorBuilder;
7
+ prepare(...prepares: (BaseOption | 'localeLower')[]): StringValidatorBuilder;
8
+ prepare(...prepares: BaseOption[]): StringValidatorBuilder;
6
9
  required(): StringValidatorBuilder;
10
+ requiredIf(cond: boolean): StringValidatorBuilder;
7
11
  noSpace(): StringValidatorBuilder;
12
+ notBlank(): StringValidatorBuilder;
8
13
  minLength(length: number): StringValidatorBuilder;
9
14
  maxLength(length: number): StringValidatorBuilder;
10
15
  uppercase(): StringValidatorBuilder;
11
16
  lowercase(): StringValidatorBuilder;
12
17
  startsWith(prefix: string | string[]): StringValidatorBuilder;
13
18
  regexp(regexp: RegExp, message?: string): StringValidatorBuilder;
14
- inArray(values: string[] | Record<string, unknown>): StringValidatorBuilder;
19
+ in(values: string[] | Record<string, unknown>): StringValidatorBuilder;
20
+ notIn(values: string[] | Record<string, unknown>): StringValidatorBuilder;
15
21
  email(): StringValidatorBuilder;
16
22
  website(prefix?: 'required' | 'forbidden' | 'optional'): StringValidatorBuilder;
17
23
  endsWith(suffix: string | string[]): StringValidatorBuilder;
18
24
  contains(substring: string): StringValidatorBuilder;
19
25
  alphanumeric(): StringValidatorBuilder;
20
26
  numeric(): StringValidatorBuilder;
27
+ slug(): StringValidatorBuilder;
28
+ identifier(): StringValidatorBuilder;
21
29
  getError(): string;
22
30
  };
23
- export declare function numberValidator(input: number): NumberValidatorBuilder;
31
+ export declare function numberValidator(input: number | null | undefined): NumberValidatorBuilder;
24
32
  type NumberValidatorBuilder = {
25
33
  required(): NumberValidatorBuilder;
34
+ requiredIf(cond: boolean): NumberValidatorBuilder;
26
35
  min(n: number): NumberValidatorBuilder;
27
36
  max(n: number): NumberValidatorBuilder;
28
37
  between(min: number, max: number): NumberValidatorBuilder;
@@ -30,22 +39,30 @@ type NumberValidatorBuilder = {
30
39
  positive(): NumberValidatorBuilder;
31
40
  negative(): NumberValidatorBuilder;
32
41
  nonNegative(): NumberValidatorBuilder;
42
+ notZero(): NumberValidatorBuilder;
33
43
  multipleOf(n: number): NumberValidatorBuilder;
44
+ step(n: number): NumberValidatorBuilder;
34
45
  decimal(places: number): NumberValidatorBuilder;
35
46
  percentage(): NumberValidatorBuilder;
36
47
  getError(): string;
37
48
  };
38
- export declare function arrayValidator<T>(input: T[]): ArrayValidatorBuilder;
39
- type ArrayValidatorBuilder = {
40
- required(): ArrayValidatorBuilder;
41
- minLength(n: number): ArrayValidatorBuilder;
42
- maxLength(n: number): ArrayValidatorBuilder;
43
- unique(): ArrayValidatorBuilder;
49
+ export declare function arrayValidator<T>(input: T[] | null | undefined): ArrayValidatorBuilder<T>;
50
+ type ArrayValidatorBuilder<T> = {
51
+ required(): ArrayValidatorBuilder<T>;
52
+ requiredIf(cond: boolean): ArrayValidatorBuilder<T>;
53
+ minLength(n: number): ArrayValidatorBuilder<T>;
54
+ maxLength(n: number): ArrayValidatorBuilder<T>;
55
+ unique(): ArrayValidatorBuilder<T>;
56
+ ofLength(n: number): ArrayValidatorBuilder<T>;
57
+ includes(item: T): ArrayValidatorBuilder<T>;
58
+ includesAny(items: T[]): ArrayValidatorBuilder<T>;
59
+ includesAll(items: T[]): ArrayValidatorBuilder<T>;
44
60
  getError(): string;
45
61
  };
46
- export declare function dateValidator(input: Date | string | number): DateValidatorBuilder;
62
+ export declare function dateValidator(input: Date | string | number | null | undefined): DateValidatorBuilder;
47
63
  type DateValidatorBuilder = {
48
64
  required(): DateValidatorBuilder;
65
+ requiredIf(cond: boolean): DateValidatorBuilder;
49
66
  before(target: Date | string | number): DateValidatorBuilder;
50
67
  after(target: Date | string | number): DateValidatorBuilder;
51
68
  between(start: Date | string | number, end: Date | string | number): DateValidatorBuilder;
@@ -2,42 +2,71 @@ const prepareOps = {
2
2
  trim: (s) => s.trim(),
3
3
  normalize: (s) => s.replaceAll(/\s{2,}/g, ' '),
4
4
  upper: (s) => s.toUpperCase(),
5
- lower: (s) => s.toLowerCase()
5
+ lower: (s) => s.toLowerCase(),
6
+ localeUpper: (s) => s.toLocaleUpperCase(),
7
+ localeLower: (s) => s.toLocaleLowerCase()
6
8
  };
7
- export function stringValidator(input, ...prepares) {
9
+ export function stringValidator(input) {
8
10
  let error = '';
11
+ const isNullish = input === null || input === undefined;
12
+ let processedInput = input ?? '';
9
13
  const setError = (message) => {
10
14
  if (!error)
11
15
  error = message;
12
16
  };
13
- const processedInput = prepares.reduce((s, op) => prepareOps[op](s), input);
14
17
  const builder = {
18
+ prepare(...prepares) {
19
+ processedInput = prepares.reduce((s, op) => prepareOps[op](s), processedInput);
20
+ return builder;
21
+ },
15
22
  required() {
16
- if (!error && !processedInput)
23
+ if (!error && (isNullish || !processedInput))
24
+ setError('Required');
25
+ return builder;
26
+ },
27
+ requiredIf(cond) {
28
+ if (cond && !error && (isNullish || !processedInput))
17
29
  setError('Required');
18
30
  return builder;
19
31
  },
20
32
  noSpace() {
33
+ if (isNullish)
34
+ return builder;
21
35
  if (!error && processedInput.includes(' '))
22
36
  setError('No space allowed');
23
37
  return builder;
24
38
  },
39
+ notBlank() {
40
+ if (isNullish)
41
+ return builder;
42
+ if (!error && processedInput.length > 0 && processedInput.trim().length === 0)
43
+ setError('Must not be blank');
44
+ return builder;
45
+ },
25
46
  minLength(length) {
47
+ if (isNullish)
48
+ return builder;
26
49
  if (!error && processedInput.length < length)
27
50
  setError(`Min length ${length}`);
28
51
  return builder;
29
52
  },
30
53
  maxLength(length) {
54
+ if (isNullish)
55
+ return builder;
31
56
  if (!error && processedInput.length > length)
32
57
  setError(`Max length ${length}`);
33
58
  return builder;
34
59
  },
35
60
  uppercase() {
61
+ if (isNullish)
62
+ return builder;
36
63
  if (!error && processedInput !== processedInput.toUpperCase())
37
64
  setError('Uppercase only');
38
65
  return builder;
39
66
  },
40
67
  lowercase() {
68
+ if (isNullish)
69
+ return builder;
41
70
  if (!error && processedInput !== processedInput.toLowerCase())
42
71
  setError('Lowercase only');
43
72
  return builder;
@@ -55,7 +84,7 @@ export function stringValidator(input, ...prepares) {
55
84
  setError(message ?? 'Not allowed chars');
56
85
  return builder;
57
86
  },
58
- inArray(values) {
87
+ in(values) {
59
88
  if (error)
60
89
  return builder;
61
90
  const allowed = Array.isArray(values) ? values : Object.keys(values);
@@ -63,6 +92,14 @@ export function stringValidator(input, ...prepares) {
63
92
  setError(`Must be one of: ${allowed.join(', ')}`);
64
93
  return builder;
65
94
  },
95
+ notIn(values) {
96
+ if (error)
97
+ return builder;
98
+ const disallowed = Array.isArray(values) ? values : Object.keys(values);
99
+ if (processedInput && disallowed.includes(processedInput))
100
+ setError(`Must not be one of: ${disallowed.join(', ')}`);
101
+ return builder;
102
+ },
66
103
  email() {
67
104
  if (!error && processedInput && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(processedInput))
68
105
  setError('Invalid email format');
@@ -101,6 +138,16 @@ export function stringValidator(input, ...prepares) {
101
138
  setError('Only numbers allowed');
102
139
  return builder;
103
140
  },
141
+ slug() {
142
+ if (!error && processedInput && !/^[\da-z-]+$/.test(processedInput))
143
+ setError('Invalid slug format');
144
+ return builder;
145
+ },
146
+ identifier() {
147
+ if (!error && processedInput && !/^[A-Z_a-z]\w*$/.test(processedInput))
148
+ setError('Invalid identifier format');
149
+ return builder;
150
+ },
104
151
  getError() {
105
152
  return error;
106
153
  }
@@ -109,57 +156,95 @@ export function stringValidator(input, ...prepares) {
109
156
  }
110
157
  export function numberValidator(input) {
111
158
  let error = '';
159
+ const isNullish = input === null || input === undefined;
112
160
  const setError = (message) => {
113
161
  if (!error)
114
162
  error = message;
115
163
  };
116
164
  const builder = {
117
165
  required() {
118
- if (!error && Number.isNaN(input))
166
+ if (!error && (isNullish || Number.isNaN(input)))
167
+ setError('Required');
168
+ return builder;
169
+ },
170
+ requiredIf(cond) {
171
+ if (cond && !error && (isNullish || Number.isNaN(input)))
119
172
  setError('Required');
120
173
  return builder;
121
174
  },
122
175
  min(n) {
176
+ if (isNullish)
177
+ return builder;
123
178
  if (!error && input < n)
124
179
  setError(`Minimum ${n}`);
125
180
  return builder;
126
181
  },
127
182
  max(n) {
183
+ if (isNullish)
184
+ return builder;
128
185
  if (!error && input > n)
129
186
  setError(`Maximum ${n}`);
130
187
  return builder;
131
188
  },
132
189
  between(min, max) {
190
+ if (isNullish)
191
+ return builder;
133
192
  if (!error && (input < min || input > max))
134
193
  setError(`Must be between ${min} and ${max}`);
135
194
  return builder;
136
195
  },
137
196
  integer() {
197
+ if (isNullish)
198
+ return builder;
138
199
  if (!error && !Number.isInteger(input))
139
200
  setError('Must be an integer');
140
201
  return builder;
141
202
  },
142
203
  positive() {
204
+ if (isNullish)
205
+ return builder;
143
206
  if (!error && input <= 0)
144
207
  setError('Must be positive');
145
208
  return builder;
146
209
  },
147
210
  negative() {
211
+ if (isNullish)
212
+ return builder;
148
213
  if (!error && input >= 0)
149
214
  setError('Must be negative');
150
215
  return builder;
151
216
  },
152
217
  nonNegative() {
218
+ if (isNullish)
219
+ return builder;
153
220
  if (!error && input < 0)
154
221
  setError('Must be non-negative');
155
222
  return builder;
156
223
  },
224
+ notZero() {
225
+ if (isNullish)
226
+ return builder;
227
+ if (!error && input === 0)
228
+ setError('Must not be zero');
229
+ return builder;
230
+ },
157
231
  multipleOf(n) {
232
+ if (isNullish)
233
+ return builder;
234
+ if (!error && input % n !== 0)
235
+ setError(`Must be a multiple of ${n}`);
236
+ return builder;
237
+ },
238
+ step(n) {
239
+ if (isNullish)
240
+ return builder;
158
241
  if (!error && input % n !== 0)
159
242
  setError(`Must be a multiple of ${n}`);
160
243
  return builder;
161
244
  },
162
245
  decimal(places) {
246
+ if (isNullish)
247
+ return builder;
163
248
  if (error || Number.isNaN(input))
164
249
  return builder;
165
250
  const parts = String(input).split('.');
@@ -169,6 +254,8 @@ export function numberValidator(input) {
169
254
  return builder;
170
255
  },
171
256
  percentage() {
257
+ if (isNullish)
258
+ return builder;
172
259
  if (!error && (input < 0 || input > 100))
173
260
  setError('Must be between 0 and 100');
174
261
  return builder;
@@ -181,31 +268,44 @@ export function numberValidator(input) {
181
268
  }
182
269
  export function arrayValidator(input) {
183
270
  let error = '';
271
+ const isNullish = input === null || input === undefined;
272
+ const array = input ?? [];
184
273
  const setError = (message) => {
185
274
  if (!error)
186
275
  error = message;
187
276
  };
188
277
  const builder = {
189
278
  required() {
190
- if (!error && input.length === 0)
279
+ if (!error && (isNullish || array.length === 0))
280
+ setError('Required');
281
+ return builder;
282
+ },
283
+ requiredIf(cond) {
284
+ if (cond && !error && (isNullish || array.length === 0))
191
285
  setError('Required');
192
286
  return builder;
193
287
  },
194
288
  minLength(n) {
195
- if (!error && input.length < n)
289
+ if (isNullish)
290
+ return builder;
291
+ if (!error && array.length < n)
196
292
  setError(`Minimum ${n} items`);
197
293
  return builder;
198
294
  },
199
295
  maxLength(n) {
200
- if (!error && input.length > n)
296
+ if (isNullish)
297
+ return builder;
298
+ if (!error && array.length > n)
201
299
  setError(`Maximum ${n} items`);
202
300
  return builder;
203
301
  },
204
302
  unique() {
303
+ if (isNullish)
304
+ return builder;
205
305
  if (error)
206
306
  return builder;
207
307
  const seen = new Set();
208
- for (const item of input) {
308
+ for (const item of array) {
209
309
  const key = typeof item === 'object' ? JSON.stringify(item) : String(item);
210
310
  if (seen.has(key)) {
211
311
  setError('Items must be unique');
@@ -215,6 +315,57 @@ export function arrayValidator(input) {
215
315
  }
216
316
  return builder;
217
317
  },
318
+ ofLength(n) {
319
+ if (isNullish)
320
+ return builder;
321
+ if (!error && array.length !== n)
322
+ setError(`Must have exactly ${n} items`);
323
+ return builder;
324
+ },
325
+ includes(item) {
326
+ if (isNullish)
327
+ return builder;
328
+ if (error)
329
+ return builder;
330
+ const itemKey = typeof item === 'object' ? JSON.stringify(item) : String(item);
331
+ const found = array.some((element) => {
332
+ const elementKey = typeof element === 'object' ? JSON.stringify(element) : String(element);
333
+ return elementKey === itemKey;
334
+ });
335
+ if (!found)
336
+ setError(`Must include ${itemKey}`);
337
+ return builder;
338
+ },
339
+ includesAny(items) {
340
+ if (isNullish)
341
+ return builder;
342
+ if (error)
343
+ return builder;
344
+ const itemKeys = items.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : String(entry)));
345
+ const found = array.some((element) => {
346
+ const elementKey = typeof element === 'object' ? JSON.stringify(element) : String(element);
347
+ return itemKeys.includes(elementKey);
348
+ });
349
+ if (!found)
350
+ setError(`Must include at least one of: ${itemKeys.join(', ')}`);
351
+ return builder;
352
+ },
353
+ includesAll(items) {
354
+ if (isNullish)
355
+ return builder;
356
+ if (error)
357
+ return builder;
358
+ const arrayKeys = new Set(array.map((element) => (typeof element === 'object' ? JSON.stringify(element) : String(element))));
359
+ const missing = items.filter((entry) => {
360
+ const entryKey = typeof entry === 'object' ? JSON.stringify(entry) : String(entry);
361
+ return !arrayKeys.has(entryKey);
362
+ });
363
+ if (missing.length > 0) {
364
+ const missingKeys = missing.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : String(entry)));
365
+ setError(`Missing required items: ${missingKeys.join(', ')}`);
366
+ }
367
+ return builder;
368
+ },
218
369
  getError() {
219
370
  return error;
220
371
  }
@@ -223,18 +374,24 @@ export function arrayValidator(input) {
223
374
  }
224
375
  export function dateValidator(input) {
225
376
  let error = '';
377
+ const isNullish = input === null || input === undefined;
226
378
  const setError = (message) => {
227
379
  if (!error)
228
380
  error = message;
229
381
  };
230
- const date = input instanceof Date ? input : new Date(input);
231
- const isValid = !Number.isNaN(date.getTime());
382
+ const date = isNullish ? new Date(Number.NaN) : input instanceof Date ? input : new Date(input);
383
+ const isValid = !isNullish && !Number.isNaN(date.getTime());
232
384
  const builder = {
233
385
  required() {
234
386
  if (!error && !isValid)
235
387
  setError('Required');
236
388
  return builder;
237
389
  },
390
+ requiredIf(cond) {
391
+ if (cond && !error && !isValid)
392
+ setError('Required');
393
+ return builder;
394
+ },
238
395
  before(target) {
239
396
  if (!error && isValid) {
240
397
  const targetDate = target instanceof Date ? target : new Date(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svstate",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Supercharged $state() for Svelte 5: deep reactive proxy with validation, cross-field rules, computed & side-effects",
5
5
  "author": "BCsabaEngine",
6
6
  "license": "ISC",
@@ -54,21 +54,21 @@
54
54
  ],
55
55
  "devDependencies": {
56
56
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
57
- "@types/node": "^25.0.9",
58
- "@typescript-eslint/eslint-plugin": "^8.53.1",
59
- "@typescript-eslint/parser": "^8.53.1",
60
- "@vitest/coverage-v8": "^4.0.17",
57
+ "@types/node": "^25.1.0",
58
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
59
+ "@typescript-eslint/parser": "^8.54.0",
60
+ "@vitest/coverage-v8": "^4.0.18",
61
61
  "eslint": "^9.39.2",
62
62
  "eslint-config-prettier": "^10.1.8",
63
63
  "eslint-plugin-simple-import-sort": "^12.1.1",
64
64
  "eslint-plugin-unicorn": "^62.0.0",
65
65
  "nodemon": "^3.1.11",
66
- "prettier": "^3.8.0",
67
- "svelte": "^5.47.1",
66
+ "prettier": "^3.8.1",
67
+ "svelte": "^5.49.1",
68
68
  "ts-node": "^10.9.2",
69
69
  "tsx": "^4.21.0",
70
70
  "typescript": "^5.9.3",
71
- "vitest": "^4.0.17"
71
+ "vitest": "^4.0.18"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "svelte": "^5.0.0"