@coherent.js/state 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/types/index.d.ts CHANGED
@@ -3,256 +3,261 @@
3
3
  * @module @coherent.js/state
4
4
  */
5
5
 
6
- import type { CoherentNode, ComponentState } from '@coherent.js/core';
7
-
8
6
  // ============================================================================
9
- // Core Store Types
7
+ // Reactive State
10
8
  // ============================================================================
11
9
 
12
- /**
13
- * Typed state store with subscribe and update capabilities
14
- * @template T - The shape of the state object
15
- */
16
- export interface Store<T extends Record<string, unknown> = Record<string, unknown>> {
17
- /**
18
- * Get the current state
19
- */
20
- getState(): T;
21
-
22
- /**
23
- * Update state with partial object or updater function
24
- */
25
- setState(partial: Partial<T> | ((state: T) => Partial<T>)): void;
26
-
27
- /**
28
- * Subscribe to state changes
29
- * @returns Unsubscribe function
30
- */
31
- subscribe(listener: (state: T, prevState: T) => void): () => void;
32
-
33
- /**
34
- * Destroy the store and clean up subscriptions
35
- */
36
- destroy(): void;
37
- }
38
-
39
- /**
40
- * Store creation options
41
- * @template T - The shape of the state object
42
- */
43
- export interface StoreOptions<T extends Record<string, unknown> = Record<string, unknown>> {
44
- /** Initial state */
45
- initialState: T;
46
- /** Persistence configuration */
47
- persist?: {
48
- /** Storage key */
49
- key: string;
50
- /** Storage adapter (localStorage, sessionStorage, or custom) */
51
- storage?: Storage;
52
- /** Custom serialization */
53
- serialize?: (state: T) => string;
54
- /** Custom deserialization */
55
- deserialize?: (value: string) => T;
56
- /** Debounce persistence writes (ms) */
57
- debounce?: number;
58
- };
59
- /** Enable devtools integration */
60
- devtools?: boolean;
61
- /** Store name for debugging */
62
- name?: string;
63
- /** Middleware functions */
64
- middleware?: Array<StoreMiddleware<T>>;
10
+ /** Called with the new and previous value; `unwatch` stops further calls. */
11
+ export type Watcher<T = unknown> = (
12
+ newValue: T,
13
+ oldValue: T | undefined,
14
+ unwatch: () => void
15
+ ) => void;
16
+
17
+ export interface ObservableOptions {
18
+ /** Notify even when the new value is `===` the old one; defaults to `true` */
19
+ deep?: boolean;
20
+ /** Invoke watchers on subscribe; defaults to `true` */
21
+ immediate?: boolean;
22
+ [option: string]: unknown;
65
23
  }
66
24
 
67
25
  /**
68
- * Store middleware function
26
+ * A single reactive value.
27
+ *
28
+ * Read and write through the `value` accessor — assigning notifies watchers
29
+ * and invalidates any computed that read it.
30
+ *
31
+ * ```ts
32
+ * const count = observable(0);
33
+ * count.watch(next => console.log(next));
34
+ * count.value = 1;
35
+ * ```
69
36
  */
70
- export type StoreMiddleware<T> = (
71
- state: T,
72
- action: string,
73
- payload?: unknown
74
- ) => T | void;
75
-
76
- // ============================================================================
77
- // Selector Types
78
- // ============================================================================
37
+ export class Observable<T = unknown> {
38
+ constructor(value: T, options?: ObservableOptions);
79
39
 
80
- // ============================================================================
81
- // Action Types
82
- // ============================================================================
40
+ /** The current value; assigning notifies watchers */
41
+ get value(): T;
42
+ set value(newValue: T);
83
43
 
84
- /**
85
- * Action type helper for type-safe actions
86
- * @template T - Store state type
87
- * @template P - Payload type (void for no payload)
88
- */
89
- export type Action<T, P = void> = P extends void
90
- ? () => Partial<T>
91
- : (payload: P) => Partial<T>;
44
+ /** Subscribe to changes; returns an unwatch function */
45
+ watch(callback: Watcher<T>, options?: { immediate?: boolean }): () => void;
92
46
 
93
- // ============================================================================
94
- // Reactive State Types
95
- // ============================================================================
47
+ /** Remove one observer */
48
+ unwatch(observer: (newValue: T, oldValue: T | undefined) => void): void;
96
49
 
97
- /**
98
- * Observer callback type
99
- */
100
- export interface Observer<T = unknown> {
101
- (value: T, oldValue: T): void;
50
+ /** Remove every observer and computed dependent */
51
+ unwatchAll(): void;
102
52
  }
103
53
 
104
- /**
105
- * Observable value wrapper
106
- */
107
- export class Observable<T = unknown> {
108
- constructor(initialValue: T);
109
-
110
- /** Get current value */
111
- get(): T;
54
+ /** Raised by the reactive primitives. */
55
+ export class StateError extends Error {
56
+ constructor(message: string, options?: Record<string, unknown>);
57
+ }
112
58
 
113
- /** Set new value */
114
- set(value: T): void;
59
+ /** Sink for errors thrown inside watchers and computed getters. */
60
+ export const globalErrorHandler: {
61
+ handle(error: unknown, context?: Record<string, unknown>): void;
62
+ };
115
63
 
116
- /** Subscribe to changes */
117
- subscribe(observer: Observer<T>): () => void;
64
+ export interface ReactiveStateOptions extends ObservableOptions {
65
+ /** Run registered middleware on `set()` */
66
+ enableMiddleware?: boolean;
67
+ /** Record mutations so `undo()` and `getHistory()` work */
68
+ enableHistory?: boolean;
69
+ /** Cap on retained history entries */
70
+ maxHistorySize?: number;
71
+ }
118
72
 
119
- /** Unsubscribe observer */
120
- unsubscribe(observer: Observer<T>): void;
73
+ /** What `set` middleware may return to alter or veto a write. */
74
+ export interface MiddlewareResult {
75
+ cancelled?: boolean;
76
+ value?: unknown;
77
+ }
121
78
 
122
- /** Notify all observers */
123
- notify(): void;
79
+ /** Payload handed to a `subscribe()` listener. */
80
+ export interface StateChange<T = unknown> {
81
+ key: string;
82
+ newValue: T;
83
+ oldValue: T | undefined;
84
+ state: Record<string, unknown>;
124
85
  }
125
86
 
126
- /**
127
- * Reactive state options
128
- */
129
- export interface ReactiveStateOptions<T = unknown> {
130
- /** Initial state value */
131
- initialValue: T;
132
- /** Computed properties */
133
- computed?: Record<string, (state: T) => unknown>;
134
- /** Property watchers */
135
- watchers?: Record<string, Observer<unknown>>;
136
- /** Middleware functions */
137
- middleware?: Array<(state: T, action: string, payload?: unknown) => T | void>;
87
+ /** One recorded mutation. */
88
+ export interface HistoryEntry {
89
+ action: 'set' | 'delete' | 'clear' | 'batch';
90
+ key: string | null;
91
+ oldValue: unknown;
92
+ newValue: unknown;
93
+ timestamp?: number;
138
94
  }
139
95
 
140
96
  /**
141
- * Reactive state class with computed properties and watchers
97
+ * A keyed collection of observables, with computed properties, watchers,
98
+ * middleware and optional undo history.
99
+ *
100
+ * ```ts
101
+ * const state = createReactiveState({ count: 0 });
102
+ * state.computed('doubled', () => state.get('count') * 2);
103
+ * state.watch('count', next => console.log(next));
104
+ * state.set('count', 1);
105
+ * ```
142
106
  */
143
- export class ReactiveState<T extends Record<string, unknown> = Record<string, unknown>> {
144
- constructor(options: ReactiveStateOptions<T>);
107
+ export class ReactiveState {
108
+ constructor(initialState?: Record<string, unknown>, options?: ReactiveStateOptions);
145
109
 
146
- /** Get a property value */
147
- get<K extends keyof T>(key: K): T[K];
110
+ /** Current value of a key, or `undefined` */
111
+ get<T = unknown>(key: string): T | undefined;
148
112
 
149
- /** Set a property value */
150
- set<K extends keyof T>(key: K, value: T[K]): void;
113
+ /** Write a key; `false` when middleware cancelled the write */
114
+ set(key: string, value: unknown, options?: ReactiveStateOptions): boolean;
151
115
 
152
- /** Update multiple properties */
153
- update(partial: Partial<T>): void;
116
+ /** Whether the key exists */
117
+ has(key: string): boolean;
154
118
 
155
- /** Subscribe to all changes */
156
- subscribe(observer: Observer<T>): () => void;
119
+ /** Drop a key and its watchers; `false` when it was absent */
120
+ delete(key: string): boolean;
157
121
 
158
- /** Watch a specific property */
159
- watch<K extends keyof T>(key: K, observer: Observer<T[K]>): () => void;
122
+ /** Drop every key */
123
+ clear(): void;
160
124
 
161
- /** Get full state */
162
- getState(): T;
125
+ /** Define a computed property derived from other keys */
126
+ computed(key: string, getter: () => unknown, options?: ObservableOptions): void;
163
127
 
164
- /** Reset to initial state */
165
- reset(): void;
128
+ /** Current value of a computed property, or `undefined` */
129
+ getComputed<T = unknown>(key: string): T | undefined;
130
+
131
+ /**
132
+ * Watch one key, or a getter expression. Returns an unwatch function, and
133
+ * throws {@link StateError} for a key that does not exist.
134
+ */
135
+ watch<T = unknown>(
136
+ key: string | (() => T),
137
+ callback: Watcher<T>,
138
+ options?: { immediate?: boolean }
139
+ ): () => void;
140
+
141
+ /** Apply several writes with history suppressed until the batch ends */
142
+ batch<T>(updates: ((state: this) => T) | Record<string, unknown>): T | undefined;
143
+
144
+ /** Subscribe to one or more keys; returns an unsubscribe function */
145
+ subscribe(
146
+ keys: string | string[],
147
+ callback: (change: StateChange) => void,
148
+ options?: { immediate?: boolean }
149
+ ): () => void;
150
+
151
+ /** Register middleware consulted on every `set()` */
152
+ use(middleware: (action: string, context: Record<string, unknown>) => MiddlewareResult | void): void;
153
+
154
+ /** Most recent mutations, newest first */
155
+ getHistory(limit?: number): HistoryEntry[];
156
+
157
+ /** Revert the most recent mutation; `false` when history is empty */
158
+ undo(): boolean;
159
+
160
+ /** Snapshot every key as a plain object */
161
+ toObject(): Record<string, unknown>;
162
+
163
+ /** Snapshot every computed property as a plain object */
164
+ getComputedValues(): Record<string, unknown>;
165
+
166
+ /** Counts of keys, computeds, watchers, history entries and middleware */
167
+ getStats(): {
168
+ stateKeys: number;
169
+ computedKeys: number;
170
+ watcherKeys: number;
171
+ historyLength: number;
172
+ middlewareCount: number;
173
+ };
174
+
175
+ /** Release every watcher and drop all state */
176
+ destroy(): void;
166
177
  }
167
178
 
168
- /**
169
- * Create a reactive state instance
170
- */
171
- export function createReactiveState<T extends Record<string, unknown> = Record<string, unknown>>(
172
- options: ReactiveStateOptions<T>
173
- ): ReactiveState<T>;
179
+ /** Create a {@link ReactiveState}. */
180
+ export function createReactiveState(
181
+ initialState?: Record<string, unknown>,
182
+ options?: ReactiveStateOptions
183
+ ): ReactiveState;
174
184
 
175
- /**
176
- * Create a simple observable value
177
- */
178
- export function observable<T = unknown>(initialValue: T): Observable<T>;
185
+ /** Create an {@link Observable}. */
186
+ export function observable<T = unknown>(value: T, options?: ObservableOptions): Observable<T>;
179
187
 
180
188
  /**
181
- * Create a computed observable
189
+ * Create a read-only observable derived from other observables. Dependencies
190
+ * are tracked automatically; assigning to `value` throws {@link StateError}.
182
191
  */
183
192
  export function computed<T = unknown>(
184
- fn: () => T,
185
- dependencies: Observable<unknown>[]
193
+ getter: () => T,
194
+ options?: ObservableOptions
186
195
  ): Observable<T>;
187
196
 
188
- /**
189
- * State utility functions
190
- */
197
+ /** An observable with a `toggle()` helper. */
198
+ export type ToggleObservable = Observable<boolean> & { toggle(): void };
199
+
200
+ /** An observable with counter helpers. */
201
+ export type CounterObservable = Observable<number> & {
202
+ increment(by?: number): void;
203
+ decrement(by?: number): void;
204
+ reset(): void;
205
+ };
206
+
207
+ /** An observable with immutable array helpers. */
208
+ export type ArrayObservable<T = unknown> = Observable<T[]> & {
209
+ push(...items: T[]): void;
210
+ pop(): T | undefined;
211
+ filter(predicate: (item: T, index: number, array: T[]) => boolean): void;
212
+ clear(): void;
213
+ };
214
+
215
+ /** Observable factories for common shapes. */
191
216
  export const stateUtils: {
192
- /** Batch multiple state updates */
193
- batch<T>(fn: () => T): T;
194
- /** Run updates in a transaction */
195
- transaction<T>(fn: () => T): T;
196
- /** Freeze state (make immutable) */
197
- freeze<T>(state: T): Readonly<T>;
198
- /** Deep clone state */
199
- clone<T>(state: T): T;
217
+ /** A boolean observable that can flip itself */
218
+ toggle(initialValue?: boolean): ToggleObservable;
219
+ /** A numeric observable with increment/decrement/reset */
220
+ counter(initialValue?: number): CounterObservable;
221
+ /** An array observable whose helpers replace rather than mutate */
222
+ array<T = unknown>(initialArray?: T[]): ArrayObservable<T>;
223
+ /** A deeply reactive state container, not an observable */
224
+ object(initialObject?: Record<string, unknown>): ReactiveState;
200
225
  };
201
226
 
202
227
  // ============================================================================
203
- // SSR-Compatible State Manager
228
+ // SSR State Manager
204
229
  // ============================================================================
205
230
 
206
- /**
207
- * State manager options
208
- */
209
- export interface StateManagerOptions<T = unknown> {
210
- /** Initial state */
211
- initialState?: T;
212
- /** Enable persistence */
213
- persist?: boolean;
214
- /** Persistence key */
215
- key?: string;
216
- /** Middleware functions */
217
- middleware?: Array<(state: T, action: string) => T | void>;
218
- }
219
-
220
- /**
221
- * Simple state interface
222
- */
223
- export interface State<T = unknown> {
224
- /** Get current state */
225
- get(): T;
226
- /** Set new state */
227
- set(value: T): void;
228
- /** Update with partial */
229
- update(partial: Partial<T>): void;
230
- /** Subscribe to changes */
231
- subscribe(listener: (state: T) => void): () => void;
232
- /** Reset to initial state */
233
- reset(): void;
231
+ /** A per-render key/value store. */
232
+ export interface StateContainer {
233
+ get<T = unknown>(key: string): T | undefined;
234
+ /** Chainable */
235
+ set(key: string, value: unknown): StateContainer;
236
+ has(key: string): boolean;
237
+ /** `false` when the key was absent */
238
+ delete(key: string): boolean;
239
+ /** Chainable */
240
+ clear(): StateContainer;
241
+ /** Snapshot as a plain object */
242
+ toObject(): Record<string, unknown>;
234
243
  }
235
244
 
236
245
  /**
237
- * Create a simple state container
246
+ * Create a state container scoped to one request or render.
238
247
  */
239
- export function createState<T = unknown>(
240
- initialState: T,
241
- options?: StateManagerOptions<T>
242
- ): State<T>;
248
+ export function createState(initialState?: Record<string, unknown>): StateContainer;
243
249
 
244
250
  /**
245
- * Global state manager for SSR
251
+ * Process-wide store shared across renders. Prefer
252
+ * {@link StateContainer | request state} for anything request-specific.
246
253
  */
247
254
  export const globalStateManager: {
248
- /** Get state by key */
249
- getState<T = unknown>(key: string): T | undefined;
250
- /** Set state by key */
251
- setState<T = unknown>(key: string, value: T): void;
252
- /** Subscribe to state key */
253
- subscribe<T = unknown>(key: string, listener: (state: T) => void): () => void;
254
- /** Clear state (optionally by key) */
255
- clear(key?: string): void;
255
+ set(key: string, value: unknown): void;
256
+ get<T = unknown>(key: string): T | undefined;
257
+ has(key: string): boolean;
258
+ clear(): void;
259
+ /** A fresh container isolated from the global store */
260
+ createRequestState(): StateContainer;
256
261
  };
257
262
 
258
263
  // ============================================================================
@@ -260,163 +265,362 @@ export const globalStateManager: {
260
265
  // ============================================================================
261
266
 
262
267
  /**
263
- * Context value wrapper
264
- */
265
- export interface ContextValue<T = unknown> {
266
- value: T;
267
- subscribers: Set<(value: T) => void>;
268
- }
269
-
270
- /**
271
- * Provide a context value
268
+ * Push a context value, remembering the previous one so
269
+ * {@link restoreContext} can unwind it.
272
270
  */
273
- export function provideContext<T = unknown>(key: string, value: T): void;
271
+ export function provideContext(key: string, value: unknown): void;
274
272
 
275
273
  /**
276
- * Create a context provider
274
+ * Wrap children in a context. The returned function provides the context,
275
+ * renders, then restores the previous value.
277
276
  */
278
- export function createContextProvider<T = unknown>(
277
+ export function createContextProvider<C = unknown, R = unknown>(
279
278
  key: string,
280
- value: T
281
- ): { key: string; value: T };
279
+ value: unknown,
280
+ children: C
281
+ ): (renderFunction?: (children: C) => R) => R | C;
282
282
 
283
- /**
284
- * Use/consume a context value
285
- */
286
- export function useContext<T = unknown>(key: string, defaultValue?: T): T;
283
+ /** Pop one context value, restoring what {@link provideContext} replaced. */
284
+ export function restoreContext(key: string): void;
287
285
 
288
286
  /**
289
- * Restore context from saved state
290
- */
291
- export function restoreContext(contexts: Record<string, unknown>): void;
292
-
293
- /**
294
- * Clear all context stacks
287
+ * Unwind every tracked context to the value it held before its first
288
+ * `provideContext()`. Call between renders so one request's contexts do not
289
+ * leak into the next.
295
290
  */
296
291
  export function clearAllContexts(): void;
297
292
 
293
+ /** Read the current context value, or `undefined`. */
294
+ export function useContext<T = unknown>(key: string): T | undefined;
295
+
298
296
  // ============================================================================
299
297
  // Persistent State
300
298
  // ============================================================================
301
299
 
302
- /**
303
- * Persistence adapter interface
304
- */
300
+ /** Where persistent state is written. */
301
+ export type StorageKind = 'localStorage' | 'sessionStorage' | 'indexedDB' | 'memory';
302
+
303
+ /** Storage backend contract. */
305
304
  export interface PersistenceAdapter {
306
- /** Get item from storage */
307
- getItem(key: string): Promise<string | null> | string | null;
308
- /** Set item in storage */
309
- setItem(key: string, value: string): Promise<void> | void;
310
- /** Remove item from storage */
311
- removeItem(key: string): Promise<void> | void;
305
+ get(key: string): Promise<string | null> | string | null;
306
+ set(key: string, value: string): Promise<boolean> | boolean;
307
+ remove(key: string): Promise<boolean> | boolean;
308
+ clear(): Promise<boolean> | boolean;
312
309
  }
313
310
 
314
- /**
315
- * Persistent state options
316
- */
317
- export interface PersistentStateOptions<T = unknown> extends StateManagerOptions<T> {
318
- /** Required: storage key */
319
- key: string;
320
- /** Storage adapter */
321
- storage?: PersistenceAdapter;
322
- /** Custom serialization */
323
- serialize?: (state: T) => string;
324
- /** Custom deserialization */
325
- deserialize?: (data: string) => T;
326
- /** Debounce writes (ms) */
327
- debounce?: number;
311
+ export interface PersistentStateOptions {
312
+ /** Backend; defaults to `'localStorage'` */
313
+ storage?: StorageKind;
314
+ /** Storage key; defaults to `'coherent-state'` */
315
+ key?: string;
316
+ /** Coalesce writes; defaults to `true` */
317
+ debounce?: boolean;
318
+ /** Debounce window in ms; defaults to `300` */
319
+ debounceDelay?: number;
320
+ serialize?: (state: Record<string, unknown>) => string;
321
+ deserialize?: (data: string) => Record<string, unknown>;
322
+ /** Persist only these keys */
323
+ include?: string[] | null;
324
+ /** Persist everything except these keys */
325
+ exclude?: string[] | null;
326
+ /** Obfuscate the payload with `encryptionKey` */
327
+ encrypt?: boolean;
328
+ encryptionKey?: string | null;
329
+ onSave?: ((state: Record<string, unknown>) => void) | null;
330
+ onLoad?: ((state: Record<string, unknown>) => void) | null;
331
+ onError?: ((error: unknown) => void) | null;
332
+ /** Tag stored payloads with `version` and run `migrate` on mismatch */
333
+ versioning?: boolean;
334
+ version?: string;
335
+ migrate?: ((state: Record<string, unknown>, from: string) => Record<string, unknown>) | null;
336
+ /** Discard stored state older than this many ms */
337
+ ttl?: number | null;
338
+ /** Mirror updates to other tabs over BroadcastChannel */
339
+ crossTab?: boolean;
328
340
  }
329
341
 
330
- /**
331
- * Create a persistent state
332
- */
333
- export function createPersistentState<T = unknown>(
334
- options: PersistentStateOptions<T>
335
- ): State<T>;
342
+ /** A state container backed by storage. */
343
+ export interface PersistentState {
344
+ /** One key, or a copy of the whole state */
345
+ getState<T = unknown>(key: string): T | undefined;
346
+ getState(): Record<string, unknown>;
347
+ /** Merge updates, persisting unless `persist` is `false` */
348
+ setState(
349
+ updates:
350
+ | Record<string, unknown>
351
+ | ((state: Record<string, unknown>) => Record<string, unknown>),
352
+ persist?: boolean
353
+ ): void;
354
+ /** Restore the initial values */
355
+ resetState(persist?: boolean): void;
356
+ /** Subscribe to changes; returns an unsubscribe function */
357
+ subscribe(
358
+ listener: (state: Record<string, unknown>, oldState: Record<string, unknown>) => void
359
+ ): () => void;
360
+ /** Force an immediate write */
361
+ persist(): Promise<void>;
362
+ /** Reload from storage; `false` when nothing was stored */
363
+ restore(): Promise<boolean>;
364
+ /** Remove the stored payload */
365
+ clearStorage(): Promise<void>;
366
+ load(): Promise<Record<string, unknown> | null>;
367
+ save(): Promise<void>;
368
+ readonly adapter: PersistenceAdapter;
369
+ }
336
370
 
337
371
  /**
338
- * Wrap state with localStorage persistence
372
+ * Create a state container that persists to storage. Unless the backend is
373
+ * `'memory'`, stored state is restored on creation.
339
374
  */
340
- export function withLocalStorage<T = unknown>(state: State<T>, key: string): State<T>;
375
+ export function createPersistentState(
376
+ initialState?: Record<string, unknown>,
377
+ options?: PersistentStateOptions
378
+ ): PersistentState;
341
379
 
342
- /**
343
- * Wrap state with sessionStorage persistence
344
- */
345
- export function withSessionStorage<T = unknown>(state: State<T>, key: string): State<T>;
380
+ /** {@link createPersistentState} backed by localStorage. */
381
+ export function withLocalStorage(
382
+ initialState?: Record<string, unknown>,
383
+ key?: string,
384
+ options?: PersistentStateOptions
385
+ ): PersistentState;
346
386
 
347
- /**
348
- * Wrap state with IndexedDB persistence
349
- */
350
- export function withIndexedDB<T = unknown>(
351
- state: State<T>,
352
- key: string,
353
- dbName?: string
354
- ): Promise<State<T>>;
387
+ /** {@link createPersistentState} backed by sessionStorage. */
388
+ export function withSessionStorage(
389
+ initialState?: Record<string, unknown>,
390
+ key?: string,
391
+ options?: PersistentStateOptions
392
+ ): PersistentState;
393
+
394
+ /** {@link createPersistentState} backed by IndexedDB. */
395
+ export function withIndexedDB(
396
+ initialState?: Record<string, unknown>,
397
+ key?: string,
398
+ options?: PersistentStateOptions
399
+ ): PersistentState;
355
400
 
356
401
  // ============================================================================
357
402
  // Validated State
358
403
  // ============================================================================
359
404
 
360
- /**
361
- * Validation rule function
362
- */
363
- export interface ValidationRule<T = unknown> {
364
- (value: T): boolean | string;
405
+ /** JSON-Schema-style constraints. */
406
+ export interface StateSchema {
407
+ type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null';
408
+ properties?: Record<string, StateSchema>;
409
+ required?: string[];
410
+ enum?: unknown[];
411
+ minLength?: number;
412
+ maxLength?: number;
413
+ pattern?: string;
414
+ format?: 'email' | 'url' | 'uuid' | 'date' | 'date-time' | (string & {});
415
+ minimum?: number;
416
+ maximum?: number;
417
+ exclusiveMinimum?: number;
418
+ exclusiveMaximum?: number;
419
+ multipleOf?: number;
420
+ items?: StateSchema;
421
+ minItems?: number;
422
+ maxItems?: number;
423
+ uniqueItems?: boolean;
424
+ /** Custom check run after the built-in constraints */
425
+ validate?: (value: unknown) => boolean | string;
426
+ [keyword: string]: unknown;
365
427
  }
366
428
 
367
- /**
368
- * Validated state options
369
- */
370
- export interface ValidatedStateOptions<T extends Record<string, unknown> = Record<string, unknown>>
371
- extends StateManagerOptions<T> {
372
- /** Validation rules by property */
373
- validators: { [K in keyof T]?: ValidationRule<T[K]>[] };
374
- /** Validate on every change */
375
- validateOnChange?: boolean;
376
- /** Throw on validation failure */
429
+ /** One validation failure. */
430
+ export interface ValidationError {
431
+ path: string;
432
+ message: string;
433
+ [detail: string]: unknown;
434
+ }
435
+
436
+ export interface ValidationResult {
437
+ valid: boolean;
438
+ errors: ValidationError[];
439
+ /** The value after coercion, when `coerce` is set */
440
+ value?: unknown;
441
+ }
442
+
443
+ /** Returns `true` when valid, or a message describing the failure. */
444
+ export type Validator<T = unknown> = (value: T) => boolean | string;
445
+
446
+ export interface ValidatedStateOptions {
447
+ /** Schema applied to the whole state, and per key via `properties` */
448
+ schema?: StateSchema | null;
449
+ /** Extra per-key validators */
450
+ validators?: Record<string, Validator | Validator[]>;
451
+ /** Reject writes that fail validation */
377
452
  strict?: boolean;
453
+ /** Convert values to the declared type where possible */
454
+ coerce?: boolean;
455
+ onError?: ((errors: ValidationError[]) => void) | null;
456
+ /** Validate on write; defaults to `true` */
457
+ validateOnSet?: boolean;
458
+ /** Validate on read; defaults to `false` */
459
+ validateOnGet?: boolean;
460
+ required?: string[];
461
+ /** Permit keys the schema does not mention; defaults to `true` */
462
+ allowUnknown?: boolean;
378
463
  }
379
464
 
380
- /**
381
- * Validated state interface
382
- */
383
- export interface ValidatedState<T extends Record<string, unknown> = Record<string, unknown>>
384
- extends State<T> {
385
- /** Validate current state */
386
- validate(): { valid: boolean; errors: { [K in keyof T]?: string[] } };
387
- /** Check if state is valid */
465
+ /** A state container that validates writes. */
466
+ export interface ValidatedState {
467
+ getState<T = unknown>(key: string): T | undefined;
468
+ getState(): Record<string, unknown>;
469
+ setState(
470
+ updates:
471
+ | Record<string, unknown>
472
+ | ((state: Record<string, unknown>) => Record<string, unknown>)
473
+ ): void;
474
+ /** Subscribe to changes; returns an unsubscribe function */
475
+ subscribe(
476
+ listener: (state: Record<string, unknown>, oldState: Record<string, unknown>) => void
477
+ ): () => void;
478
+ /** Current errors, keyed by path */
479
+ getErrors(): Record<string, ValidationError[]>;
388
480
  isValid(): boolean;
389
- /** Get validation errors */
390
- getErrors(): { [K in keyof T]?: string[] };
481
+ /** Validate one key */
482
+ validateField(key: string, value: unknown): ValidationResult;
483
+ /** Validate the whole state */
484
+ validate(): ValidationResult;
391
485
  }
392
486
 
393
- /**
394
- * Create a validated state
395
- */
396
- export function createValidatedState<T extends Record<string, unknown> = Record<string, unknown>>(
397
- options: ValidatedStateOptions<T>
398
- ): ValidatedState<T>;
487
+ /** Create a {@link ValidatedState}. */
488
+ export function createValidatedState(
489
+ initialState?: Record<string, unknown>,
490
+ options?: ValidatedStateOptions
491
+ ): ValidatedState;
399
492
 
400
- /**
401
- * Built-in validators
402
- */
493
+ /** Ready-made validators, some of them parameterized factories. */
403
494
  export const validators: {
404
- /** Required value */
405
- required(message?: string): ValidationRule;
406
- /** Minimum length */
407
- minLength(length: number, message?: string): ValidationRule;
408
- /** Maximum length */
409
- maxLength(length: number, message?: string): ValidationRule;
410
- /** Minimum value */
411
- min(value: number, message?: string): ValidationRule;
412
- /** Maximum value */
413
- max(value: number, message?: string): ValidationRule;
414
- /** Pattern matching */
415
- pattern(regex: RegExp, message?: string): ValidationRule;
416
- /** Email format */
417
- email(message?: string): ValidationRule;
418
- /** URL format */
419
- url(message?: string): ValidationRule;
420
- /** Custom validator */
421
- custom(fn: (value: unknown) => boolean | string): ValidationRule;
495
+ email: Validator<unknown>;
496
+ url: Validator<unknown>;
497
+ required: Validator<unknown>;
498
+ range(min: number, max: number): Validator<unknown>;
499
+ length(min: number, max: number): Validator<unknown>;
500
+ pattern(pattern: RegExp | string): Validator<unknown>;
422
501
  };
502
+
503
+ // ============================================================================
504
+ // State Patterns
505
+ // ============================================================================
506
+
507
+ /**
508
+ * A field check for {@link FormState}. Returns the error message, or a falsy
509
+ * value when the field passes — the opposite of {@link Validator}, which
510
+ * returns `true` for valid.
511
+ */
512
+ export type FieldCheck<V = unknown, T = Record<string, unknown>> = (
513
+ value: V,
514
+ allValues: T
515
+ ) => string | false | null | undefined;
516
+
517
+ /** Form values, errors and submission flags in one container. */
518
+ export class FormState<T extends Record<string, unknown> = Record<string, unknown>> {
519
+ constructor(initialValues?: T, options?: ReactiveStateOptions);
520
+
521
+ setValue<K extends keyof T & string>(field: K, value: T[K]): void;
522
+ getValue<K extends keyof T & string>(field: K): T[K];
523
+ setError(field: keyof T & string, error: string | null): void;
524
+ /** Register a check run whenever the field changes */
525
+ addValidator<K extends keyof T & string>(field: K, validator: FieldCheck<T[K], T>): void;
526
+ /** Run every validator; `true` when all pass */
527
+ validateAll(): boolean;
528
+ /**
529
+ * Validate, then await `onSubmit`. Resolves `false` when validation fails or
530
+ * `onSubmit` throws, recording the error under `_form`.
531
+ */
532
+ submit(onSubmit: (values: T) => unknown): Promise<boolean>;
533
+ /** Restore the initial values and drop errors */
534
+ reset(): void;
535
+
536
+ watchValues(callback: Watcher<T>): () => void;
537
+ watchErrors(callback: Watcher<Record<string, string>>): () => void;
538
+ watchSubmitting(callback: Watcher<boolean>): () => void;
539
+ }
540
+
541
+ export interface ListStateOptions extends ReactiveStateOptions {
542
+ /** Items per page; defaults to `10` */
543
+ pageSize?: number;
544
+ }
545
+
546
+ /** A list with filtering, sorting and pagination. */
547
+ export class ListState<T = unknown> {
548
+ constructor(initialItems?: T[], options?: ListStateOptions);
549
+
550
+ addItem(item: T): void;
551
+ /** Remove by index, or by the first item matching a predicate */
552
+ removeItem(indexOrPredicate: number | ((item: T) => boolean)): void;
553
+ updateItem(indexOrPredicate: number | ((item: T) => boolean), updates: Partial<T>): void;
554
+ filter(filters: Record<string, unknown>): void;
555
+ sort(sortBy: string, order?: 'asc' | 'desc'): void;
556
+ setPage(page: number): void;
557
+
558
+ /**
559
+ * Replace the items with whatever `loader` resolves to, toggling `loading`.
560
+ * Resolves `[]` and records `error` when the loader throws.
561
+ */
562
+ load(loader: (filters: Record<string, unknown>) => T[] | Promise<T[]>): Promise<T[]>;
563
+
564
+ /** Items matching the current filters */
565
+ get filteredItems(): T[];
566
+ /** {@link ListState.filteredItems} in the current sort order */
567
+ get sortedItems(): T[];
568
+ /** The current page of {@link ListState.sortedItems} */
569
+ get paginatedItems(): T[];
570
+ /** Page count for the current filters and page size */
571
+ get totalPages(): number;
572
+
573
+ watchItems(callback: Watcher<T[]>): () => void;
574
+ watchLoading(callback: Watcher<boolean>): () => void;
575
+ }
576
+
577
+ /** A modal whose `open()` resolves with whatever `close()` is passed. */
578
+ export class ModalState<D = unknown, R = unknown> {
579
+ constructor(initialState?: Record<string, unknown>);
580
+
581
+ /** Open with data; resolves once closed */
582
+ open(data?: D): Promise<R | null>;
583
+ /** Close, resolving the pending `open()` */
584
+ close(result?: R | null): void;
585
+ setLoading(loading: boolean): void;
586
+ setError(error: unknown): void;
587
+
588
+ watchOpen(callback: Watcher<boolean>): () => void;
589
+ watchData(callback: Watcher<D | null>): () => void;
590
+ }
591
+
592
+ /** Client-side route, params and history. */
593
+ export class RouterState {
594
+ constructor(initialRoute?: string, options?: ReactiveStateOptions);
595
+
596
+ addRoute(path: string, handler: unknown): void;
597
+ navigate(path: string, params?: Record<string, unknown>, query?: Record<string, unknown>): void;
598
+ back(): void;
599
+ forward(): void;
600
+
601
+ watchRoute(callback: Watcher<string>): () => void;
602
+ watchParams(callback: Watcher<Record<string, unknown>>): () => void;
603
+ }
604
+
605
+ /** Create a {@link FormState}. */
606
+ export function createFormState<T extends Record<string, unknown> = Record<string, unknown>>(
607
+ initialValues?: T,
608
+ options?: ReactiveStateOptions
609
+ ): FormState<T>;
610
+
611
+ /** Create a {@link ListState}. */
612
+ export function createListState<T = unknown>(
613
+ initialItems?: T[],
614
+ options?: ListStateOptions
615
+ ): ListState<T>;
616
+
617
+ /** Create a {@link ModalState}. */
618
+ export function createModalState<D = unknown, R = unknown>(
619
+ initialState?: Record<string, unknown>
620
+ ): ModalState<D, R>;
621
+
622
+ /** Create a {@link RouterState}. */
623
+ export function createRouterState(
624
+ initialRoute?: string,
625
+ options?: ReactiveStateOptions
626
+ ): RouterState;