stunk 0.7.0 → 0.9.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,101 +1,62 @@
1
1
  # Stunk
2
2
 
3
- **Stunk** is a **framework-agnostic** state management library that helps you manage your application's state in a clean and simple way. It uses a technique called **Atomic State**, breaking down state into smaller **chunks** that are easy to update, subscribe to, and manage.
4
-
5
- ---
6
-
7
- ## Pronunciation and Meaning
8
-
9
3
  - **Pronunciation**: _Stunk_ (A playful blend of "state" and "chunk")
10
- - **Meaning**: "Stunk" represents the combination of state management with chunk-based atomic units. The term captures the essence of atomic state management while using "chunk" to refer to these discrete units of state.
11
4
 
12
- ## What is Stunk?
13
-
14
- Think of your application's state as a big jar of data. In traditional state management, you keep everything in one big jar, and every time you want to change something, you have to dig through the whole jar.
5
+ A lightweight, reactive state management library for TypeScript/JavaScript applications. Stunk combines atomic state management with powerful features like middleware, time travel, and async state handling.
15
6
 
16
7
  **Stunk** is like dividing your jar into many smaller containers, each holding a single piece of state. These smaller containers are called **chunks**. Each **chunk** can be updated and accessed easily, and any part of your app can subscribe to changes in a chunk so it gets updated automatically.
17
8
 
18
- ### Why Stunk
19
-
20
- - lightweight, framework-agnostic state management.
21
- - granular control over state updates and subscriptions.
22
- - modular and composable state architecture.
9
+ ## Features
23
10
 
24
- ---
11
+ - 🚀 **Lightweight and Fast**: No dependencies, minimal overhead
12
+ - 🔄 **Reactive**: Automatic updates when state changes
13
+ - 📦 **Batch Updates**: Group multiple state updates together
14
+ - 🎯 **Atomic State Management**: Break down state into manageable chunks
15
+ - 🎭 **State Selection**: Select and derive specific parts of state
16
+ - 🔄 **Async Support**: Handle async state with built-in loading and error states
17
+ - 🔌 **Middleware Support**: Extend functionality with custom middleware
18
+ - ⏱️ **Time Travel**: Undo/redo state changes
19
+ - 🔍 **Type-Safe**: Written in TypeScript with full type inference
25
20
 
26
21
  ## Installation
27
22
 
28
- You can install **Stunk** from NPM:
29
-
30
23
  ```bash
31
24
  npm install stunk
25
+ # or
26
+ yarn add stunk
32
27
  ```
33
28
 
34
- ## 1 Features
29
+ ## Basic Usage
35
30
 
36
- ### 1.0 **Chunks**
31
+ A **chunk** is a small container of state. It holds a value, and you can do some stuffs with it:
37
32
 
38
- A **chunk** is a small container of state. It holds a value, and you can do three things with it:
39
-
40
- - **Get** the current value of the chunk
41
- - **Set** a new value for the chunk
42
- - **Subscribe** to the chunk to get notified whenever the value changes
43
-
44
- ### Usage:
45
-
46
- ```ts
33
+ ```typescript
47
34
  import { chunk } from "stunk";
48
35
 
49
- const count = chunk(0);
50
-
51
- console.log(count.get()); // 0
52
-
53
- count.set(5);
54
-
55
- console.log(count.get()); // 5
56
- ```
57
-
58
- ### 1.1. **Subscription**
59
-
60
- You can **subscribe** to a **chunk**. This means you get notified whenever the value inside the chunk changes. This is super useful for updating your app automatically when **state** changes.
61
-
62
- ### Usage
63
-
64
- ```ts
65
- const count = chunk(0);
66
- const callback = (newValue: number) => console.log("Updated value:", newValue);
67
-
68
- count.subscribe(callback);
69
-
70
- count.set(10); // Will log: "Updated value: 10"
71
- ```
72
-
73
- ### 1.1.0. **Unsubscribing**
74
-
75
- You can **unsubscribe** from a **chunk**, which means you stop getting notifications when the **value changes**. You can do this by calling the function that's returned when you **subscribe..** Well, would you wanna do that? 😂
76
-
77
- ### Usage
36
+ // Create a simple counter
37
+ const counterChunk = chunk(0);
78
38
 
79
- ```ts
80
- const count = chunk(0);
81
- const callback = (newValue: number) => console.log("Updated value:", newValue);
82
-
83
- const unsubscribe = count.subscribe(callback);
39
+ // Subscribe to changes
40
+ counterChunk.subscribe((value) => {
41
+ console.log("Counter changed:", value);
42
+ });
84
43
 
85
- count.set(10); // Will log: "Updated value: 10"
44
+ // Update the value
45
+ counterChunk.set(1);
86
46
 
87
- unsubscribe(); // Unsubscribe
47
+ // Get current value
48
+ const value = counterChunk.get(); // 1
88
49
 
89
- count.set(20); // Nothing will happen now, because you unsubscribed
50
+ // Reset to initial value
51
+ counterChunk.reset();
90
52
  ```
91
53
 
92
- ### 1.2. **Deriving New Chunks**
93
-
94
- With Stunk, you can create **derived chunks**. This means you can create a new **chunk** based on the value of another **chunk**. When the original **chunk** changes, the **derived chunk** will automatically update.
54
+ ## Deriving New Chunks
95
55
 
96
- ### Usage
56
+ With **Stunk**, you can create **derived chunks**. This means you can create a new **chunk** based on the value of another **chunk**.
57
+ When the original **chunk** changes, the **derived chunk** will automatically update.
97
58
 
98
- ```ts
59
+ ```typescript
99
60
  const count = chunk(5);
100
61
 
101
62
  // Create a derived chunk that doubles the count
@@ -110,138 +71,238 @@ count.set(10);
110
71
  // "Double count: 20"
111
72
  ```
112
73
 
113
- ### 1.3. **Batch Updates**
74
+ ## Batch Updates
114
75
 
115
- Batch updates allow you to group multiple **state changes** together and notify **subscribers** only once at the end of the **batch**. This is particularly useful for **optimizing performance** when you need to **update multiple** chunks at the same time.
76
+ Batch Update group multiple **state changes** together and notify **subscribers** only once at the end of the **batch**. This is particularly useful for **optimizing performance** when you need to **update multiple** chunks at the same time.
116
77
 
117
- ### Usage
118
-
119
- ```ts
78
+ ```typescript
120
79
  import { chunk, batch } from "stunk";
121
80
 
122
- const firstName = chunk("John");
123
- const lastName = chunk("Doe");
124
- const age = chunk(25);
81
+ const nameChunk = chunk("Olamide");
82
+ const ageChunk = chunk(30);
125
83
 
126
- // Subscribe to changes
127
- firstName.subscribe((name) => console.log("First name changed:", name));
128
- lastName.subscribe((name) => console.log("Last name changed:", name));
129
- age.subscribe((age) => console.log("Age changed:", age));
130
-
131
- // Without batch - triggers three separate updates
132
- firstName.set("Jane"); // Logs immediately
133
- lastName.set("Smith"); // Logs immediately
134
- age.set(26); // Logs immediately
135
-
136
- // With batch - triggers only one update per chunk at the end
137
84
  batch(() => {
138
- firstName.set("Jane"); // Doesn't log yet
139
- lastName.set("Smith"); // Doesn't log yet
140
- age.set(26); // Doesn't log yet
141
- }); // All logs happen here at once
85
+ nameChunk.set("AbdulAzeez");
86
+ ageChunk.set(31);
87
+ }); // Only one notification will be sent to subscribers
142
88
 
143
89
  // Nested batches are also supported
144
90
  batch(() => {
145
- firstName.set("Jane");
91
+ firstName.set("Olanrewaju");
146
92
  batch(() => {
147
- lastName.set("Smith");
148
- age.set(26);
93
+ age.set(29);
149
94
  });
150
- });
95
+ }); // Only one notification will be sent to subscribers
151
96
  ```
152
97
 
153
- Looks intresting right? There's more!
154
-
155
- Batching is particularly useful when:
156
-
157
- - Updating multiple related pieces of state at once
158
- - Performing form updates
159
- - Handling complex state transitions
160
- - Optimizing performance in data-heavy applications
98
+ ## State Selection
161
99
 
162
- The batch function ensures that:
100
+ Efficiently access and react to specific state parts:
163
101
 
164
- - All updates within the batch are processed together
165
- - Subscribers are notified only once with the final value
166
- - Nested batches are handled correctly
167
- - Updates are processed even if an error occurs (using try/finally)
102
+ ```typescript
103
+ import { chunk, select } from "stunk";
168
104
 
169
- ### 1.4. **Middleware**
105
+ const userChunk = chunk({
106
+ name: "Olamide",
107
+ age: 30,
108
+ email: "olamide@example.com",
109
+ });
170
110
 
171
- Middleware allows you to customize how values are set in a **chunk**. For example, you can add **logging**, **validation**, or any custom behavior when a chunk's value changes.
111
+ // Select specific properties -readonly
112
+ const nameChunk = select(userChunk, (state) => state.name);
113
+ const ageChunk = select(userChunk, (state) => state.age);
172
114
 
173
- A middleware is a function with the following structure:
115
+ nameChunk.subscribe((name) => console.log("Name changed:", name));
116
+ // will only re-render if the selected part change.
174
117
 
175
- ```ts
176
- export type Middleware<T> = (value: T, next: (newValue: T) => void) => void;
118
+ nameChunk.set("Olamide"); // ❌ this will throw an error, because it is a readonly.
177
119
  ```
178
120
 
179
- - value: The value that is about to be set to the chunk.
180
- - next(value): A function you must call with the processed (or unaltered) value to continue the chain of middleware and eventually update the chunk's state.
121
+ ## Middleware
181
122
 
182
- ### Usage
123
+ Middleware allows you to customize how values are set in a **chunk**. For example, you can add **logging**, **validation**, or any custom behavior when a chunk's value changes.
183
124
 
184
- ```ts
125
+ ```typescript
185
126
  import { chunk } from "stunk";
186
- import { logger } from "stunk/middleware"; // native to stunk
187
- import { nonNegativeValidator } from "stunk/middleware"; // native to stunk
188
- // You can also create yours and pass it []
127
+ import { logger, nonNegativeValidator } from "stunk/middleware";
128
+
129
+ // You can also create yours and pass it chunk as the second param
189
130
 
190
131
  // Use middleware for logging and validation
191
132
  const age = chunk(25, [logger, nonNegativeValidator]);
192
133
 
193
134
  age.set(30); // Logs: "Setting value: 30"
194
- age.set(-5); // Throws an error: "Value must be non-negative!"
135
+ age.set(-5); // Throws an error: "Value must be non-negative!"
195
136
  ```
196
137
 
197
- ### 1.4.1. Middleware: Undo & Redo (withHistory)
198
-
199
- The **withHistory** middleware extends a chunk to support undo and redo functionality. This allows you to navigate back and forth between previous **states**, making it useful for implementing features like **undo/redo**, form history, and state time travel.
200
-
201
- ### Usage
138
+ ## Time Travel (Middleware)
202
139
 
203
- ```ts
140
+ ```typescript
204
141
  import { chunk } from "stunk";
205
- import { withHistory } from "stunk/middleware"; // Import the history middleware
142
+ import { withHistory } from "stunk/midddleware";
206
143
 
207
- const counter = withHistory(chunk(0));
144
+ const counterChunk = withHistory(chunk(0));
208
145
 
209
- counter.set(10);
210
- counter.set(20);
146
+ counterChunk.set(1);
147
+ counterChunk.set(2);
211
148
 
212
- console.log(counter.get()); // 20
149
+ counterChunk.undo(); // Goes back to 1
150
+ counterChunk.undo(); // Goes back to 0
213
151
 
214
- counter.undo(); // Go back one step
215
- console.log(counter.get()); // 10
152
+ counterChunk.redo(); // Goes forward to 1
216
153
 
217
- counter.redo(); // Go forward one step
218
- console.log(counter.get()); // 20
219
- ```
154
+ counterChunk.canUndo(); // Returns `true` if there is a previous state to revert to..
155
+ counterChunk.canRedo(); // Returns `true` if there is a next state to move to.
220
156
 
221
- **Available Methods**
157
+ counterChunk.getHistory(); // Returns an array of all the values in the history.
222
158
 
223
- | Method | Description |
224
- | ---------------- | ----------------------------------------------------------- |
225
- | `undo()` | Reverts to the previous state (if available). |
226
- | `redo()` | Moves forward to the next state (if available). |
227
- | `canUndo()` | Returns `true` if there are past states available. |
228
- | `canRedo()` | Returns `true` if there are future states available. |
229
- | `getHistory()` | Returns an `array` of all past states. |
230
- | `clearHistory()` | Clears all stored history and keeps only the current state. |
159
+ counterChunk.clearHistory(); // Clears the history, keeping only the current value.
160
+ ```
231
161
 
232
162
  **Example: Limiting History Size (Optional)**
233
163
  You can specify a max history size to prevent excessive memory usage.
234
164
 
235
165
  ```ts
236
- const counter = withHistory(chunk(0), { maxHistory: 5 }); // Only keeps the last 5 changes -- default is 100.
166
+ const counter = withHistory(chunk(0), { maxHistory: 5 });
167
+ // Only keeps the last 5 changes -- default is 100.
237
168
  ```
238
169
 
239
170
  This prevents the history from growing indefinitely and ensures efficient memory usage.
240
171
 
241
- ## 2. **Atomic State Technique**
172
+ ## State Persistence
173
+
174
+ Stunk provides a persistence middleware to automatically save state changes to storage (localStorage, sessionStorage, etc).
175
+
176
+ ```typescript
177
+ import { chunk } from "stunk";
178
+ import { withPersistence } from "stunk/middleware";
179
+
180
+ const counterChunk = withPersistence(chunk({ count: 0 }), {
181
+ key: "counter-state",
182
+ });
183
+
184
+ // State automatically persists to localStorage
185
+ counterChunk.set({ count: 1 });
186
+ ```
187
+
188
+ ## Async State
189
+
190
+ ```typescript
191
+ import { asyncChunk } from "stunk";
192
+
193
+ type User = {
194
+ id: number;
195
+ name: string;
196
+ email: string;
197
+ };
198
+
199
+ const user = asyncChunk<User>(async () => {
200
+ const response = await fetch("/api/user");
201
+ return response.json(); // TypeScript expects this to return User;
202
+ });
203
+
204
+ // Now userChunk is typed as AsyncChunk<User>, which means:
205
+ user.subscribe((state) => {
206
+ if (state.data) {
207
+ // state.data is typed as User | null
208
+ console.log(state.data.name); // TypeScript knows 'name' exists
209
+ console.log(state.data.age); // ❌ TypeScript Error: Property 'age' does not exist
210
+ }
211
+ });
212
+
213
+ user.subscribe(({ loading, error, data }) => {
214
+ if (loading) console.log("Loading...");
215
+ if (error) console.log("Error:", error);
216
+ if (data) console.log("User:", data);
217
+ });
218
+
219
+ // Reload data
220
+ await user.reload();
221
+
222
+ // Optimistic update
223
+ user.mutate((currentData) => ({
224
+ ...currentData,
225
+ name: "Fola",
226
+ }));
227
+
228
+ // The mutate function also enforces the User type
229
+ user.mutate(currentUser => ({
230
+ id: currentUser?.id ?? 0,
231
+ name: "Olamide",
232
+ email: "olamide@gmail.com"
233
+ age: 70 // ❌ TypeScript Error: Object literal may only specify known properties
234
+ }));
235
+ ```
236
+
237
+ ## API Reference
238
+
239
+ ### Core
240
+
241
+ - `chunk<T>(initialValue: T): Chunk<T>`
242
+ - `batch(fn: () => void): void`
243
+ - `select<T, S>(sourceChunk: Chunk<T>, selector: (state: T) => S): Chunk<S>`
244
+ <!-- - `asyncChunk<T>(fetcher: () => Promise<T>, options?): AsyncChunk<T>` -->
245
+
246
+ ### History
247
+
248
+ - `withHistory<T>(chunk: Chunk<T>, options: { maxHistory?: number }): ChunkWithHistory<T>`
249
+
250
+ ### Persistance
251
+
252
+ - `withPersistence<T>(baseChunk: Chunk<T>,options: PersistOptions<T>): Chunk<T>`
253
+
254
+ ### Types
255
+
256
+ ```typescript
257
+ interface Chunk<T> {
258
+ get(): T;
259
+ set(value: T): void;
260
+ subscribe(callback: (value: T) => void): () => void;
261
+ derive<D>(fn: (value: T) => D): Chunk<D>;
262
+ reset(): void;
263
+ destroy(): void;
264
+ }
265
+ ```
266
+
267
+ ```typescript
268
+ interface AsyncState<T> {
269
+ loading: boolean;
270
+ error: Error | null;
271
+ data: T | null;
272
+ }
273
+ ```
274
+
275
+ ```typescript
276
+ interface AsyncChunk<T> extends Chunk<AsyncState<T>> {
277
+ reload(): Promise<void>;
278
+ mutate(mutator: (currentData: T | null) => T): void;
279
+ }
280
+ ```
281
+
282
+ ```typescript
283
+ interface ChunkWithHistory<T> extends Chunk<T> {
284
+ undo: () => void;
285
+ redo: () => void;
286
+ canUndo: () => boolean;
287
+ canRedo: () => boolean;
288
+ getHistory: () => T[];
289
+ clearHistory: () => void;
290
+ }
291
+ ```
292
+
293
+ ```typescript
294
+ interface PersistOptions<T> {
295
+ key: string; // Storage key
296
+ storage?: Storage; // Storage mechanism (default: localStorage)
297
+ serialize?: (value: T) => string; // Custom serializer
298
+ deserialize?: (value: string) => T; // Custom deserializer
299
+ }
300
+ ```
301
+
302
+ ## Contributing
303
+
304
+ Contributions are welcome! Please feel free to submit a Pull Request.
242
305
 
243
- The **Atomic State** technique is all about breaking down your state into small, manageable chunks. This allows you to:
306
+ ## License
244
307
 
245
- - Keep state changes focused and efficient
246
- - Update only the parts of your app that need to change
247
- - Easily manage and subscribe to state changes
308
+ This is licence under MIT
@@ -0,0 +1,46 @@
1
+ import { chunk } from "./core";
2
+ export function asyncChunk(fetcher, options = {}) {
3
+ const { initialData = null, onError, retryCount = 0, retryDelay = 1000, } = options;
4
+ const initialState = {
5
+ loading: true,
6
+ error: null,
7
+ data: initialData,
8
+ };
9
+ const baseChunk = chunk(initialState);
10
+ const fetchData = async (retries = retryCount) => {
11
+ baseChunk.set({ ...baseChunk.get(), loading: true, error: null });
12
+ try {
13
+ const data = await fetcher();
14
+ baseChunk.set({ loading: false, error: null, data });
15
+ }
16
+ catch (error) {
17
+ if (retries > 0) {
18
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
19
+ return fetchData(retries - 1);
20
+ }
21
+ const errorObj = error instanceof Error ? error : new Error(String(error));
22
+ baseChunk.set({ loading: false, error: errorObj, data: baseChunk.get().data });
23
+ if (onError) {
24
+ onError(errorObj);
25
+ }
26
+ }
27
+ };
28
+ // Initial fetch
29
+ fetchData();
30
+ const asyncChunkInstance = {
31
+ ...baseChunk,
32
+ reload: async () => {
33
+ await fetchData();
34
+ },
35
+ mutate: (mutator) => {
36
+ const currentState = baseChunk.get();
37
+ const newData = mutator(currentState.data);
38
+ baseChunk.set({ ...currentState, data: newData });
39
+ },
40
+ reset: () => {
41
+ baseChunk.set(initialState);
42
+ fetchData();
43
+ },
44
+ };
45
+ return asyncChunkInstance;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,6 @@
1
+ // Middleware passed to chunks
1
2
  export { logger } from "./logger";
2
3
  export { nonNegativeValidator } from "./validator";
4
+ // Middleware used with chunks
3
5
  export { withHistory } from "./history";
6
+ export { withPersistence } from './persistence';
@@ -0,0 +1,25 @@
1
+ export function withPersistence(baseChunk, options) {
2
+ const { key, storage = localStorage, serialize = JSON.stringify, deserialize = JSON.parse, } = options;
3
+ // Try to load initial state from storage
4
+ try {
5
+ const savedChunk = storage.getItem(key);
6
+ if (savedChunk) {
7
+ const parsed = deserialize(savedChunk);
8
+ baseChunk.set(parsed);
9
+ }
10
+ }
11
+ catch (error) {
12
+ console.error('Failed to load persisted state:', error);
13
+ }
14
+ // Save to storage
15
+ baseChunk.subscribe((newValue) => {
16
+ try {
17
+ const serialized = serialize(newValue);
18
+ storage.setItem(key, serialized);
19
+ }
20
+ catch (error) {
21
+ console.log('Failed to persist chunk', error);
22
+ }
23
+ });
24
+ return baseChunk;
25
+ }
package/dist/utils.js CHANGED
@@ -1,3 +1,33 @@
1
+ import { chunk } from "./core/core";
1
2
  export function isValidChunkValue(value) {
2
3
  return value !== null && value !== undefined;
3
4
  }
5
+ export function combineAsyncChunks(chunks) {
6
+ // Create initial state with proper typing
7
+ const initialData = Object.keys(chunks).reduce((acc, key) => {
8
+ acc[key] = null;
9
+ return acc;
10
+ }, {});
11
+ const initialState = {
12
+ loading: true,
13
+ error: null,
14
+ data: initialData
15
+ };
16
+ const combined = chunk(initialState);
17
+ Object.entries(chunks).forEach(([key, asyncChunk]) => {
18
+ asyncChunk.subscribe((state) => {
19
+ const currentState = combined.get();
20
+ combined.set({
21
+ loading: Object.values(chunks).some(chunk => chunk.get().loading),
22
+ error: Object.values(chunks)
23
+ .map(chunk => chunk.get().error)
24
+ .find(error => error !== null) || null,
25
+ data: {
26
+ ...currentState.data,
27
+ [key]: state.data
28
+ },
29
+ });
30
+ });
31
+ });
32
+ return combined;
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stunk",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Stunk - A framework-agnostic state management library implementing the Atomic State technique, utilizing chunk-based units for efficient state management.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/types/index.d.ts",
@@ -9,19 +9,25 @@
9
9
  "test": "jest --runInBand",
10
10
  "prepare": "npm run build"
11
11
  },
12
+ "testEnvironment": "jsdom",
12
13
  "keywords": [
13
14
  "state-management",
14
15
  "atomic-state",
15
16
  "framework-agnostic",
16
17
  "stunk",
17
18
  "state",
18
- "chunk"
19
+ "chunk",
20
+ "react",
21
+ "React state management",
22
+ "Vue state management",
23
+ "management"
19
24
  ],
20
25
  "author": "AbdulAzeez",
21
26
  "license": "MIT",
22
27
  "devDependencies": {
23
28
  "@types/jest": "^29.5.14",
24
29
  "jest": "^29.7.0",
30
+ "jest-environment-jsdom": "^29.7.0",
25
31
  "ts-jest": "^29.2.5",
26
32
  "typescript": "^5.0.0"
27
33
  }
@@ -0,0 +1,83 @@
1
+ import { chunk, Chunk } from "./core";
2
+ import { AsyncChunkOpt } from "./types";
3
+
4
+ export interface AsyncState<T> {
5
+ loading: boolean;
6
+ error: Error | null;
7
+ data: T | null;
8
+ }
9
+
10
+ export interface AsyncChunk<T> extends Chunk<AsyncState<T>> {
11
+ /**
12
+ * Reload the data from the source.
13
+ */
14
+ reload: () => Promise<void>;
15
+ /**
16
+ * Mutate the data directly.
17
+ */
18
+ mutate: (mutator: (currentData: T | null) => T) => void;
19
+ /**
20
+ * Reset the state to the initial value.
21
+ */
22
+ reset: () => void;
23
+ }
24
+
25
+ export function asyncChunk<T>(fetcher: () => Promise<T>, options: AsyncChunkOpt<T> = {}): AsyncChunk<T> {
26
+ const {
27
+ initialData = null,
28
+ onError,
29
+ retryCount = 0,
30
+ retryDelay = 1000,
31
+ } = options;
32
+
33
+ const initialState: AsyncState<T> = {
34
+ loading: true,
35
+ error: null,
36
+ data: initialData,
37
+ };
38
+
39
+ const baseChunk = chunk(initialState);
40
+
41
+ const fetchData = async (retries = retryCount): Promise<void> => {
42
+ baseChunk.set({ ...baseChunk.get(), loading: true, error: null });
43
+
44
+ try {
45
+ const data = await fetcher();
46
+ baseChunk.set({ loading: false, error: null, data });
47
+ } catch (error) {
48
+ if (retries > 0) {
49
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
50
+ return fetchData(retries - 1);
51
+ }
52
+
53
+ const errorObj = error instanceof Error ? error : new Error(String(error));
54
+ baseChunk.set({ loading: false, error: errorObj, data: baseChunk.get().data });
55
+
56
+ if (onError) {
57
+ onError(errorObj);
58
+ }
59
+
60
+ }
61
+ }
62
+
63
+ // Initial fetch
64
+ fetchData();
65
+
66
+ const asyncChunkInstance: AsyncChunk<T> = {
67
+ ...baseChunk,
68
+ reload: async () => {
69
+ await fetchData();
70
+ },
71
+ mutate: (mutator: (currentData: T | null) => T) => {
72
+ const currentState = baseChunk.get();
73
+ const newData = mutator(currentState.data);
74
+ baseChunk.set({ ...currentState, data: newData });
75
+ },
76
+ reset: () => {
77
+ baseChunk.set(initialState);
78
+ fetchData();
79
+ },
80
+ }
81
+
82
+ return asyncChunkInstance;
83
+ }
@@ -0,0 +1,17 @@
1
+ import { AsyncChunk } from "./asyncChunk";
2
+
3
+ export type AsyncChunkOpt<T> = {
4
+ initialData?: T | null;
5
+ onError?: (error: Error) => void;
6
+ retryCount?: number;
7
+ retryDelay?: number;
8
+ }
9
+
10
+ export type InferAsyncData<T> = T extends AsyncChunk<infer U> ? U : never;
11
+
12
+ export type CombinedData<T> = { [K in keyof T]: InferAsyncData<T[K]> | null };
13
+ export type CombinedState<T> = {
14
+ loading: boolean;
15
+ error: Error | null;
16
+ data: CombinedData<T>;
17
+ };
@@ -1,11 +1,29 @@
1
1
  import { Chunk } from "../core/core";
2
2
 
3
3
  export interface ChunkWithHistory<T> extends Chunk<T> {
4
+ /**
5
+ * Reverts to the previous state (if available).
6
+ */
4
7
  undo: () => void;
8
+ /**
9
+ * Moves to the next state (if available).
10
+ */
5
11
  redo: () => void;
12
+ /**
13
+ * Returns true if there is a previous state to revert to.
14
+ */
6
15
  canUndo: () => boolean;
16
+ /**
17
+ * Returns true if there is a next state to move to.
18
+ */
7
19
  canRedo: () => boolean;
20
+ /**
21
+ * Returns an array of all the values in the history.
22
+ */
8
23
  getHistory: () => T[];
24
+ /**
25
+ * Clears the history, keeping only the current value.
26
+ */
9
27
  clearHistory: () => void;
10
28
  }
11
29
 
@@ -1,3 +1,7 @@
1
+ // Middleware passed to chunks
1
2
  export { logger } from "./logger";
2
3
  export { nonNegativeValidator } from "./validator";
4
+
5
+ // Middleware used with chunks
3
6
  export { withHistory } from "./history";
7
+ export { withPersistence } from './persistence';
@@ -0,0 +1,45 @@
1
+ import { Chunk } from "../core/core";
2
+
3
+ export interface PersistOptions<T> {
4
+ key: string;
5
+ storage?: Storage;
6
+ serialize?: (value: T) => string;
7
+ deserialize?: (value: string) => T;
8
+ }
9
+
10
+ export function withPersistence<T>(
11
+ baseChunk: Chunk<T>,
12
+ options: PersistOptions<T>
13
+ ): Chunk<T> {
14
+ const {
15
+ key,
16
+ storage = localStorage,
17
+ serialize = JSON.stringify,
18
+ deserialize = JSON.parse,
19
+ } = options;
20
+
21
+ // Try to load initial state from storage
22
+ try {
23
+ const savedChunk = storage.getItem(key);
24
+ if (savedChunk) {
25
+ const parsed = deserialize(savedChunk);
26
+ baseChunk.set(parsed)
27
+ }
28
+ } catch (error) {
29
+ console.error('Failed to load persisted state:', error);
30
+ }
31
+
32
+ // Save to storage
33
+ baseChunk.subscribe((newValue) => {
34
+ try {
35
+ const serialized = serialize(newValue);
36
+ storage.setItem(key, serialized);
37
+
38
+ } catch (error) {
39
+ console.log('Failed to persist chunk', error)
40
+ }
41
+ })
42
+
43
+ return baseChunk
44
+
45
+ }
package/src/utils.ts CHANGED
@@ -1,3 +1,49 @@
1
+ import { chunk, Chunk } from "./core/core";
2
+
3
+ import { AsyncChunk } from "./core/asyncChunk";
4
+ import { CombinedData, CombinedState, InferAsyncData } from "./core/types";
5
+
1
6
  export function isValidChunkValue(value: any): boolean {
2
7
  return value !== null && value !== undefined;
3
8
  }
9
+
10
+ export function combineAsyncChunks<T extends Record<string, AsyncChunk<any>>>(
11
+ chunks: T
12
+ ): Chunk<{
13
+ loading: boolean;
14
+ error: Error | null;
15
+ data: { [K in keyof T]: InferAsyncData<T[K]> | null };
16
+ }> {
17
+ // Create initial state with proper typing
18
+ const initialData = Object.keys(chunks).reduce((acc, key) => {
19
+ acc[key as keyof T] = null;
20
+ return acc;
21
+ }, {} as CombinedData<T>);
22
+
23
+ const initialState: CombinedState<T> = {
24
+ loading: true,
25
+ error: null,
26
+ data: initialData
27
+ };
28
+
29
+ const combined = chunk(initialState);
30
+
31
+ Object.entries(chunks).forEach(([key, asyncChunk]) => {
32
+ asyncChunk.subscribe((state) => {
33
+ const currentState = combined.get();
34
+
35
+ combined.set({
36
+ loading: Object.values(chunks).some(chunk => chunk.get().loading),
37
+ error: Object.values(chunks)
38
+ .map(chunk => chunk.get().error)
39
+ .find(error => error !== null) || null,
40
+ data: {
41
+ ...currentState.data,
42
+ [key]: state.data
43
+ },
44
+ });
45
+ });
46
+ });
47
+
48
+ return combined;
49
+ }
@@ -0,0 +1,215 @@
1
+ import { asyncChunk } from '../src/core/asyncChunk';
2
+ import { combineAsyncChunks } from '../src/utils';
3
+
4
+ // Mock types for testing
5
+ interface User {
6
+ id: number;
7
+ name: string;
8
+ }
9
+
10
+ interface Post {
11
+ id: number;
12
+ title: string;
13
+ }
14
+
15
+ describe('asyncChunk', () => {
16
+ // Helper to create a delayed response
17
+ const createDelayedResponse = <T>(data: T, delay = 50): Promise<T> => {
18
+ return new Promise((resolve) => setTimeout(() => resolve(data), delay));
19
+ };
20
+
21
+ it('should handle successful async operations', async () => {
22
+ const mockUser: User = { id: 1, name: 'Test User' };
23
+ const userChunk = asyncChunk<User>(async () => {
24
+ return createDelayedResponse(mockUser);
25
+ });
26
+
27
+ // Initial state
28
+ expect(userChunk.get()).toEqual({
29
+ loading: true,
30
+ error: null,
31
+ data: null
32
+ });
33
+
34
+ // Wait for async operation to complete
35
+ await new Promise(resolve => setTimeout(resolve, 100));
36
+
37
+ // Check final state
38
+ expect(userChunk.get()).toEqual({
39
+ loading: false,
40
+ error: null,
41
+ data: mockUser
42
+ });
43
+ });
44
+
45
+ it('should handle errors', async () => {
46
+ const errorMessage = 'Failed to fetch';
47
+ const userChunk = asyncChunk<User>(async () => {
48
+ throw new Error(errorMessage);
49
+ });
50
+
51
+ // Wait for async operation to complete
52
+ await new Promise(resolve => setTimeout(resolve, 100));
53
+
54
+ const state = userChunk.get();
55
+ expect(state.loading).toBe(false);
56
+ expect(state.error?.message).toBe(errorMessage);
57
+ expect(state.data).toBe(null);
58
+ });
59
+
60
+ it('should handle retries', async () => {
61
+ let attempts = 0;
62
+ const mockUser: User = { id: 1, name: 'Test User' };
63
+
64
+ const userChunk = asyncChunk<User>(
65
+ async () => {
66
+ attempts++;
67
+ if (attempts < 3) {
68
+ throw new Error('Temporary error');
69
+ }
70
+ return mockUser;
71
+ },
72
+ { retryCount: 2, retryDelay: 50 }
73
+ );
74
+
75
+ // Wait for all retries to complete
76
+ await new Promise(resolve => setTimeout(resolve, 200));
77
+
78
+ expect(attempts).toBe(3);
79
+ expect(userChunk.get().data).toEqual(mockUser);
80
+ });
81
+
82
+ it('should support optimistic updates via mutate', () => {
83
+ const mockUser: User = { id: 1, name: 'Test User' };
84
+ const userChunk = asyncChunk<User>(async () => mockUser);
85
+
86
+ userChunk.mutate(current => ({
87
+ ...current!,
88
+ name: 'Updated Name'
89
+ }));
90
+
91
+ const state = userChunk.get();
92
+ expect(state.data?.name).toBe('Updated Name');
93
+ });
94
+
95
+ it('should reload data when requested', async () => {
96
+ let counter = 0;
97
+ const userChunk = asyncChunk<User>(async () => {
98
+ counter++;
99
+ return { id: counter, name: `User ${counter}` };
100
+ });
101
+
102
+ // Wait for initial load
103
+ await new Promise(resolve => setTimeout(resolve, 100));
104
+ expect(userChunk.get().data?.id).toBe(1);
105
+
106
+ // Trigger reload
107
+ await userChunk.reload();
108
+ expect(userChunk.get().data?.id).toBe(2);
109
+ });
110
+ });
111
+
112
+ describe('combineAsyncChunks', () => {
113
+ it('should combine multiple async chunks', async () => {
114
+ const mockUser: User = { id: 1, name: 'Test User' };
115
+ const mockPosts: Post[] = [
116
+ { id: 1, title: 'Post 1' },
117
+ { id: 2, title: 'Post 2' }
118
+ ];
119
+
120
+ const userChunk = asyncChunk<User>(async () => {
121
+ return createDelayedResponse(mockUser, 50);
122
+ });
123
+
124
+ const postsChunk = asyncChunk<Post[]>(async () => {
125
+ return createDelayedResponse(mockPosts, 100);
126
+ });
127
+
128
+ const combined = combineAsyncChunks({
129
+ user: userChunk,
130
+ posts: postsChunk
131
+ });
132
+
133
+ // Initial state
134
+ expect(combined.get()).toEqual({
135
+ loading: true,
136
+ error: null,
137
+ data: {
138
+ user: null,
139
+ posts: null
140
+ }
141
+ });
142
+
143
+ // Wait for all async operations to complete
144
+ await new Promise(resolve => setTimeout(resolve, 150));
145
+
146
+ // Check final state
147
+ expect(combined.get()).toEqual({
148
+ loading: false,
149
+ error: null,
150
+ data: {
151
+ user: mockUser,
152
+ posts: mockPosts
153
+ }
154
+ });
155
+ });
156
+
157
+ it('should handle errors in combined chunks', async () => {
158
+ const mockUser: User = { id: 1, name: 'Test User' };
159
+ const errorMessage = 'Failed to fetch posts';
160
+
161
+ const userChunk = asyncChunk<User>(async () => {
162
+ return createDelayedResponse(mockUser, 50);
163
+ });
164
+
165
+ const postsChunk = asyncChunk<Post[]>(async () => {
166
+ throw new Error(errorMessage);
167
+ });
168
+
169
+ const combined = combineAsyncChunks({
170
+ user: userChunk,
171
+ posts: postsChunk
172
+ });
173
+
174
+ // Wait for all operations to complete
175
+ await new Promise(resolve => setTimeout(resolve, 150));
176
+
177
+ const state = combined.get();
178
+ expect(state.loading).toBe(false);
179
+ expect(state.error?.message).toBe(errorMessage);
180
+ expect(state.data.user).toEqual(mockUser);
181
+ expect(state.data.posts).toBe(null);
182
+ });
183
+
184
+ it('should update loading state correctly', async () => {
185
+ const loadingStates: boolean[] = [];
186
+ const userChunk = asyncChunk<User>(async () => {
187
+ return createDelayedResponse({ id: 1, name: 'Test User' }, 50);
188
+ });
189
+
190
+ const postsChunk = asyncChunk<Post[]>(async () => {
191
+ return createDelayedResponse([], 100);
192
+ });
193
+
194
+ const combined = combineAsyncChunks({
195
+ user: userChunk,
196
+ posts: postsChunk
197
+ });
198
+
199
+ combined.subscribe(state => {
200
+ loadingStates.push(state.loading);
201
+ });
202
+
203
+ // Wait for all operations
204
+ await new Promise(resolve => setTimeout(resolve, 150));
205
+
206
+ // Should start with loading true and end with false
207
+ expect(loadingStates[0]).toBe(true);
208
+ expect(loadingStates[loadingStates.length - 1]).toBe(false);
209
+ });
210
+ });
211
+
212
+ // Helper function
213
+ function createDelayedResponse<T>(data: T, delay = 50): Promise<T> {
214
+ return new Promise((resolve) => setTimeout(() => resolve(data), delay));
215
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { chunk } from '../src/core/core';
6
+ import { withPersistence } from "../src/middleware/persistence";
7
+
8
+ describe('withPersistence', () => {
9
+ beforeEach(() => {
10
+ localStorage.clear();
11
+ sessionStorage.clear();
12
+ });
13
+
14
+ it('should persist state to localStorage', () => {
15
+ const baseChunk = chunk({ count: 0 });
16
+ const persistedChunk = withPersistence(baseChunk, { key: 'test-key' });
17
+
18
+ persistedChunk.set({ count: 1 });
19
+ expect(JSON.parse(localStorage.getItem('test-key')!)).toEqual({ count: 1 });
20
+ });
21
+
22
+ it('should load persisted state on initialization', () => {
23
+ localStorage.setItem('test-key', JSON.stringify({ count: 5 }));
24
+
25
+ const baseChunk = chunk({ count: 0 });
26
+ const persistedChunk = withPersistence(baseChunk, { key: 'test-key' });
27
+
28
+ expect(persistedChunk.get()).toEqual({ count: 5 });
29
+ });
30
+
31
+ it('should use custom storage', () => {
32
+ const mockStorage = {
33
+ getItem: jest.fn(),
34
+ setItem: jest.fn(),
35
+ };
36
+
37
+ const baseChunk = chunk({ count: 0 });
38
+ withPersistence(baseChunk, {
39
+ key: 'test-key',
40
+ storage: mockStorage as unknown as Storage
41
+ });
42
+
43
+ expect(mockStorage.getItem).toHaveBeenCalledWith('test-key');
44
+ });
45
+
46
+ it('should use custom serializer/deserializer', () => {
47
+ const baseChunk = chunk({ count: 0 });
48
+ const persistedChunk = withPersistence(baseChunk, {
49
+ key: 'test-key',
50
+ serialize: value => btoa(JSON.stringify(value)),
51
+ deserialize: value => JSON.parse(atob(value))
52
+ });
53
+
54
+ persistedChunk.set({ count: 1 });
55
+ expect(localStorage.getItem('test-key')).toBe(btoa(JSON.stringify({ count: 1 })));
56
+ });
57
+ });