@effuse/store 1.0.2 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effuse/store",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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.3"
51
+ "@effuse/core": "1.1.0"
52
52
  },
53
53
  "dependencies": {
54
54
  "effect": "^3.19.14"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@effect/eslint-plugin": "^0.3.2",
58
- "@types/node": "^25.0.3",
59
- "@effuse/core": "1.0.3",
58
+ "@types/node": "^25.0.8",
59
+ "@effuse/core": "1.1.0",
60
60
  "eslint": "^9.39.2",
61
61
  "tsup": "^8.5.1",
62
62
  "typescript": "^5.9.3",
63
- "vitest": "^4.0.16"
63
+ "vitest": "^4.0.17"
64
64
  }
65
65
  }
@@ -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
+ });