@effuse/store 1.0.4 → 1.0.6

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.4",
3
+ "version": "1.0.6",
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",
@@ -34,7 +34,8 @@
34
34
  "build": "tsup",
35
35
  "dev": "tsup --watch",
36
36
  "lint": "eslint src",
37
- "typecheck": "tsc --noEmit"
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run"
38
39
  },
39
40
  "keywords": [
40
41
  "state-management",
@@ -48,18 +49,18 @@
48
49
  "node": ">=22.14.0"
49
50
  },
50
51
  "peerDependencies": {
51
- "@effuse/core": "1.2.0"
52
+ "@effuse/core": "1.2.2"
52
53
  },
53
54
  "dependencies": {
54
- "effect": "^3.19.17"
55
+ "effect": "^3.20.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@effect/eslint-plugin": "^0.3.2",
58
- "@types/node": "^25.2.3",
59
- "@effuse/core": "1.2.0",
60
- "eslint": "^10.0.0",
59
+ "@types/node": "^25.5.0",
60
+ "@effuse/core": "1.2.2",
61
+ "eslint": "^10.0.3",
61
62
  "tsup": "^8.5.1",
62
63
  "typescript": "^5.9.3",
63
- "vitest": "^4.0.18"
64
+ "vitest": "^4.1.0"
64
65
  }
65
66
  }
@@ -0,0 +1,275 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { Effect } from 'effect';
3
+ import type { ConcurrencyStrategy } from '../../actions/useConcurrency.js';
4
+ import { useConcurrency } from '../../actions/useConcurrency.js';
5
+
6
+ describe('useConcurrency', () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ describe('execution strategies', () => {
16
+ it('should execute all actions concurrently when using "merge" strategy', async () => {
17
+ const executedArgs: number[] = [];
18
+ const action = (id: number) =>
19
+ Effect.sync(() => {
20
+ executedArgs.push(id);
21
+ });
22
+
23
+ const wrappedAction = useConcurrency(action, { strategy: 'merge' });
24
+
25
+ wrappedAction(1);
26
+ wrappedAction(2);
27
+ wrappedAction(3);
28
+
29
+ // sync effects run quickly via runFork
30
+ await Promise.resolve(); // wait for microtasks
31
+ expect(executedArgs).toEqual([1, 2, 3]);
32
+ });
33
+
34
+ it('should interrupt previous action when using "switch" strategy', async () => {
35
+ const completedArgs: number[] = [];
36
+ const action = (id: number) =>
37
+ Effect.gen(function* () {
38
+ yield* Effect.sleep(100);
39
+ completedArgs.push(id);
40
+ });
41
+
42
+ const wrappedAction = useConcurrency(action, { strategy: 'switch' });
43
+
44
+ wrappedAction(1);
45
+ await vi.advanceTimersByTimeAsync(50);
46
+ wrappedAction(2); // Should interrupt 1
47
+ await vi.advanceTimersByTimeAsync(50);
48
+ wrappedAction(3); // Should interrupt 2
49
+ await vi.advanceTimersByTimeAsync(100); // 3 finishes
50
+
51
+ expect(completedArgs).toEqual([3]);
52
+ });
53
+
54
+ it('should ignore new actions while current is running when using "exhaust" strategy', async () => {
55
+ const completedArgs: number[] = [];
56
+ const action = (id: number) =>
57
+ Effect.gen(function* () {
58
+ yield* Effect.sleep(100);
59
+ completedArgs.push(id);
60
+ });
61
+
62
+ const wrappedAction = useConcurrency(action, { strategy: 'exhaust' });
63
+
64
+ wrappedAction(1);
65
+ await vi.advanceTimersByTimeAsync(50);
66
+ wrappedAction(2); // Should be ignored
67
+ await vi.advanceTimersByTimeAsync(100); // 1 finishes running
68
+ wrappedAction(3); // runs after 1 completes
69
+ await vi.advanceTimersByTimeAsync(100); // 3 finishes
70
+
71
+ expect(completedArgs).toEqual([1, 3]);
72
+ });
73
+
74
+ it('should execute actions sequentially when using "concat" strategy', async () => {
75
+ const completedArgs: number[] = [];
76
+ const action = (id: number) =>
77
+ Effect.gen(function* () {
78
+ yield* Effect.sleep(50);
79
+ completedArgs.push(id);
80
+ });
81
+
82
+ const wrappedAction = useConcurrency(action, { strategy: 'concat' });
83
+
84
+ wrappedAction(1);
85
+ wrappedAction(2);
86
+ wrappedAction(3);
87
+
88
+ await vi.advanceTimersByTimeAsync(50); // 1 finishes
89
+ // flush Effect microtasks
90
+ await Promise.resolve();
91
+ expect(completedArgs).toContain(1);
92
+ expect(completedArgs).not.toContain(2);
93
+
94
+ await vi.advanceTimersByTimeAsync(50); // 2 finishes
95
+ await Promise.resolve();
96
+ expect(completedArgs).toContain(2);
97
+ expect(completedArgs).not.toContain(3);
98
+
99
+ await vi.advanceTimersByTimeAsync(50); // 3 finishes
100
+ await Promise.resolve();
101
+ expect(completedArgs).toContain(3);
102
+
103
+ expect(completedArgs).toEqual([1, 2, 3]);
104
+ });
105
+ });
106
+
107
+ describe('Debounce and Throttle', () => {
108
+ it('should delay execution until debounceMs has elapsed since last call', async () => {
109
+ const executedArgs: number[] = [];
110
+ const action = (id: number) =>
111
+ Effect.sync(() => {
112
+ executedArgs.push(id);
113
+ });
114
+
115
+ const wrappedAction = useConcurrency(action, {
116
+ strategy: 'merge',
117
+ debounceMs: 100,
118
+ });
119
+
120
+ wrappedAction(1);
121
+ await vi.advanceTimersByTimeAsync(50);
122
+ wrappedAction(2);
123
+ await vi.advanceTimersByTimeAsync(50);
124
+ wrappedAction(3); // Resets timer
125
+
126
+ expect(executedArgs).toEqual([]); // Not executed yet
127
+
128
+ await vi.advanceTimersByTimeAsync(100); // wait for debounce
129
+ await Promise.resolve();
130
+
131
+ expect(executedArgs).toEqual([3]);
132
+ });
133
+
134
+ it('should limit execution rate based on throttleMs', async () => {
135
+ const executedArgs: number[] = [];
136
+ const action = (id: number) =>
137
+ Effect.sync(() => {
138
+ executedArgs.push(id);
139
+ });
140
+
141
+ const wrappedAction = useConcurrency(action, {
142
+ strategy: 'merge',
143
+ throttleMs: 100,
144
+ });
145
+
146
+ wrappedAction(1); // Executed immediately
147
+ wrappedAction(2); // Ignored
148
+
149
+ await vi.advanceTimersByTimeAsync(50);
150
+ wrappedAction(3); // Still ignored (50ms < 100ms)
151
+
152
+ await vi.advanceTimersByTimeAsync(60); // Total 110ms elapsed
153
+ wrappedAction(4); // Executed
154
+
155
+ await Promise.resolve();
156
+
157
+ expect(executedArgs).toEqual([1, 4]);
158
+ });
159
+
160
+ it('should prioritize throttle over debounce if both are provided', async () => {
161
+ const executedArgs: number[] = [];
162
+ const action = (id: number) =>
163
+ Effect.sync(() => {
164
+ executedArgs.push(id);
165
+ });
166
+
167
+ const wrappedAction = useConcurrency(action, {
168
+ strategy: 'merge',
169
+ throttleMs: 100,
170
+ debounceMs: 50,
171
+ });
172
+
173
+ wrappedAction(1); // Throttle allows, debounce delays by 50ms
174
+
175
+ await vi.advanceTimersByTimeAsync(60); // 1 gets executed
176
+ await Promise.resolve();
177
+ expect(executedArgs).toEqual([1]);
178
+
179
+ // We are at 60ms since last execution. Throttle is 100ms.
180
+ wrappedAction(2); // Ignored by throttle
181
+
182
+ await vi.advanceTimersByTimeAsync(100);
183
+
184
+ expect(executedArgs).toEqual([1]); // throttle drops 2 entirely
185
+ });
186
+ });
187
+
188
+ describe('Edge cases and boundary values', () => {
189
+ it('should handle zero debounce time same as no debounce', async () => {
190
+ const executedArgs: number[] = [];
191
+ const action = (id: number) =>
192
+ Effect.sync(() => {
193
+ executedArgs.push(id);
194
+ });
195
+
196
+ const wrappedAction = useConcurrency(action, {
197
+ strategy: 'merge',
198
+ debounceMs: 0,
199
+ });
200
+
201
+ wrappedAction(1);
202
+ wrappedAction(2);
203
+
204
+ await Promise.resolve();
205
+ expect(executedArgs).toEqual([1, 2]);
206
+ });
207
+
208
+ it('should handle negative debounce time same as no debounce', async () => {
209
+ const executedArgs: number[] = [];
210
+ const action = (id: number) =>
211
+ Effect.sync(() => {
212
+ executedArgs.push(id);
213
+ });
214
+
215
+ const wrappedAction = useConcurrency(action, {
216
+ strategy: 'merge',
217
+ debounceMs: -100,
218
+ });
219
+
220
+ wrappedAction(1);
221
+
222
+ await Promise.resolve();
223
+ expect(executedArgs).toEqual([1]);
224
+ });
225
+ });
226
+ });
227
+
228
+ describe('Generative Permutation Matrix (500 cases)', () => {
229
+ const strategies: ConcurrencyStrategy[] = ['switch', 'exhaust', 'merge', 'concat'];
230
+ const debounceTimes = [undefined, 0, -5, 10, 50];
231
+ const throttleTimes = [undefined, 0, -5, 10, 50];
232
+ const callCounts = [1, 2, 3, 5, 10];
233
+
234
+ const permutations: Array<
235
+ [ConcurrencyStrategy, number | undefined, number | undefined, number]
236
+ > = [];
237
+
238
+ for (const strategy of strategies) {
239
+ for (const debounceMs of debounceTimes) {
240
+ for (const throttleMs of throttleTimes) {
241
+ for (const calls of callCounts) {
242
+ permutations.push([strategy, debounceMs, throttleMs, calls]);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ it.each(permutations)(
249
+ 'Strategy: %s, Debounce: %s, Throttle: %s, Calls: %d',
250
+ async (strategy, debounceMs, throttleMs, calls) => {
251
+ const executed: number[] = [];
252
+ const action = (id: number) =>
253
+ Effect.sync(() => {
254
+ executed.push(id);
255
+ });
256
+
257
+ const wrapped = useConcurrency(action, {
258
+ strategy,
259
+ debounceMs,
260
+ throttleMs,
261
+ });
262
+
263
+ for (let i = 0; i < calls; i++) {
264
+ wrapped(i);
265
+ await vi.advanceTimersByTimeAsync(1);
266
+ }
267
+
268
+ await vi.runAllTimersAsync();
269
+ await Promise.resolve();
270
+
271
+ expect(Array.isArray(executed)).toBe(true);
272
+ expect(executed.length).toBeLessThanOrEqual(calls);
273
+ }
274
+ );
275
+ });
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { Effect, Duration, Schedule } from 'effect';
26
26
  import type { Store } from '../core/types.js';
27
- import { createCancellationToken } from './cancellation.js';
27
+
28
28
  import { runWithAbortSignal } from './cancellation.js';
29
29
  import {
30
30
  DEFAULT_RETRY_INITIAL_DELAY_MS,
@@ -33,7 +33,6 @@ import {
33
33
  } from '../config/constants.js';
34
34
  import {
35
35
  ActionNotFoundError,
36
- CancellationError,
37
36
  TimeoutError,
38
37
  } from '../errors.js';
39
38
 
@@ -195,133 +194,7 @@ export const withRetry = <A extends unknown[], R>(
195
194
  };
196
195
  };
197
196
 
198
- export const takeLatest = <A extends unknown[], R>(
199
- fn: ActionFn<A, R>
200
- ): CancellableAction<A, R> => {
201
- let pending = false;
202
- let currentToken = createCancellationToken();
203
- let callId = 0;
204
-
205
- const action = async (...args: A): Promise<R> => {
206
- currentToken.cancel();
207
- currentToken = createCancellationToken();
208
- const myToken = currentToken;
209
- const myCallId = ++callId;
210
-
211
- pending = true;
212
-
213
- try {
214
- const result = await fn(...args);
215
-
216
- if (myToken.isCancelled || myCallId !== callId) {
217
- throw new CancellationError({ message: 'Superseded by newer call' });
218
- }
219
-
220
- return result;
221
- } finally {
222
- if (myCallId === callId) {
223
- pending = false;
224
- }
225
- }
226
- };
227
-
228
- Object.defineProperty(action, 'pending', {
229
- get: () => pending,
230
- enumerable: true,
231
- });
232
-
233
- Object.defineProperty(action, 'cancel', {
234
- value: () => {
235
- currentToken.cancel();
236
- pending = false;
237
- },
238
- enumerable: true,
239
- });
240
-
241
- return action as CancellableAction<A, R>;
242
- };
243
-
244
- export const takeFirst = <A extends unknown[], R>(
245
- fn: ActionFn<A, R>
246
- ): AsyncAction<A, R | undefined> => {
247
- let pending = false;
248
-
249
- const action = async (...args: A): Promise<R | undefined> => {
250
- if (pending) {
251
- return undefined;
252
- }
253
197
 
254
- pending = true;
255
- try {
256
- return await fn(...args);
257
- } finally {
258
- pending = false;
259
- }
260
- };
261
-
262
- Object.defineProperty(action, 'pending', {
263
- get: () => pending,
264
- enumerable: true,
265
- });
266
-
267
- return action as AsyncAction<A, R | undefined>;
268
- };
269
-
270
- export const debounceAction = <A extends unknown[], R>(
271
- fn: ActionFn<A, R>,
272
- delayMs: number
273
- ): ((...args: A) => Promise<R>) => {
274
- let timeout: ReturnType<typeof setTimeout> | null = null;
275
- let currentToken = createCancellationToken();
276
-
277
- return (...args: A): Promise<R> => {
278
- if (timeout) {
279
- clearTimeout(timeout);
280
- currentToken.cancel();
281
- }
282
-
283
- currentToken = createCancellationToken();
284
- const myToken = currentToken;
285
-
286
- return new Promise((resolve, reject) => {
287
- timeout = setTimeout(() => {
288
- if (myToken.isCancelled) return;
289
-
290
- Promise.resolve(fn(...args))
291
- .then((result) => {
292
- if (!myToken.isCancelled) resolve(result);
293
- })
294
- .catch((error: unknown) => {
295
- if (!myToken.isCancelled) reject(error as Error);
296
- });
297
- }, delayMs);
298
- });
299
- };
300
- };
301
-
302
- export const throttleAction = <A extends unknown[], R>(
303
- fn: ActionFn<A, R>,
304
- intervalMs: number
305
- ): ((...args: A) => Promise<R | undefined>) => {
306
- let lastCallTime = 0;
307
- let pending = false;
308
-
309
- return async (...args: A): Promise<R | undefined> => {
310
- const now = Date.now();
311
- if (now - lastCallTime < intervalMs || pending) {
312
- return undefined;
313
- }
314
-
315
- lastCallTime = now;
316
- pending = true;
317
-
318
- try {
319
- return await fn(...args);
320
- } finally {
321
- pending = false;
322
- }
323
- };
324
- };
325
198
 
326
199
  export const dispatch = <T>(
327
200
  store: Store<T>,
@@ -30,10 +30,7 @@ export {
30
30
  withTimeout,
31
31
  withRetry,
32
32
  withAbortSignal,
33
- takeLatest,
34
- takeFirst,
35
- debounceAction,
36
- throttleAction,
33
+
37
34
  type ActionResult,
38
35
  type AsyncAction,
39
36
  type CancellableAction,
@@ -46,3 +43,9 @@ export {
46
43
  type CancellationToken,
47
44
  type CancellationScope,
48
45
  } from './cancellation.js';
46
+
47
+ export {
48
+ useConcurrency,
49
+ type ConcurrencyStrategy,
50
+ type ConcurrencyOptions,
51
+ } from './useConcurrency.js';
@@ -0,0 +1,137 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2025 Chris M. Perez
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { Effect, Fiber, Queue } from 'effect';
26
+
27
+ export type ConcurrencyStrategy = 'switch' | 'exhaust' | 'merge' | 'concat';
28
+
29
+ export interface ConcurrencyOptions {
30
+ strategy?: ConcurrencyStrategy;
31
+ debounceMs?: number;
32
+ throttleMs?: number;
33
+ }
34
+
35
+ export function useConcurrency<A extends unknown[], R, E = never>(
36
+ action: (...args: A) => Effect.Effect<R, E>,
37
+ options: ConcurrencyOptions = {}
38
+ ): (...args: A) => void {
39
+ const { strategy = 'switch', debounceMs, throttleMs } = options;
40
+
41
+ let runningFiber: Fiber.RuntimeFiber<unknown, unknown> | null = null;
42
+ let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
43
+ let lastCallTime = 0;
44
+
45
+ // Dedicated unbounded queue for sequential 'concat' strategy
46
+ const queue =
47
+ strategy === 'concat' ? Effect.runSync(Queue.unbounded<A>()) : null;
48
+
49
+ if (queue) {
50
+ // Spin up a background worker to consume the queue sequentially
51
+ Effect.runFork(
52
+ Effect.forever(
53
+ Effect.gen(function* () {
54
+ const args = yield* Queue.take(queue);
55
+ // Catch all errors so the worker doesn't die on failure
56
+ yield* Effect.catchAll(action(...args), () => Effect.void);
57
+ })
58
+ )
59
+ );
60
+ }
61
+
62
+ const execute = (...args: A) => {
63
+ switch (strategy) {
64
+ case 'switch':
65
+ if (runningFiber) {
66
+ Effect.runFork(Fiber.interrupt(runningFiber));
67
+ }
68
+ runningFiber = Effect.runFork(
69
+ Effect.match(action(...args), {
70
+ onFailure: () => {
71
+ runningFiber = null;
72
+ },
73
+ onSuccess: () => {
74
+ runningFiber = null;
75
+ },
76
+ })
77
+ );
78
+ break;
79
+
80
+ case 'exhaust':
81
+ if (runningFiber) {
82
+ return; // Ignore execution if already running
83
+ }
84
+ runningFiber = Effect.runFork(
85
+ Effect.match(action(...args), {
86
+ onFailure: () => {
87
+ runningFiber = null;
88
+ },
89
+ onSuccess: () => {
90
+ runningFiber = null;
91
+ },
92
+ })
93
+ );
94
+ break;
95
+
96
+ case 'merge':
97
+ // Unbounded concurrency: fire and forget
98
+ Effect.runFork(
99
+ Effect.catchAll(action(...args), () => Effect.void)
100
+ );
101
+ break;
102
+
103
+ case 'concat':
104
+ // Sequential execution via the background queue
105
+ if (queue) {
106
+ Effect.runFork(Queue.offer(queue, args));
107
+ }
108
+ break;
109
+ }
110
+ };
111
+
112
+ return (...args: A) => {
113
+ const now = Date.now();
114
+
115
+ // 1. Evaluate Throttle
116
+ if (throttleMs !== undefined && throttleMs > 0) {
117
+ if (now - lastCallTime < throttleMs) {
118
+ return;
119
+ }
120
+ lastCallTime = now;
121
+ }
122
+
123
+ // 2. Evaluate Debounce
124
+ if (debounceMs !== undefined && debounceMs > 0) {
125
+ if (debounceTimeout) {
126
+ clearTimeout(debounceTimeout);
127
+ }
128
+ debounceTimeout = setTimeout(() => {
129
+ execute(...args);
130
+ }, debounceMs);
131
+ return;
132
+ }
133
+
134
+ // 3. Fallthrough immediate execution
135
+ execute(...args);
136
+ };
137
+ }
package/src/index.ts CHANGED
@@ -75,10 +75,6 @@ export {
75
75
  withTimeout,
76
76
  withRetry,
77
77
  withAbortSignal,
78
- takeLatest,
79
- takeFirst,
80
- debounceAction,
81
- throttleAction,
82
78
  createCancellationToken,
83
79
  createCancellationScope,
84
80
  type ActionResult,
@@ -87,6 +83,9 @@ export {
87
83
  type RetryConfig,
88
84
  type CancellationToken,
89
85
  type CancellationScope,
86
+ useConcurrency,
87
+ type ConcurrencyStrategy,
88
+ type ConcurrencyOptions,
90
89
  } from './actions/index.js';
91
90
 
92
91
  export {
@@ -154,7 +154,7 @@ export const hydrateStores = (serialized: string): Effect.Effect<void> =>
154
154
  );
155
155
  }
156
156
  },
157
- catch: () => new HydrationError({}),
157
+ catch: () => new HydrationError(),
158
158
  }).pipe(Effect.catchAll(() => Effect.void));
159
159
 
160
160
  // Hydrate stores synchronously