@push.rocks/smartstate 2.0.30 → 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,310 +8,315 @@ 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.
23
+ ### Quick Start
27
24
 
28
- ### Getting Started
25
+ ```typescript
26
+ import { Smartstate } from '@push.rocks/smartstate';
29
27
 
30
- Import the necessary components from the library:
28
+ // 1. Define your state part names
29
+ type AppParts = 'user' | 'settings';
31
30
 
32
- ```typescript
33
- import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
34
- ```
31
+ // 2. Create the root instance
32
+ const state = new Smartstate<AppParts>();
35
33
 
36
- ### Creating a SmartState Instance
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
+ });
37
39
 
38
- `Smartstate` acts as the container for your state parts. Think of it as the root of your state management structure:
40
+ // 4. Subscribe to changes
41
+ userState.select((s) => s.name).subscribe((name) => {
42
+ console.log('Name changed:', name);
43
+ });
39
44
 
40
- ```typescript
41
- const myAppSmartState = new Smartstate<YourStatePartNamesEnum>();
45
+ // 5. Update state
46
+ await userState.setState({ name: 'Alice', loggedIn: true });
42
47
  ```
43
48
 
44
- ### Understanding Init Modes
49
+ ### State Parts & Init Modes
45
50
 
46
- When creating state parts, you can specify different initialization modes:
51
+ State parts are isolated, typed units of state. Create them with `getStatePart()`:
47
52
 
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 |
53
+ ```typescript
54
+ const part = await state.getStatePart<IMyState>(name, initialState, initMode);
55
+ ```
54
56
 
55
- ### Defining State Parts
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 |
56
63
 
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:
64
+ #### Persistent State
58
65
 
59
66
  ```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';
67
+ const settings = await state.getStatePart('settings', { theme: 'dark' }, 'persistent');
68
+ // Automatically saved to IndexedDB. On next app load, persisted values override defaults.
68
69
  ```
69
70
 
70
- Create a state part within your `Smartstate` instance:
71
+ ### Selecting State
72
+
73
+ `select()` returns an RxJS Observable that emits the current value immediately and on every change:
71
74
 
72
75
  ```typescript
73
- interface IUserState {
74
- isLoggedIn: boolean;
75
- username?: string;
76
- }
76
+ // Full state
77
+ userState.select().subscribe((state) => console.log(state));
77
78
 
78
- const userStatePart = await myAppSmartState.getStatePart<IUserState>(
79
- AppStateParts.UserState,
80
- { isLoggedIn: false }, // Initial state
81
- 'soft' // Init mode (optional, defaults to 'soft')
82
- );
79
+ // Derived value via selector
80
+ userState.select((s) => s.name).subscribe((name) => console.log(name));
83
81
  ```
84
82
 
85
- ### Subscribing to State Changes
83
+ Selectors are **memoized** calling `select(fn)` with the same function reference returns the same cached Observable, shared across all subscribers.
86
84
 
87
- Subscribe to changes in a state part to perform actions accordingly:
85
+ #### AbortSignal Support
86
+
87
+ Clean up subscriptions without manual `unsubscribe()`:
88
88
 
89
89
  ```typescript
90
- // The select() method automatically filters out undefined states
91
- userStatePart.select().subscribe((currentState) => {
92
- console.log(`User Logged In: ${currentState.isLoggedIn}`);
90
+ const controller = new AbortController();
91
+
92
+ userState.select((s) => s.name, { signal: controller.signal }).subscribe((name) => {
93
+ console.log(name); // stops receiving when aborted
93
94
  });
95
+
96
+ // Later: clean up
97
+ controller.abort();
94
98
  ```
95
99
 
96
- Select a specific part of your state with a selector function:
100
+ ### Actions
101
+
102
+ Actions provide controlled, named state mutations:
97
103
 
98
104
  ```typescript
99
- userStatePart.select(state => state.username).subscribe((username) => {
100
- if (username) {
101
- console.log(`Current user: ${username}`);
102
- }
105
+ const login = userState.createAction<{ name: string }>(async (statePart, payload) => {
106
+ return { ...statePart.getState(), name: payload.name, loggedIn: true };
103
107
  });
108
+
109
+ // Two equivalent ways to dispatch:
110
+ await login.trigger({ name: 'Alice' });
111
+ await userState.dispatchAction(login, { name: 'Alice' });
104
112
  ```
105
113
 
106
- ### Modifying State with Actions
114
+ ### Middleware
107
115
 
108
- Create actions to modify the state in a controlled manner:
116
+ Intercept every `setState()` call to transform, validate, or reject state changes:
109
117
 
110
118
  ```typescript
111
- interface ILoginPayload {
112
- username: string;
113
- }
119
+ // Logging middleware
120
+ userState.addMiddleware((newState, oldState) => {
121
+ console.log('State changing from', oldState, 'to', newState);
122
+ return newState;
123
+ });
124
+
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
+ });
114
130
 
115
- const loginUserAction = userStatePart.createAction<ILoginPayload>(async (statePart, payload) => {
116
- return { ...statePart.getState(), isLoggedIn: true, username: payload.username };
131
+ // Transform middleware
132
+ userState.addMiddleware((newState) => {
133
+ return { ...newState, name: newState.name.trim() };
117
134
  });
118
135
 
119
- // Dispatch the action to update the state
120
- const newState = await loginUserAction.trigger({ username: 'johnDoe' });
136
+ // Removal addMiddleware returns a dispose function
137
+ const remove = userState.addMiddleware(myMiddleware);
138
+ remove(); // middleware no longer runs
121
139
  ```
122
140
 
123
- ### Dispatching Actions
141
+ Middleware runs sequentially in insertion order. If any middleware throws, the state is unchanged (atomic).
142
+
143
+ ### Computed / Derived State
124
144
 
125
- There are two ways to dispatch actions:
145
+ Derive reactive values from one or more state parts:
126
146
 
127
147
  ```typescript
128
- // Method 1: Using trigger on the action (returns promise)
129
- const newState = await loginUserAction.trigger({ username: 'johnDoe' });
148
+ import { computed } from '@push.rocks/smartstate';
130
149
 
131
- // Method 2: Using dispatchAction on the state part (returns promise)
132
- const newState = await userStatePart.dispatchAction(loginUserAction, { username: 'johnDoe' });
133
- ```
150
+ const userState = await state.getStatePart('user', { firstName: 'Jane', lastName: 'Doe' });
151
+ const settingsState = await state.getStatePart('settings', { locale: 'en' });
134
152
 
135
- Both methods return a Promise with the new state payload.
153
+ // Standalone function
154
+ const greeting$ = computed(
155
+ [userState, settingsState],
156
+ (user, settings) => `Hello, ${user.firstName} (${settings.locale})`,
157
+ );
136
158
 
137
- ### Additional State Methods
159
+ greeting$.subscribe((msg) => console.log(msg));
160
+ // => "Hello, Jane (en)"
138
161
 
139
- `StatePart` provides several useful methods for state management:
162
+ // Also available as a method on Smartstate:
163
+ const greeting2$ = state.computed([userState, settingsState], (user, settings) => /* ... */);
164
+ ```
140
165
 
141
- ```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
- }
166
+ Computed observables are **lazy** — they only subscribe to sources when someone subscribes to them.
147
167
 
148
- // Wait for state to be present
149
- await userStatePart.waitUntilPresent();
168
+ ### Batch Updates
150
169
 
151
- // Wait for a specific property to be present
152
- await userStatePart.waitUntilPresent(state => state.username);
170
+ Update multiple state parts without intermediate notifications:
153
171
 
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
- }
160
-
161
- // Setup initial state with async operations
162
- await userStatePart.stateSetup(async (statePart) => {
163
- const userData = await fetchUserData();
164
- return { ...statePart.getState(), ...userData };
172
+ ```typescript
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
165
181
  });
182
+ // Both subscribers now fire with their new values
166
183
 
167
- // Defer notification to end of call stack (debounced)
168
- userStatePart.notifyChangeCumulative();
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 });
189
+ });
190
+ // Still deferred
191
+ });
192
+ // Now both fire
169
193
  ```
170
194
 
171
- ### Persistent State with WebStore
195
+ ### Waiting for State
172
196
 
173
- `Smartstate` supports persistent states using WebStore (IndexedDB-based storage), allowing you to maintain state across sessions:
197
+ Wait for a specific state condition to be met:
174
198
 
175
199
  ```typescript
176
- const settingsStatePart = await myAppSmartState.getStatePart<ISettingsState>(
177
- AppStateParts.SettingsState,
178
- { theme: 'light' }, // Initial/default state
179
- 'persistent' // Mode
180
- );
181
- ```
182
-
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)
188
-
189
- ### State Validation
200
+ // Wait for any truthy state
201
+ const currentState = await userState.waitUntilPresent();
190
202
 
191
- `Smartstate` includes built-in state validation to ensure data integrity:
203
+ // Wait for a specific condition
204
+ const name = await userState.waitUntilPresent((s) => s.name || undefined);
192
205
 
193
- ```typescript
194
- // Basic validation (built-in) ensures state is not null or undefined
195
- await userStatePart.setState(null); // Throws error: Invalid state structure
206
+ // With timeout (backward compatible)
207
+ const name = await userState.waitUntilPresent((s) => s.name || undefined, 5000);
196
208
 
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
- }
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
202
218
  }
203
219
  ```
204
220
 
205
- ### Performance Optimization
206
-
207
- `Smartstate` includes advanced performance optimizations:
221
+ ### Context Protocol Bridge (Web Components)
208
222
 
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
215
-
216
- ### RxJS Integration
217
-
218
- `Smartstate` leverages RxJS for reactive state management:
223
+ Expose state parts to web components via the [W3C Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md):
219
224
 
220
225
  ```typescript
221
- // State is exposed as an RxJS Subject
222
- const stateObservable = userStatePart.select();
226
+ import { attachContextProvider } from '@push.rocks/smartstate';
223
227
 
224
- // Automatically starts with current state value
225
- stateObservable.subscribe((state) => {
226
- console.log('Current state:', state);
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
227
236
  });
228
237
 
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);
237
- });
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
+ }),
249
+ );
250
+
251
+ // Cleanup when done
252
+ cleanup();
238
253
  ```
239
254
 
240
- ### Complete Example
255
+ This works with Lit's `@consume()` decorator, FAST, or any framework implementing the Context Protocol.
241
256
 
242
- Here's a comprehensive example showcasing the power of `@push.rocks/smartstate`:
257
+ ### State Validation
258
+
259
+ Built-in null/undefined validation. Extend for custom rules:
243
260
 
244
261
  ```typescript
245
- import { Smartstate, StatePart, StateAction } from '@push.rocks/smartstate';
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
+ }
267
+ ```
246
268
 
247
- // Define your state structure
248
- type AppStateParts = 'user' | 'settings' | 'cart';
269
+ ### Performance Features
249
270
 
250
- interface IUserState {
251
- isLoggedIn: boolean;
252
- username?: string;
253
- email?: string;
254
- }
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
255
277
 
256
- interface ICartState {
257
- items: Array<{ id: string; quantity: number }>;
258
- total: number;
259
- }
278
+ ## API Reference
260
279
 
261
- // Create the smartstate instance
262
- const appState = new Smartstate<AppStateParts>();
280
+ ### `Smartstate<T>`
263
281
 
264
- // Initialize state parts
265
- const userState = await appState.getStatePart<IUserState>('user', {
266
- isLoggedIn: false
267
- });
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 |
268
288
 
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
- }
286
- );
289
+ ### `StatePart<TName, TPayload>`
287
290
 
288
- // Subscribe to changes
289
- userState.select(state => state.isLoggedIn).subscribe(isLoggedIn => {
290
- console.log('Login status changed:', isLoggedIn);
291
- });
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 |
292
303
 
293
- // Dispatch actions
294
- await loginAction.trigger({ username: 'john', email: 'john@example.com' });
295
- ```
304
+ ### `StateAction<TState, TPayload>`
305
+
306
+ | Method | Description |
307
+ |--------|-------------|
308
+ | `trigger(payload)` | Dispatch the action |
309
+
310
+ ### Standalone Functions
296
311
 
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 |
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
 
314
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
319
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
315
320
 
316
321
  **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
317
322
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartstate',
6
- version: '2.0.30',
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
+ }