feat(stateprocess): add managed state processes with lifecycle controls, scheduled actions, and disposal safety
This commit is contained in:
226
readme.md
226
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartstate
|
||||
|
||||
A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
|
||||
A TypeScript-first reactive state management library with processes, middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -48,7 +48,7 @@ await userState.setState({ name: 'Alice', loggedIn: true });
|
||||
|
||||
### 🧩 State Parts & Init Modes
|
||||
|
||||
State parts are isolated, typed units of state. They are the building blocks of your application's state tree. Create them via `getStatePart()`:
|
||||
State parts are isolated, typed units of state — the building blocks of your application's state tree. Create them via `getStatePart()`:
|
||||
|
||||
```typescript
|
||||
const part = await state.getStatePart<IMyState>(name, initialState, initMode);
|
||||
@@ -58,10 +58,10 @@ const part = await state.getStatePart<IMyState>(name, initialState, initMode);
|
||||
|-----------|----------|
|
||||
| `'soft'` (default) | Returns existing if found, creates new otherwise |
|
||||
| `'mandatory'` | Throws if state part already exists — useful for ensuring single-initialization |
|
||||
| `'force'` | Always creates a new state part, overwriting any existing one |
|
||||
| `'force'` | Always creates a new state part, disposing and overwriting any existing one |
|
||||
| `'persistent'` | Like `'soft'` but automatically persists state to IndexedDB via WebStore |
|
||||
|
||||
You can use either enums or string literal types for state part names:
|
||||
You can use either string literal union types or enums for state part names:
|
||||
|
||||
```typescript
|
||||
// String literal types (simpler)
|
||||
@@ -82,12 +82,12 @@ const settings = await state.getStatePart('settings', { theme: 'dark', fontSize:
|
||||
|
||||
// ✅ Automatically saved to IndexedDB on every setState()
|
||||
// ✅ On next app load, persisted values override defaults
|
||||
// ✅ Persistence writes complete before in-memory updates (atomic)
|
||||
// ✅ Persistence writes complete before in-memory updates
|
||||
```
|
||||
|
||||
### 🔭 Selecting State
|
||||
|
||||
`select()` returns an RxJS Observable that emits the current value immediately and on every subsequent change:
|
||||
`select()` returns an RxJS Observable that emits the current value immediately (via `BehaviorSubject`) and on every subsequent change:
|
||||
|
||||
```typescript
|
||||
// Full state
|
||||
@@ -99,6 +99,8 @@ userState.select((s) => s.name).subscribe((name) => console.log(name));
|
||||
|
||||
Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers via `shareReplay`. This means you can call `select(mySelector)` in multiple places without creating duplicate subscriptions.
|
||||
|
||||
**Change detection** is built in: `select()` uses `distinctUntilChanged` with deep JSON comparison, so subscribers only fire when the selected value actually changes. Selecting `s => s.name` won't re-emit when only `s.count` changes.
|
||||
|
||||
#### ✂️ AbortSignal Support
|
||||
|
||||
Clean up subscriptions without manual `.unsubscribe()` — the modern way:
|
||||
@@ -125,7 +127,6 @@ interface ILoginPayload {
|
||||
}
|
||||
|
||||
const loginAction = userState.createAction<ILoginPayload>(async (statePart, payload) => {
|
||||
// You have access to the current state via statePart.getState()
|
||||
const current = statePart.getState();
|
||||
return { ...current, name: payload.username, loggedIn: true };
|
||||
});
|
||||
@@ -136,7 +137,157 @@ await loginAction.trigger({ username: 'Alice', email: 'alice@example.com' });
|
||||
await userState.dispatchAction(loginAction, { username: 'Alice', email: 'alice@example.com' });
|
||||
```
|
||||
|
||||
Both `trigger()` and `dispatchAction()` return a Promise with the new state.
|
||||
Both `trigger()` and `dispatchAction()` return a Promise with the new state. All dispatches are serialized through a mutation queue, so concurrent dispatches never cause lost updates.
|
||||
|
||||
#### 🔗 Nested Actions (Action Context)
|
||||
|
||||
When you need to dispatch sub-actions from within an action, use the `context` parameter. This is critical because calling `dispatchAction()` directly from inside an action would deadlock (it tries to acquire the mutation queue that's already held). The context's `dispatch()` bypasses the queue and executes inline:
|
||||
|
||||
```typescript
|
||||
const incrementAction = userState.createAction<number>(async (statePart, amount) => {
|
||||
const current = statePart.getState();
|
||||
return { ...current, count: current.count + amount };
|
||||
});
|
||||
|
||||
const doubleIncrementAction = userState.createAction<number>(async (statePart, amount, context) => {
|
||||
// ✅ Safe: uses context.dispatch() which bypasses the mutation queue
|
||||
await context.dispatch(incrementAction, amount);
|
||||
const current = statePart.getState();
|
||||
return { ...current, count: current.count + amount };
|
||||
});
|
||||
|
||||
// ❌ DON'T do this inside an action — it will deadlock:
|
||||
// await statePart.dispatchAction(someAction, payload);
|
||||
```
|
||||
|
||||
A built-in depth limit (10 levels) prevents infinite circular dispatch chains, throwing a clear error if exceeded.
|
||||
|
||||
### 🔄 Processes (Polling, Streams & Scheduled Tasks)
|
||||
|
||||
Processes are managed, pausable observable-to-state bridges — the "side effects" layer. They tie an ongoing data source (polling, WebSockets, event streams) to state updates with full lifecycle control and optional auto-pause.
|
||||
|
||||
#### Basic Process: Polling an API
|
||||
|
||||
```typescript
|
||||
import { interval, switchMap, from } from 'rxjs';
|
||||
|
||||
const metricsPoller = dashboard.createProcess<{ cpu: number; memory: number }>({
|
||||
// Producer: an Observable factory — called on start and each resume
|
||||
producer: () => interval(5000).pipe(
|
||||
switchMap(() => from(fetch('/api/metrics').then(r => r.json()))),
|
||||
),
|
||||
// Reducer: folds each produced value into state (runs through middleware & validation)
|
||||
reducer: (currentState, metrics) => ({
|
||||
...currentState,
|
||||
metrics,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
autoPause: 'visibility', // ⏸️ Stop polling when the tab is hidden
|
||||
autoStart: true, // ▶️ Start immediately
|
||||
});
|
||||
|
||||
// Full lifecycle control
|
||||
metricsPoller.pause(); // Unsubscribes from producer
|
||||
metricsPoller.resume(); // Re-subscribes (fresh subscription)
|
||||
metricsPoller.dispose(); // Permanent cleanup
|
||||
|
||||
// Observe status reactively
|
||||
metricsPoller.status; // 'idle' | 'running' | 'paused' | 'disposed'
|
||||
metricsPoller.status$.subscribe(s => console.log('Process:', s));
|
||||
```
|
||||
|
||||
#### Scheduled Actions
|
||||
|
||||
Dispatch an existing action on a recurring interval — syntactic sugar over `createProcess`:
|
||||
|
||||
```typescript
|
||||
const refreshAction = dashboard.createAction<void>(async (sp) => {
|
||||
const data = await fetch('/api/dashboard').then(r => r.json());
|
||||
return { ...sp.getState()!, ...data, lastUpdated: Date.now() };
|
||||
});
|
||||
|
||||
// Dispatches refreshAction every 30 seconds, auto-pauses when tab is hidden
|
||||
const scheduled = dashboard.createScheduledAction({
|
||||
action: refreshAction,
|
||||
payload: undefined,
|
||||
intervalMs: 30000,
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
|
||||
// It's a full StateProcess — pause, resume, dispose all work
|
||||
scheduled.dispose();
|
||||
```
|
||||
|
||||
#### Custom Auto-Pause Signals
|
||||
|
||||
Pass any `Observable<boolean>` as the auto-pause signal — `true` means active, `false` means pause:
|
||||
|
||||
```typescript
|
||||
import { fromEvent, map, startWith } from 'rxjs';
|
||||
|
||||
// Pause when offline, resume when online
|
||||
const onlineSignal = fromEvent(window, 'online').pipe(
|
||||
startWith(null),
|
||||
map(() => navigator.onLine),
|
||||
);
|
||||
|
||||
const syncProcess = userPart.createProcess<SyncPayload>({
|
||||
producer: () => interval(10000).pipe(
|
||||
switchMap(() => from(syncWithServer())),
|
||||
),
|
||||
reducer: (state, result) => ({ ...state, ...result }),
|
||||
autoPause: onlineSignal,
|
||||
});
|
||||
syncProcess.start();
|
||||
```
|
||||
|
||||
#### WebSocket / Live Streams
|
||||
|
||||
Pause disconnects; resume creates a fresh connection:
|
||||
|
||||
```typescript
|
||||
const liveProcess = tickerPart.createProcess<TradeEvent>({
|
||||
producer: () => new Observable<TradeEvent>(subscriber => {
|
||||
const ws = new WebSocket('wss://trades.example.com');
|
||||
ws.onmessage = (e) => subscriber.next(JSON.parse(e.data));
|
||||
ws.onerror = (e) => subscriber.error(e);
|
||||
ws.onclose = () => subscriber.complete();
|
||||
return () => ws.close(); // Teardown: close WebSocket on unsubscribe
|
||||
}),
|
||||
reducer: (state, trade) => ({
|
||||
...state,
|
||||
lastPrice: trade.price,
|
||||
trades: [...state.trades.slice(-99), trade],
|
||||
}),
|
||||
autoPause: 'visibility',
|
||||
});
|
||||
liveProcess.start();
|
||||
```
|
||||
|
||||
#### Error Recovery
|
||||
|
||||
If a producer errors, the process gracefully transitions to `'paused'` instead of dying. Call `resume()` to retry with a fresh subscription:
|
||||
|
||||
```typescript
|
||||
process.start();
|
||||
// Producer errors → status becomes 'paused'
|
||||
process.resume(); // Creates a fresh subscription — retry
|
||||
```
|
||||
|
||||
#### Process Cleanup Cascades
|
||||
|
||||
Disposing a `StatePart` or `Smartstate` instance automatically disposes all attached processes:
|
||||
|
||||
```typescript
|
||||
const p1 = part.createProcess({ ... });
|
||||
const p2 = part.createProcess({ ... });
|
||||
p1.start();
|
||||
p2.start();
|
||||
|
||||
part.dispose();
|
||||
console.log(p1.status); // 'disposed'
|
||||
console.log(p2.status); // 'disposed'
|
||||
```
|
||||
|
||||
### 🛡️ Middleware
|
||||
|
||||
@@ -171,7 +322,7 @@ const remove = userState.addMiddleware(myMiddleware);
|
||||
remove(); // middleware no longer runs
|
||||
```
|
||||
|
||||
Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**.
|
||||
Middleware runs **sequentially** in insertion order. If any middleware throws, the state remains unchanged — the operation is **atomic**. Process-driven state updates go through middleware too.
|
||||
|
||||
### 🧮 Computed / Derived State
|
||||
|
||||
@@ -199,7 +350,7 @@ const greeting2$ = state.computed(
|
||||
);
|
||||
```
|
||||
|
||||
Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect.
|
||||
Computed observables are **lazy** — they only subscribe to their sources when someone subscribes to them, and they automatically unsubscribe when all subscribers disconnect. They also use `distinctUntilChanged` to avoid redundant emissions when the derived value hasn't actually changed.
|
||||
|
||||
### 📦 Batch Updates
|
||||
|
||||
@@ -322,15 +473,31 @@ await userState.stateSetup(async (statePart) => {
|
||||
// Any dispatchAction() calls will automatically wait for stateSetup() to finish
|
||||
```
|
||||
|
||||
### 🧹 Disposal & Cleanup
|
||||
|
||||
Both `Smartstate` and individual `StatePart` instances support disposal for proper cleanup:
|
||||
|
||||
```typescript
|
||||
// Dispose a single state part — completes the BehaviorSubject, clears middleware, caches,
|
||||
// and disposes all attached processes
|
||||
userState.dispose();
|
||||
|
||||
// Dispose the entire Smartstate instance — disposes all state parts and clears internal maps
|
||||
state.dispose();
|
||||
```
|
||||
|
||||
After disposal, `setState()` and `dispatchAction()` will throw if called on a disposed `StatePart`. Calling `start()`, `pause()`, or `resume()` on a disposed `StateProcess` also throws.
|
||||
|
||||
### 🏎️ Performance
|
||||
|
||||
Smartstate is built with performance in mind:
|
||||
|
||||
- **🔒 SHA256 Change Detection** — Uses content hashing to detect actual changes. Identical state values don't trigger notifications, even with different object references.
|
||||
- **🎯 distinctUntilChanged on Selectors** — Sub-selectors only fire when the selected slice actually changes. `select(s => s.name)` won't emit when `s.count` changes.
|
||||
- **♻️ Selector Memoization** — `select(fn)` caches observables by function reference and shares them via `shareReplay({ refCount: true })`. Multiple subscribers share one upstream subscription.
|
||||
- **📦 Cumulative Notifications** — `notifyChangeCumulative()` debounces rapid changes into a single notification at the end of the call stack.
|
||||
- **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation or race conditions.
|
||||
- **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency even if the process crashes mid-write.
|
||||
- **🔐 Concurrent Safety** — Simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation. All `setState()` and `dispatchAction()` calls are serialized through a mutation queue. Process values are serialized through their own internal queue.
|
||||
- **💾 Atomic Persistence** — WebStore writes complete before in-memory state updates, ensuring consistency.
|
||||
- **⏸️ Batch Deferred Notifications** — `batch()` suppresses all subscriber notifications until every update in the batch completes.
|
||||
|
||||
## API Reference
|
||||
@@ -342,23 +509,26 @@ Smartstate is built with performance in mind:
|
||||
| `getStatePart(name, initial?, initMode?)` | Get or create a typed state part |
|
||||
| `batch(fn)` | Batch state updates, defer all notifications until complete |
|
||||
| `computed(sources, fn)` | Create a computed observable from multiple state parts |
|
||||
| `dispose()` | Dispose all state parts and clear internal state |
|
||||
| `isBatching` | `boolean` — whether a batch is currently active |
|
||||
| `statePartMap` | Registry of all created state parts |
|
||||
|
||||
### `StatePart<TName, TPayload>`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getState()` | Get current state (returns `TPayload \| undefined`) |
|
||||
| `getState()` | Get current state synchronously (`TPayload \| undefined`) |
|
||||
| `setState(newState)` | Set state — runs middleware → validates → persists → notifies |
|
||||
| `select(selectorFn?, options?)` | Returns an Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
|
||||
| `select(selectorFn?, options?)` | Observable of state or derived values. Options: `{ signal?: AbortSignal }` |
|
||||
| `createAction(actionDef)` | Create a reusable, typed state action |
|
||||
| `dispatchAction(action, payload)` | Dispatch an action and return the new state |
|
||||
| `addMiddleware(fn)` | Add a middleware interceptor. Returns a removal function |
|
||||
| `waitUntilPresent(selectorFn?, opts?)` | Wait for a state condition. Opts: `number` (timeout) or `{ timeoutMs?, signal? }` |
|
||||
| `createProcess(options)` | Create a managed, pausable process tied to this state part |
|
||||
| `createScheduledAction(options)` | Create a process that dispatches an action on a recurring interval |
|
||||
| `notifyChange()` | Manually trigger a change notification (with hash dedup) |
|
||||
| `notifyChangeCumulative()` | Debounced notification — fires at end of call stack |
|
||||
| `stateSetup(fn)` | Async state initialization with action serialization |
|
||||
| `dispose()` | Complete the BehaviorSubject, dispose processes, clear middleware and caches |
|
||||
|
||||
### `StateAction<TState, TPayload>`
|
||||
|
||||
@@ -366,6 +536,23 @@ Smartstate is built with performance in mind:
|
||||
|--------|-------------|
|
||||
| `trigger(payload)` | Dispatch the action on its associated state part |
|
||||
|
||||
### `StateProcess<TName, TPayload, TProducerValue>`
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `start()` | Start the process (subscribes to producer, sets up auto-pause) |
|
||||
| `pause()` | Pause the process (unsubscribes from producer) |
|
||||
| `resume()` | Resume a paused process (fresh subscription to producer) |
|
||||
| `dispose()` | Permanently stop the process and clean up |
|
||||
| `status` | Current status: `'idle' \| 'running' \| 'paused' \| 'disposed'` |
|
||||
| `status$` | Observable of status transitions |
|
||||
|
||||
### `IActionContext<TState>`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `dispatch(action, payload)` | Dispatch a sub-action inline (bypasses mutation queue). Available as the third argument to action definitions |
|
||||
|
||||
### Standalone Functions
|
||||
|
||||
| Function | Description |
|
||||
@@ -379,8 +566,13 @@ Smartstate is built with performance in mind:
|
||||
|------|-------------|
|
||||
| `TInitMode` | `'soft' \| 'mandatory' \| 'force' \| 'persistent'` |
|
||||
| `TMiddleware<TPayload>` | `(newState, oldState) => TPayload \| Promise<TPayload>` |
|
||||
| `IActionDef<TState, TPayload>` | Action definition function signature |
|
||||
| `IActionDef<TState, TPayload>` | Action definition function signature (receives statePart, payload, context?) |
|
||||
| `IActionContext<TState>` | Context for safe nested dispatch within actions |
|
||||
| `IContextProviderOptions<TPayload>` | Options for `attachContextProvider` |
|
||||
| `IProcessOptions<TPayload, TValue>` | Options for `createProcess` (producer, reducer, autoPause, autoStart) |
|
||||
| `IScheduledActionOptions<TPayload, TActionPayload>` | Options for `createScheduledAction` (action, payload, intervalMs, autoPause) |
|
||||
| `TProcessStatus` | `'idle' \| 'running' \| 'paused' \| 'disposed'` |
|
||||
| `TAutoPause` | `'visibility' \| Observable<boolean> \| false` |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -396,7 +588,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
Reference in New Issue
Block a user