@push.rocks/smartstate 2.0.31 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @push.rocks/smartstate
2
2
 
3
- A powerful TypeScript library for elegant state management using RxJS and reactive programming patterns 🚀
3
+ A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support 🚀
4
4
 
5
5
  ## Issue Reporting and Security
6
6
 
@@ -8,306 +8,311 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
8
8
 
9
9
  ## Install
10
10
 
11
- To install `@push.rocks/smartstate`, you can use pnpm, npm, or yarn:
12
-
13
11
  ```bash
14
- # Using pnpm (recommended)
15
12
  pnpm install @push.rocks/smartstate --save
13
+ ```
16
14
 
17
- # Using npm
18
- npm install @push.rocks/smartstate --save
15
+ Or with npm:
19
16
 
20
- # Using yarn
21
- yarn add @push.rocks/smartstate
17
+ ```bash
18
+ npm install @push.rocks/smartstate --save
22
19
  ```
23
20
 
24
21
  ## Usage
25
22
 
26
- The `@push.rocks/smartstate` library provides an elegant way to handle state within your JavaScript or TypeScript projects, leveraging the power of Reactive Extensions (RxJS) and a structured state management strategy.
27
-
28
- ### Getting Started
29
-
30
- Import the necessary components from the library:
23
+ ### Quick Start
31
24
 
32
25
  ```typescript
33
- import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
34
- ```
35
-
36
- ### Creating a SmartState Instance
26
+ import { Smartstate } from '@push.rocks/smartstate';
37
27
 
38
- `Smartstate` acts as the container for your state parts. Think of it as the root of your state management structure:
28
+ // 1. Define your state part names
29
+ type AppParts = 'user' | 'settings';
39
30
 
40
- ```typescript
41
- const myAppSmartState = new Smartstate<YourStatePartNamesEnum>();
42
- ```
31
+ // 2. Create the root instance
32
+ const state = new Smartstate<AppParts>();
43
33
 
44
- ### Understanding Init Modes
34
+ // 3. Create state parts with initial values
35
+ const userState = await state.getStatePart<{ name: string; loggedIn: boolean }>('user', {
36
+ name: '',
37
+ loggedIn: false,
38
+ });
45
39
 
46
- When creating state parts, you can specify different initialization modes:
40
+ // 4. Subscribe to changes
41
+ userState.select((s) => s.name).subscribe((name) => {
42
+ console.log('Name changed:', name);
43
+ });
47
44
 
48
- | Mode | Description |
49
- |------|-------------|
50
- | `'soft'` | Default. Returns existing state part if it exists, creates new if not |
51
- | `'mandatory'` | Requires state part to not exist, throws error if it does |
52
- | `'force'` | Always creates new state part, overwriting any existing one |
53
- | `'persistent'` | Like 'soft' but with WebStore persistence using IndexedDB |
45
+ // 5. Update state
46
+ await userState.setState({ name: 'Alice', loggedIn: true });
47
+ ```
54
48
 
55
- ### Defining State Parts
49
+ ### State Parts & Init Modes
56
50
 
57
- State parts represent separable sections of your state, making it easier to manage and modularize. Define state part names using either enums or string literal types:
51
+ State parts are isolated, typed units of state. Create them with `getStatePart()`:
58
52
 
59
53
  ```typescript
60
- // Option 1: Using enums
61
- enum AppStateParts {
62
- UserState = 'UserState',
63
- SettingsState = 'SettingsState'
64
- }
65
-
66
- // Option 2: Using string literal types (simpler approach)
67
- type AppStateParts = 'UserState' | 'SettingsState';
54
+ const part = await state.getStatePart<IMyState>(name, initialState, initMode);
68
55
  ```
69
56
 
70
- Create a state part within your `Smartstate` instance:
57
+ | Init Mode | Behavior |
58
+ |-----------|----------|
59
+ | `'soft'` (default) | Returns existing if found, creates new otherwise |
60
+ | `'mandatory'` | Throws if state part already exists |
61
+ | `'force'` | Always creates new, overwrites existing |
62
+ | `'persistent'` | Like `'soft'` but persists to IndexedDB via WebStore |
71
63
 
72
- ```typescript
73
- interface IUserState {
74
- isLoggedIn: boolean;
75
- username?: string;
76
- }
64
+ #### Persistent State
77
65
 
78
- const userStatePart = await myAppSmartState.getStatePart<IUserState>(
79
- AppStateParts.UserState,
80
- { isLoggedIn: false }, // Initial state
81
- 'soft' // Init mode (optional, defaults to 'soft')
82
- );
66
+ ```typescript
67
+ const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent');
68
+ // Automatically saved to IndexedDB. On next app load, persisted values override defaults.
83
69
  ```
84
70
 
85
- ### Subscribing to State Changes
71
+ ### Selecting State
86
72
 
87
- Subscribe to changes in a state part to perform actions accordingly:
73
+ `select()` returns an RxJS Observable that emits the current value immediately and on every change:
88
74
 
89
75
  ```typescript
90
- // The select() method automatically filters out undefined states
91
- userStatePart.select().subscribe((currentState) => {
92
- console.log(`User Logged In: ${currentState.isLoggedIn}`);
93
- });
94
- ```
95
-
96
- Select a specific part of your state with a selector function:
76
+ // Full state
77
+ userState.select().subscribe((state) => console.log(state));
97
78
 
98
- ```typescript
99
- userStatePart.select(state => state.username).subscribe((username) => {
100
- if (username) {
101
- console.log(`Current user: ${username}`);
102
- }
103
- });
79
+ // Derived value via selector
80
+ userState.select((s) => s.name).subscribe((name) => console.log(name));
104
81
  ```
105
82
 
106
- ### Modifying State with Actions
83
+ Selectors are **memoized** — calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers.
84
+
85
+ #### AbortSignal Support
107
86
 
108
- Create actions to modify the state in a controlled manner:
87
+ Clean up subscriptions without manual `unsubscribe()`:
109
88
 
110
89
  ```typescript
111
- interface ILoginPayload {
112
- username: string;
113
- }
90
+ const controller = new AbortController();
114
91
 
115
- const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => {
116
- return { ...statePart.getState(), isLoggedIn: true, username: payload.username };
92
+ userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
93
+ console.log(name); // stops receiving when aborted
117
94
  });
118
95
 
119
- // Dispatch the action to update the state
120
- const newState = await loginUserAction.trigger({ username: 'johnDoe' });
96
+ // Later: clean up
97
+ controller.abort();
121
98
  ```
122
99
 
123
- ### Dispatching Actions
100
+ ### Actions
124
101
 
125
- There are two ways to dispatch actions:
102
+ Actions provide controlled, named state mutations:
126
103
 
127
104
  ```typescript
128
- // Method 1: Using trigger on the action (returns promise)
129
- const newState = await loginUserAction.trigger({ username: 'johnDoe' });
105
+ const login = userState.createAction<{ name: string }>(async (statePart, payload) => {
106
+ return { ...statePart.getState(), name: payload.name, loggedIn: true };
107
+ });
130
108
 
131
- // Method 2: Using dispatchAction on the state part (returns promise)
132
- const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' });
109
+ // Two equivalent ways to dispatch:
110
+ await login.trigger({ name: 'Alice' });
111
+ await userState.dispatchAction(login, { name: 'Alice' });
133
112
  ```
134
113
 
135
- Both methods return a Promise with the new state payload.
114
+ ### Middleware
136
115
 
137
- ### Additional State Methods
138
-
139
- `StatePart` provides several useful methods for state management:
116
+ Intercept every `setState()` call to transform, validate, or reject state changes:
140
117
 
141
118
  ```typescript
142
- // Get current state (may be undefined initially)
143
- const currentState = userStatePart.getState();
144
- if (currentState) {
145
- console.log('Current user:', currentState.username);
146
- }
147
-
148
- // Wait for state to be present
149
- await userStatePart.waitUntilPresent();
150
-
151
- // Wait for a specific property to be present
152
- await userStatePart.waitUntilPresent(state => state.username);
119
+ // Logging middleware
120
+ userState.addMiddleware((newState, oldState) => {
121
+ console.log('State changing from', oldState, 'to', newState);
122
+ return newState;
123
+ });
153
124
 
154
- // Wait with a timeout (throws error if condition not met within timeout)
155
- try {
156
- await userStatePart.waitUntilPresent(state => state.username, 5000); // 5 second timeout
157
- } catch (error) {
158
- console.error('Timed out waiting for username');
159
- }
125
+ // Validation middleware throw to reject the change
126
+ userState.addMiddleware((newState) => {
127
+ if (!newState.name) throw new Error('Name is required');
128
+ return newState;
129
+ });
160
130
 
161
- // Setup initial state with async operations
162
- await userStatePart.stateSetup(async (statePart) => {
163
- const userData = await fetchUserData();
164
- return { ...statePart.getState(), ...userData };
131
+ // Transform middleware
132
+ userState.addMiddleware((newState) => {
133
+ return { ...newState, name: newState.name.trim() };
165
134
  });
166
135
 
167
- // Defer notification to end of call stack (debounced)
168
- userStatePart.notifyChangeCumulative();
136
+ // Removal addMiddleware returns a dispose function
137
+ const remove = userState.addMiddleware(myMiddleware);
138
+ remove(); // middleware no longer runs
169
139
  ```
170
140
 
171
- ### Persistent State with WebStore
141
+ Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic).
172
142
 
173
- `Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions:
143
+ ### Computed / Derived State
174
144
 
175
- ```typescript
176
- const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
177
- AppStateParts.SettingsState,
178
- { theme: 'light' }, // Initial/default state
179
- 'persistent' // Mode
180
- );
181
- ```
145
+ Derive reactive values from one or more state parts:
182
146
 
183
- Persistent state automatically:
184
- - Saves state changes to IndexedDB
185
- - Restores state on application restart
186
- - Merges persisted values with defaults (persisted values take precedence)
187
- - Ensures atomic writes (persistence happens before memory update)
147
+ ```typescript
148
+ import { computed } from '@push.rocks/smartstate';
188
149
 
189
- ### State Validation
150
+ const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' });
151
+ const settingsState = await state.getStatePart('settings', { locale: 'en' });
190
152
 
191
- `Smartstate` includes built-in state validation to ensure data integrity:
153
+ // Standalone function
154
+ const greeting$ = computed(
155
+ [userState, settingsState],
156
+ (user, settings) => `Hello, ${user.firstName} (${settings.locale})`,
157
+ );
192
158
 
193
- ```typescript
194
- // Basic validation (built-in) ensures state is not null or undefined
195
- await userStatePart.setState(null); // Throws error: Invalid state structure
159
+ greeting$.subscribe((msg) => console.log(msg));
160
+ // => "Hello, Jane (en)"
196
161
 
197
- // Custom validation by extending StatePart
198
- class ValidatedStatePart<T> extends StatePart<string, T> {
199
- protected validateState(stateArg: any): stateArg is T {
200
- return super.validateState(stateArg) && /* your validation */;
201
- }
202
- }
162
+ // Also available as a method on Smartstate:
163
+ const greeting2$ = state.computed([userState, settingsState], (user, settings) => /* ... */);
203
164
  ```
204
165
 
205
- ### Performance Optimization
206
-
207
- `Smartstate` includes advanced performance optimizations:
166
+ Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them.
208
167
 
209
- - **🔒 Async State Hash Detection**: Uses SHA256 hashing to detect actual state changes, preventing unnecessary notifications when state values haven't truly changed
210
- - **🚫 Duplicate Prevention**: Identical state updates are automatically filtered out
211
- - **📦 Cumulative Notifications**: Batch multiple state changes into a single notification using `notifyChangeCumulative()` with automatic debouncing
212
- - **🎯 Selective Subscriptions**: Use selectors to subscribe only to specific state properties
213
- - **✨ Undefined State Filtering**: The `select()` method automatically filters out undefined states
214
- - **⚡ Concurrent Access Safety**: Prevents race conditions when multiple calls request the same state part simultaneously
168
+ ### Batch Updates
215
169
 
216
- ### RxJS Integration
217
-
218
- `Smartstate` leverages RxJS for reactive state management:
170
+ Update multiple state parts without intermediate notifications:
219
171
 
220
172
  ```typescript
221
- // State is exposed as an RxJS Subject
222
- const stateObservable = userStatePart.select();
223
-
224
- // Automatically starts with current state value
225
- stateObservable.subscribe((state) => {
226
- console.log('Current state:', state);
173
+ const partA = await state.getStatePart('a', { value: 1 });
174
+ const partB = await state.getStatePart('b', { value: 2 });
175
+
176
+ // Subscribers see no updates during the batch — only after it completes
177
+ await state.batch(async () => {
178
+ await partA.setState({ value: 10 });
179
+ await partB.setState({ value: 20 });
180
+ // Notifications are deferred here
227
181
  });
182
+ // Both subscribers now fire with their new values
228
183
 
229
- // Use selectors for specific properties
230
- userStatePart.select(state => state.username)
231
- .pipe(
232
- distinctUntilChanged(),
233
- filter(username => username !== undefined)
234
- )
235
- .subscribe(username => {
236
- console.log('Username changed:', username);
184
+ // Nested batches are supported — flush happens at the outermost level
185
+ await state.batch(async () => {
186
+ await partA.setState({ value: 100 });
187
+ await state.batch(async () => {
188
+ await partB.setState({ value: 200 });
237
189
  });
190
+ // Still deferred
191
+ });
192
+ // Now both fire
238
193
  ```
239
194
 
240
- ### Complete Example
195
+ ### Waiting for State
241
196
 
242
- Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`:
197
+ Wait for a specific state condition to be met:
243
198
 
244
199
  ```typescript
245
- import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
200
+ // Wait for any truthy state
201
+ const currentState = await userState.waitUntilPresent();
246
202
 
247
- // Define your state structure
248
- type AppStateParts = 'user' | 'settings' | 'cart';
203
+ // Wait for a specific condition
204
+ const name = await userState.waitUntilPresent((s) => s.name || undefined);
249
205
 
250
- interface IUserState {
251
- isLoggedIn: boolean;
252
- username?: string;
253
- email?: string;
254
- }
206
+ // With timeout (backward compatible)
207
+ const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
255
208
 
256
- interface ICartState {
257
- items: Array<{ id: string; quantity: number }>;
258
- total: number;
209
+ // With AbortSignal
210
+ const controller = new AbortController();
211
+ try {
212
+ const name = await userState.waitUntilPresent(
213
+ (s) => s.name || undefined,
214
+ { timeoutMs: 5000, signal: controller.signal },
215
+ );
216
+ } catch (e) {
217
+ // 'Aborted' or timeout error
259
218
  }
219
+ ```
220
+
221
+ ### Context Protocol Bridge (Web Components)
260
222
 
261
- // Create the smartstate instance
262
- const appState = new Smartstate<AppStateParts>();
223
+ Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md):
224
+
225
+ ```typescript
226
+ import { attachContextProvider } from '@push.rocks/smartstate';
263
227
 
264
- // Initialize state parts
265
- const userState = await appState.getStatePart<IUserState>('user', {
266
- isLoggedIn: false
228
+ // Define a context key
229
+ const themeContext = Symbol('theme');
230
+
231
+ // Attach a provider to a DOM element
232
+ const cleanup = attachContextProvider(myElement, {
233
+ context: themeContext,
234
+ statePart: settingsState,
235
+ selectorFn: (s) => s.theme, // optional: provide derived value
267
236
  });
268
237
 
269
- const cartState = await appState.getStatePart<ICartState>('cart', {
270
- items: [],
271
- total: 0
272
- }, 'persistent'); // Persists across sessions
273
-
274
- // Create actions
275
- const loginAction = userState.createAction<{ username: string; email: string }>(
276
- async (statePart, payload) => {
277
- // Simulate API call
278
- await new Promise(resolve => setTimeout(resolve, 1000));
279
-
280
- return {
281
- isLoggedIn: true,
282
- username: payload.username,
283
- email: payload.email
284
- };
285
- }
238
+ // Any descendant can request this context:
239
+ myElement.dispatchEvent(
240
+ new CustomEvent('context-request', {
241
+ bubbles: true,
242
+ composed: true,
243
+ detail: {
244
+ context: themeContext,
245
+ callback: (theme) => console.log('Theme:', theme),
246
+ subscribe: true, // receive updates on state changes
247
+ },
248
+ }),
286
249
  );
287
250
 
288
- // Subscribe to changes
289
- userState.select(state => state.isLoggedIn).subscribe(isLoggedIn => {
290
- console.log('Login status changed:', isLoggedIn);
291
- });
251
+ // Cleanup when done
252
+ cleanup();
253
+ ```
292
254
 
293
- // Dispatch actions
294
- await loginAction.trigger({ username: 'john', email: 'john@example.com' });
255
+ This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol.
256
+
257
+ ### State Validation
258
+
259
+ Built-in null/undefined validation. Extend for custom rules:
260
+
261
+ ```typescript
262
+ class ValidatedPart<T> extends StatePart<string, T> {
263
+ protected validateState(stateArg: any): stateArg is T {
264
+ return super.validateState(stateArg) && typeof stateArg.name === 'string';
265
+ }
266
+ }
295
267
  ```
296
268
 
297
- ## Key Features
298
-
299
- | Feature | Description |
300
- |---------|-------------|
301
- | 🎯 **Type-safe** | Full TypeScript support with intelligent type inference |
302
- | **Performance optimized** | Async state hash detection prevents unnecessary re-renders |
303
- | 💾 **Persistent state** | Built-in IndexedDB support for state persistence |
304
- | 🔄 **Reactive** | Powered by RxJS for elegant async handling |
305
- | 🧩 **Modular** | Organize state into logical, reusable parts |
306
- | **Validated** | Built-in state validation with extensible validation logic |
307
- | 🎭 **Flexible init modes** | Choose how state parts are initialized |
308
- | 📦 **Zero config** | Works out of the box with sensible defaults |
309
- | 🛡️ **Race condition safe** | Concurrent state part creation is handled safely |
310
- | ⏱️ **Timeout support** | `waitUntilPresent` supports optional timeouts |
269
+ ### Performance Features
270
+
271
+ - **SHA256 Change Detection** — identical state values don't trigger notifications, even with different object references
272
+ - **Selector Memoization** — `select(fn)` caches observables by function reference, sharing one upstream subscription across all subscribers
273
+ - **Cumulative Notifications** `notifyChangeCumulative()` debounces rapid changes into a single notification
274
+ - **Concurrent Safety** simultaneous `getStatePart()` calls for the same name return the same promise, preventing duplicate creation
275
+ - **Atomic Persistence** WebStore writes complete before in-memory state updates, ensuring consistency
276
+ - **Batch Deferred Notifications** `batch()` suppresses all notifications until the batch completes
277
+
278
+ ## API Reference
279
+
280
+ ### `Smartstate<T>`
281
+
282
+ | Method | Description |
283
+ |--------|-------------|
284
+ | `getStatePart(name, initial?, initMode?)` | Get or create a state part |
285
+ | `batch(fn)` | Batch updates, defer notifications |
286
+ | `computed(sources, fn)` | Create computed observable |
287
+ | `isBatching` | Whether a batch is active |
288
+
289
+ ### `StatePart<TName, TPayload>`
290
+
291
+ | Method | Description |
292
+ |--------|-------------|
293
+ | `getState()` | Get current state (or undefined) |
294
+ | `setState(newState)` | Set state (runs middleware, validates, persists, notifies) |
295
+ | `select(selectorFn?, options?)` | Subscribe to state changes |
296
+ | `createAction(actionDef)` | Create a named action |
297
+ | `dispatchAction(action, payload)` | Dispatch an action |
298
+ | `addMiddleware(fn)` | Add middleware, returns removal function |
299
+ | `waitUntilPresent(selectorFn?, options?)` | Wait for state condition |
300
+ | `notifyChange()` | Manually trigger notification |
301
+ | `notifyChangeCumulative()` | Debounced notification |
302
+ | `stateSetup(fn)` | Async state initialization |
303
+
304
+ ### `StateAction<TState, TPayload>`
305
+
306
+ | Method | Description |
307
+ |--------|-------------|
308
+ | `trigger(payload)` | Dispatch the action |
309
+
310
+ ### Standalone Functions
311
+
312
+ | Function | Description |
313
+ |----------|-------------|
314
+ | `computed(sources, fn)` | Create computed observable from state parts |
315
+ | `attachContextProvider(element, options)` | Bridge state to Context Protocol |
311
316
 
312
317
  ## License and Legal Information
313
318
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartstate',
6
- version: '2.0.31',
7
- description: 'A package for handling and managing state in applications.'
6
+ version: '2.1.0',
7
+ description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
8
8
  }
package/ts/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './smartstate.classes.smartstate.js';
2
2
  export * from './smartstate.classes.statepart.js';
3
3
  export * from './smartstate.classes.stateaction.js';
4
+ export * from './smartstate.classes.computed.js';
5
+ export * from './smartstate.contextprovider.js';
@@ -0,0 +1,16 @@
1
+ import * as plugins from './smartstate.plugins.js';
2
+ import { combineLatest, map } from 'rxjs';
3
+ import type { StatePart } from './smartstate.classes.statepart.js';
4
+
5
+ /**
6
+ * creates a computed observable derived from multiple state parts.
7
+ * the observable is lazy — it only subscribes to sources when subscribed to.
8
+ */
9
+ export function computed<TResult>(
10
+ sources: StatePart<any, any>[],
11
+ computeFn: (...states: any[]) => TResult,
12
+ ): plugins.smartrx.rxjs.Observable<TResult> {
13
+ return combineLatest(sources.map((sp) => sp.select())).pipe(
14
+ map((states) => computeFn(...states)),
15
+ ) as plugins.smartrx.rxjs.Observable<TResult>;
16
+ }