@effuse/store 1.0.2 → 1.0.4
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/dist/index.cjs +219 -130
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +219 -130
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/actions/async.ts +0 -15
- package/src/actions/cancellation.ts +0 -5
- package/src/core/store.test.ts +261 -0
- package/src/core/store.ts +56 -152
- package/src/core/types.ts +0 -8
- package/src/devtools/connector.ts +0 -6
- package/src/handlers/index.ts +57 -0
- package/src/handlers/operations.test.ts +232 -0
- package/src/handlers/operations.ts +214 -0
- package/src/handlers/persistence.test.ts +182 -0
- package/src/handlers/persistence.ts +82 -0
- package/src/handlers/subscriptions.test.ts +147 -0
- package/src/handlers/subscriptions.ts +58 -0
- package/src/handlers/types.ts +80 -0
- package/src/persistence/adapters.ts +63 -26
- package/src/reactivity/streams.ts +0 -3
- package/src/registry/index.ts +1 -7
- package/src/validation/schema.ts +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effuse/store",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "A functional state management system built on Effect-ts. It offers typed, robust global state handling designed to scale with your application's logic.",
|
|
5
5
|
"author": "Chris M. Pérez",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,18 +48,18 @@
|
|
|
48
48
|
"node": ">=22.14.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@effuse/core": "1.0
|
|
51
|
+
"@effuse/core": "1.2.0"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"effect": "^3.19.
|
|
54
|
+
"effect": "^3.19.17"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@effect/eslint-plugin": "^0.3.2",
|
|
58
|
-
"@types/node": "^25.
|
|
59
|
-
"@effuse/core": "1.0
|
|
60
|
-
"eslint": "^
|
|
58
|
+
"@types/node": "^25.2.3",
|
|
59
|
+
"@effuse/core": "1.2.0",
|
|
60
|
+
"eslint": "^10.0.0",
|
|
61
61
|
"tsup": "^8.5.1",
|
|
62
62
|
"typescript": "^5.9.3",
|
|
63
|
-
"vitest": "^4.0.
|
|
63
|
+
"vitest": "^4.0.18"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/src/actions/async.ts
CHANGED
|
@@ -37,20 +37,17 @@ import {
|
|
|
37
37
|
TimeoutError,
|
|
38
38
|
} from '../errors.js';
|
|
39
39
|
|
|
40
|
-
// Asynchronous operation outcome
|
|
41
40
|
export interface ActionResult<T> {
|
|
42
41
|
data: T | null;
|
|
43
42
|
error: Error | null;
|
|
44
43
|
loading: boolean;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
// Asynchronous action with pending state
|
|
48
46
|
export interface AsyncAction<A extends unknown[], R> {
|
|
49
47
|
(...args: A): Promise<R>;
|
|
50
48
|
pending: boolean;
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
// Cancellable asynchronous action
|
|
54
51
|
export interface CancellableAction<A extends unknown[], R> {
|
|
55
52
|
(...args: A): Promise<R>;
|
|
56
53
|
pending: boolean;
|
|
@@ -59,7 +56,6 @@ export interface CancellableAction<A extends unknown[], R> {
|
|
|
59
56
|
|
|
60
57
|
type ActionFn<A extends unknown[], R> = (...args: A) => Promise<R> | R;
|
|
61
58
|
|
|
62
|
-
// Build asynchronous action
|
|
63
59
|
export const createAsyncAction = <A extends unknown[], R>(
|
|
64
60
|
fn: ActionFn<A, R>
|
|
65
61
|
): AsyncAction<A, R> => {
|
|
@@ -95,7 +91,6 @@ export const createAsyncAction = <A extends unknown[], R>(
|
|
|
95
91
|
return action as AsyncAction<A, R>;
|
|
96
92
|
};
|
|
97
93
|
|
|
98
|
-
// Build cancellable asynchronous action
|
|
99
94
|
export const createCancellableAction = <A extends unknown[], R>(
|
|
100
95
|
fn: ActionFn<A, R>
|
|
101
96
|
): CancellableAction<A, R> => {
|
|
@@ -145,7 +140,6 @@ export const createCancellableAction = <A extends unknown[], R>(
|
|
|
145
140
|
return action as CancellableAction<A, R>;
|
|
146
141
|
};
|
|
147
142
|
|
|
148
|
-
// Enforce operation timeout
|
|
149
143
|
export const withTimeout = <A extends unknown[], R>(
|
|
150
144
|
fn: ActionFn<A, R>,
|
|
151
145
|
timeoutMs: number
|
|
@@ -165,7 +159,6 @@ export const withTimeout = <A extends unknown[], R>(
|
|
|
165
159
|
};
|
|
166
160
|
};
|
|
167
161
|
|
|
168
|
-
// Retry configuration
|
|
169
162
|
export interface RetryConfig {
|
|
170
163
|
maxRetries: number;
|
|
171
164
|
initialDelayMs?: number;
|
|
@@ -173,7 +166,6 @@ export interface RetryConfig {
|
|
|
173
166
|
backoffFactor?: number;
|
|
174
167
|
}
|
|
175
168
|
|
|
176
|
-
// Retry on failure
|
|
177
169
|
export const withRetry = <A extends unknown[], R>(
|
|
178
170
|
fn: ActionFn<A, R>,
|
|
179
171
|
config: RetryConfig
|
|
@@ -203,7 +195,6 @@ export const withRetry = <A extends unknown[], R>(
|
|
|
203
195
|
};
|
|
204
196
|
};
|
|
205
197
|
|
|
206
|
-
// Execute only latest call
|
|
207
198
|
export const takeLatest = <A extends unknown[], R>(
|
|
208
199
|
fn: ActionFn<A, R>
|
|
209
200
|
): CancellableAction<A, R> => {
|
|
@@ -250,7 +241,6 @@ export const takeLatest = <A extends unknown[], R>(
|
|
|
250
241
|
return action as CancellableAction<A, R>;
|
|
251
242
|
};
|
|
252
243
|
|
|
253
|
-
// Execute only first call
|
|
254
244
|
export const takeFirst = <A extends unknown[], R>(
|
|
255
245
|
fn: ActionFn<A, R>
|
|
256
246
|
): AsyncAction<A, R | undefined> => {
|
|
@@ -277,7 +267,6 @@ export const takeFirst = <A extends unknown[], R>(
|
|
|
277
267
|
return action as AsyncAction<A, R | undefined>;
|
|
278
268
|
};
|
|
279
269
|
|
|
280
|
-
// Debounce action execution
|
|
281
270
|
export const debounceAction = <A extends unknown[], R>(
|
|
282
271
|
fn: ActionFn<A, R>,
|
|
283
272
|
delayMs: number
|
|
@@ -310,7 +299,6 @@ export const debounceAction = <A extends unknown[], R>(
|
|
|
310
299
|
};
|
|
311
300
|
};
|
|
312
301
|
|
|
313
|
-
// Throttle action execution
|
|
314
302
|
export const throttleAction = <A extends unknown[], R>(
|
|
315
303
|
fn: ActionFn<A, R>,
|
|
316
304
|
intervalMs: number
|
|
@@ -335,7 +323,6 @@ export const throttleAction = <A extends unknown[], R>(
|
|
|
335
323
|
};
|
|
336
324
|
};
|
|
337
325
|
|
|
338
|
-
// Dispatch store action asynchronously
|
|
339
326
|
export const dispatch = <T>(
|
|
340
327
|
store: Store<T>,
|
|
341
328
|
actionName: keyof T,
|
|
@@ -357,7 +344,6 @@ export const dispatch = <T>(
|
|
|
357
344
|
);
|
|
358
345
|
};
|
|
359
346
|
|
|
360
|
-
// Dispatch store action synchronously
|
|
361
347
|
export const dispatchSync = <T>(
|
|
362
348
|
store: Store<T>,
|
|
363
349
|
actionName: keyof T,
|
|
@@ -372,7 +358,6 @@ export const dispatchSync = <T>(
|
|
|
372
358
|
return actionFn(...args);
|
|
373
359
|
};
|
|
374
360
|
|
|
375
|
-
// Attach external abort signal
|
|
376
361
|
export const withAbortSignal = <A extends unknown[], R>(
|
|
377
362
|
fn: ActionFn<A, R>
|
|
378
363
|
): ((signal: AbortSignal, ...args: A) => Promise<R>) => {
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
import { Effect } from 'effect';
|
|
26
26
|
import { CancellationError } from '../errors.js';
|
|
27
27
|
|
|
28
|
-
// Cancellation tracking token
|
|
29
28
|
export interface CancellationToken {
|
|
30
29
|
readonly isCancelled: boolean;
|
|
31
30
|
cancel: () => void;
|
|
@@ -33,7 +32,6 @@ export interface CancellationToken {
|
|
|
33
32
|
onCancel: (callback: () => void) => () => void;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
// Build cancellation token
|
|
37
35
|
export const createCancellationToken = (): CancellationToken => {
|
|
38
36
|
let cancelled = false;
|
|
39
37
|
const callbacks = new Set<() => void>();
|
|
@@ -64,14 +62,12 @@ export const createCancellationToken = (): CancellationToken => {
|
|
|
64
62
|
};
|
|
65
63
|
};
|
|
66
64
|
|
|
67
|
-
// Nested cancellation scope
|
|
68
65
|
export interface CancellationScope {
|
|
69
66
|
readonly token: CancellationToken;
|
|
70
67
|
createChild: () => CancellationToken;
|
|
71
68
|
dispose: () => void;
|
|
72
69
|
}
|
|
73
70
|
|
|
74
|
-
// Build cancellation scope
|
|
75
71
|
export const createCancellationScope = (): CancellationScope => {
|
|
76
72
|
const children = new Set<CancellationToken>();
|
|
77
73
|
const token = createCancellationToken();
|
|
@@ -94,7 +90,6 @@ export const createCancellationScope = (): CancellationScope => {
|
|
|
94
90
|
};
|
|
95
91
|
};
|
|
96
92
|
|
|
97
|
-
// Connect external abort signal
|
|
98
93
|
export const runWithAbortSignal = <A, E>(
|
|
99
94
|
effect: Effect.Effect<A, E>,
|
|
100
95
|
signal: AbortSignal
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createStore } from './store.js';
|
|
3
|
+
import { Effect, Option } from 'effect';
|
|
4
|
+
|
|
5
|
+
describe('createStore Integration', () => {
|
|
6
|
+
it('should create a store with initial state', () => {
|
|
7
|
+
const store = createStore('testConfig', { count: 0, user: 'admin' });
|
|
8
|
+
expect(store.count.value).toBe(0);
|
|
9
|
+
expect(store.user.value).toBe('admin');
|
|
10
|
+
expect(store.state.count.value).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should allow updates via proxy setters', () => {
|
|
14
|
+
const store = createStore('testUpdates', { count: 0 });
|
|
15
|
+
// @ts-expect-error
|
|
16
|
+
store.count = 10;
|
|
17
|
+
expect(store.count.value).toBe(10);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should support actions', () => {
|
|
21
|
+
const store = createStore('testActions', {
|
|
22
|
+
count: 0,
|
|
23
|
+
increment() {
|
|
24
|
+
this.count.value++;
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
store.increment();
|
|
29
|
+
expect(store.count.value).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should support computed derived state', () => {
|
|
33
|
+
const store = createStore('testComputed', { count: 1, multiplier: 2 });
|
|
34
|
+
const doubled = store.computed(
|
|
35
|
+
(state) => (state.count as number) * (state.multiplier as number)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(doubled.value).toBe(2);
|
|
39
|
+
|
|
40
|
+
// @ts-expect-error
|
|
41
|
+
store.count = 5;
|
|
42
|
+
expect(doubled.value).toBe(10);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should batch updates', () => {
|
|
46
|
+
const store = createStore('testBatch', { a: 0, b: 0 });
|
|
47
|
+
const callback = vi.fn();
|
|
48
|
+
store.subscribe(callback);
|
|
49
|
+
|
|
50
|
+
store.batch(() => {
|
|
51
|
+
// @ts-expect-error
|
|
52
|
+
store.a = 1;
|
|
53
|
+
// @ts-expect-error
|
|
54
|
+
store.b = 2;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(store.a.value).toBe(1);
|
|
58
|
+
expect(store.b.value).toBe(2);
|
|
59
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should reset state to initial', () => {
|
|
63
|
+
const store = createStore('testReset', { count: 0 });
|
|
64
|
+
// @ts-expect-error
|
|
65
|
+
store.count = 5;
|
|
66
|
+
store.reset();
|
|
67
|
+
expect(store.count.value).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should support key subscriptions', () => {
|
|
71
|
+
const store = createStore('testKeySub', { count: 0, username: 'test' });
|
|
72
|
+
const callback = vi.fn();
|
|
73
|
+
|
|
74
|
+
store.subscribeToKey('count', callback);
|
|
75
|
+
|
|
76
|
+
// @ts-expect-error
|
|
77
|
+
store.count = 1;
|
|
78
|
+
expect(callback).toHaveBeenCalledWith(1);
|
|
79
|
+
|
|
80
|
+
// @ts-expect-error
|
|
81
|
+
store.username = 'changed';
|
|
82
|
+
expect(callback).toHaveBeenCalledTimes(1); // Should not accept name change
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should prevent overwriting store methods', () => {
|
|
86
|
+
const store = createStore('testStrict', { count: 0 });
|
|
87
|
+
expect(() => {
|
|
88
|
+
// @ts-expect-error
|
|
89
|
+
store.subscribe = () => {};
|
|
90
|
+
}).toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle snapshots', () => {
|
|
94
|
+
const store = createStore('testSnapshot', { a: 1 });
|
|
95
|
+
const snap = store.getSnapshot();
|
|
96
|
+
expect(snap).toEqual({ a: 1 });
|
|
97
|
+
|
|
98
|
+
// @ts-expect-error
|
|
99
|
+
store.a = 2;
|
|
100
|
+
expect(store.getSnapshot()).toEqual({ a: 2 });
|
|
101
|
+
// Snapshot should be immutable/copy
|
|
102
|
+
expect(snap).toEqual({ a: 1 });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should support update method', () => {
|
|
106
|
+
const store = createStore('testUpdate', { count: 0 });
|
|
107
|
+
store.update((draft) => {
|
|
108
|
+
draft.count = 10;
|
|
109
|
+
});
|
|
110
|
+
expect(store.count.value).toBe(10);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should support select method', () => {
|
|
114
|
+
const store = createStore('testSelect', {
|
|
115
|
+
users: [{ id: 1, name: 'Alice' }],
|
|
116
|
+
});
|
|
117
|
+
const firstUserName = store.select(
|
|
118
|
+
(state) => (state.users as any[])[0].name
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(firstUserName.value).toBe('Alice');
|
|
122
|
+
|
|
123
|
+
// @ts-expect-error
|
|
124
|
+
store.users = [{ id: 1, name: 'Bob' }];
|
|
125
|
+
expect(firstUserName.value).toBe('Bob');
|
|
126
|
+
});
|
|
127
|
+
it('should support computed chaining', () => {
|
|
128
|
+
const store = createStore('testChain', { count: 1 });
|
|
129
|
+
const double = store.computed((state) => (state.count as number) * 2);
|
|
130
|
+
const quadruple = store.computed(() => double.value * 2);
|
|
131
|
+
|
|
132
|
+
expect(quadruple.value).toBe(4);
|
|
133
|
+
|
|
134
|
+
// @ts-expect-error - Proxy assignment
|
|
135
|
+
store.count = 2;
|
|
136
|
+
expect(double.value).toBe(4);
|
|
137
|
+
expect(quadruple.value).toBe(8);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should unsubscribe correctly', () => {
|
|
141
|
+
const store = createStore('testUnsub', { count: 0 });
|
|
142
|
+
const callback = vi.fn();
|
|
143
|
+
const unsub = store.subscribe(callback);
|
|
144
|
+
|
|
145
|
+
// @ts-expect-error - Proxy assignment
|
|
146
|
+
store.count = 1;
|
|
147
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
148
|
+
|
|
149
|
+
unsub();
|
|
150
|
+
// @ts-expect-error - Proxy assignment
|
|
151
|
+
store.count = 2;
|
|
152
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should handle async actions (success)', async () => {
|
|
156
|
+
const store = createStore('testAsyncSuccess', {
|
|
157
|
+
data: null as string | null,
|
|
158
|
+
async fetchData() {
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
160
|
+
this.data.value = 'loaded';
|
|
161
|
+
return 'result';
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const result = await store.fetchData();
|
|
166
|
+
expect(result).toBe('result');
|
|
167
|
+
expect(store.data.value).toBe('loaded');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle async actions (failure)', async () => {
|
|
171
|
+
const store = createStore('testAsyncFail', {
|
|
172
|
+
error: null as string | null,
|
|
173
|
+
async riskyAction() {
|
|
174
|
+
throw new Error('boom');
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expect(store.riskyAction()).rejects.toThrow('boom');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should support middleware', () => {
|
|
182
|
+
const store = createStore('testMiddleware', { count: 0 });
|
|
183
|
+
const log: string[] = [];
|
|
184
|
+
|
|
185
|
+
store.use((state, action, args) => {
|
|
186
|
+
log.push(`${action}:${args}`);
|
|
187
|
+
if (action === 'set:count' && args[0] === 100) {
|
|
188
|
+
return { ...state, count: 99 }; // Cap value
|
|
189
|
+
}
|
|
190
|
+
return state;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// @ts-expect-error - Proxy assignment
|
|
194
|
+
store.count = 10;
|
|
195
|
+
expect(log).toContain('set:count:10');
|
|
196
|
+
expect(store.count.value).toBe(10);
|
|
197
|
+
|
|
198
|
+
// @ts-expect-error - Proxy assignment
|
|
199
|
+
store.count = 100;
|
|
200
|
+
expect(store.count.value).toBe(99); // Middleware modified it
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should integrate with persistence', () => {
|
|
204
|
+
const storage = new Map<string, string>();
|
|
205
|
+
const mockAdapter = {
|
|
206
|
+
getItem: (k: string) =>
|
|
207
|
+
Effect.succeed(Option.fromNullable(storage.get(k))),
|
|
208
|
+
setItem: (k: string, v: string) => Effect.sync(() => storage.set(k, v)),
|
|
209
|
+
removeItem: (k: string) => Effect.sync(() => storage.delete(k)),
|
|
210
|
+
has: (k: string) => Effect.succeed(storage.has(k)),
|
|
211
|
+
clear: () => Effect.sync(() => storage.clear()),
|
|
212
|
+
keys: () => Effect.succeed(Array.from(storage.keys())),
|
|
213
|
+
size: () => Effect.succeed(storage.size),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const store = createStore(
|
|
217
|
+
'testPersist',
|
|
218
|
+
{ count: 0 },
|
|
219
|
+
{
|
|
220
|
+
persist: true,
|
|
221
|
+
storage: mockAdapter,
|
|
222
|
+
storageKey: 'my-store',
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// @ts-expect-error - Proxy assignment
|
|
227
|
+
store.count = 5;
|
|
228
|
+
|
|
229
|
+
const stored = storage.get('my-store');
|
|
230
|
+
expect(stored).toBeDefined();
|
|
231
|
+
expect(JSON.parse(stored!)).toEqual({ count: 5 });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should load initial state from persistence', () => {
|
|
235
|
+
const storage = new Map<string, string>();
|
|
236
|
+
storage.set('restored-store', JSON.stringify({ count: 42 }));
|
|
237
|
+
|
|
238
|
+
const mockAdapter = {
|
|
239
|
+
getItem: (k: string) =>
|
|
240
|
+
Effect.succeed(Option.fromNullable(storage.get(k))),
|
|
241
|
+
setItem: (k: string, v: string) => Effect.sync(() => storage.set(k, v)),
|
|
242
|
+
removeItem: (k: string) => Effect.sync(() => storage.delete(k)),
|
|
243
|
+
has: (k: string) => Effect.succeed(storage.has(k)),
|
|
244
|
+
clear: () => Effect.sync(() => storage.clear()),
|
|
245
|
+
keys: () => Effect.succeed(Array.from(storage.keys())),
|
|
246
|
+
size: () => Effect.succeed(storage.size),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const store = createStore(
|
|
250
|
+
'testRestore',
|
|
251
|
+
{ count: 0 },
|
|
252
|
+
{
|
|
253
|
+
persist: true,
|
|
254
|
+
storage: mockAdapter,
|
|
255
|
+
storageKey: 'restored-store',
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(store.count.value).toBe(42);
|
|
260
|
+
});
|
|
261
|
+
});
|