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 +212 -40
- package/dist/index.d.ts +1 -1
- package/dist/state.svelte.d.ts +16 -0
- package/dist/state.svelte.js +162 -4
- package/dist/validators.d.ts +29 -12
- package/dist/validators.js +169 -12
- package/package.json +8 -8
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
|
|
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
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
556
|
+
city: stringValidator(source.address.city)
|
|
557
|
+
.prepare('trim')
|
|
411
558
|
.required()
|
|
412
559
|
.getError(),
|
|
413
|
-
zip: stringValidator(source.address.zip
|
|
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
|
-
.
|
|
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
|
-
.
|
|
581
|
+
.in(['NET15', 'NET30', 'NET60', 'COD'])
|
|
434
582
|
.getError(),
|
|
435
583
|
currency: stringValidator(source.billing.currency)
|
|
436
584
|
.required()
|
|
437
|
-
.
|
|
585
|
+
.in(['USD', 'EUR', 'GBP'])
|
|
438
586
|
.getError(),
|
|
439
587
|
bankAccount: {
|
|
440
|
-
iban: stringValidator(source.billing.bankAccount.iban
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>` |
|
|
704
|
-
| `state.hasErrors` | `Readable<boolean>` |
|
|
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
|
|
717
|
-
|
|
|
718
|
-
| `stringValidator(input
|
|
719
|
-
| `numberValidator(input)`
|
|
720
|
-
| `arrayValidator(input)`
|
|
721
|
-
| `dateValidator(input)`
|
|
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 {
|
|
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
|
|
732
|
-
|
|
|
733
|
-
| `Validator`
|
|
734
|
-
| `EffectContext<T>`
|
|
735
|
-
| `SnapshotFunction`
|
|
736
|
-
| `Snapshot<T>`
|
|
737
|
-
| `SvStateOptions`
|
|
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';
|
package/dist/state.svelte.d.ts
CHANGED
|
@@ -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;
|
package/dist/state.svelte.js
CHANGED
|
@@ -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
|
}
|
package/dist/validators.d.ts
CHANGED
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
type BaseOption = 'trim' | 'normalize';
|
|
2
|
-
export declare function stringValidator(input: string
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
package/dist/validators.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
58
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
59
|
-
"@typescript-eslint/parser": "^8.
|
|
60
|
-
"@vitest/coverage-v8": "^4.0.
|
|
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.
|
|
67
|
-
"svelte": "^5.
|
|
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.
|
|
71
|
+
"vitest": "^4.0.18"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"svelte": "^5.0.0"
|