@effuse/store 1.0.5 → 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/dist/index.cjs +82 -98
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -11
- package/dist/index.d.ts +39 -11
- package/dist/index.js +83 -96
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/__tests__/actions/useConcurrency.test.ts +275 -0
- package/src/actions/async.ts +1 -128
- package/src/actions/index.ts +7 -4
- package/src/actions/useConcurrency.ts +137 -0
- package/src/index.ts +3 -4
- package/src/reactivity/derived.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effuse/store",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
52
|
+
"@effuse/core": "1.2.2"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"effect": "^3.
|
|
55
|
+
"effect": "^3.20.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"@effect/eslint-plugin": "^0.3.2",
|
|
58
|
-
"@types/node": "^25.
|
|
59
|
-
"@effuse/core": "1.2.
|
|
60
|
-
"eslint": "^10.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
|
|
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
|
+
});
|
package/src/actions/async.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { Effect, Duration, Schedule } from 'effect';
|
|
26
26
|
import type { Store } from '../core/types.js';
|
|
27
|
-
|
|
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>,
|
package/src/actions/index.ts
CHANGED
|
@@ -30,10 +30,7 @@ export {
|
|
|
30
30
|
withTimeout,
|
|
31
31
|
withRetry,
|
|
32
32
|
withAbortSignal,
|
|
33
|
-
|
|
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
|