@cplieger/actions 1.0.1 → 1.0.2

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.
@@ -1,176 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- vi.mock("./notifier.js", () => ({
4
- configure: vi.fn(),
5
- notifySuccess: vi.fn(),
6
- notifyError: vi.fn(),
7
- _resetNotifierForTest: vi.fn(),
8
- }));
9
- import { defineAction, _resetForTest as resetDefine } from "./define.js";
10
- import { _resetForTest as resetRegistry, recentLog } from "./registry.js";
11
- import { _resetForTest as resetCleanup } from "./cleanup.js";
12
- import { ActionError, retryNetwork } from "./error.js";
13
-
14
- beforeEach(() => {
15
- resetDefine();
16
- resetRegistry();
17
- resetCleanup();
18
- vi.clearAllMocks();
19
- });
20
-
21
- describe("two retry-configured actions in the same scope", () => {
22
- beforeEach(() => {
23
- vi.useFakeTimers();
24
- });
25
- afterEach(() => {
26
- vi.useRealTimers();
27
- });
28
-
29
- it("second action waits for first action's retries to complete before starting", async () => {
30
- let attemptA = 0;
31
- let attemptB = 0;
32
- const actionA = defineAction<void, string>({
33
- name: "test.scope_retry_A",
34
- scope: "shared",
35
- retryable: retryNetwork,
36
- retry: { count: 2, delay: 100 },
37
- error: false,
38
- run: () => {
39
- attemptA++;
40
- if (attemptA < 3) {
41
- throw new ActionError("net", { code: "network" });
42
- }
43
- return Promise.resolve("A-done");
44
- },
45
- });
46
- const actionB = defineAction<void, string>({
47
- name: "test.scope_retry_B",
48
- scope: "shared",
49
- retryable: retryNetwork,
50
- retry: { count: 1, delay: 50 },
51
- error: false,
52
- run: () => {
53
- attemptB++;
54
- return Promise.resolve("B-done");
55
- },
56
- });
57
- const pA = actionA.dispatch();
58
- await Promise.resolve();
59
- const pB = actionB.dispatch();
60
- await Promise.resolve();
61
- expect(attemptA).toBe(1);
62
- expect(attemptB).toBe(0);
63
- await vi.advanceTimersByTimeAsync(100);
64
- expect(attemptA).toBe(2);
65
- expect(attemptB).toBe(0);
66
- await vi.advanceTimersByTimeAsync(200);
67
- const rA = await pA;
68
- expect(rA).toBe("A-done");
69
- await Promise.resolve();
70
- const rB = await pB;
71
- expect(rB).toBe("B-done");
72
- expect(attemptB).toBe(1);
73
- });
74
- });
75
-
76
- describe("onSuccess → dispatch chain with same scope", () => {
77
- it("chained dispatch via onSuccess runs after the triggering action completes", async () => {
78
- const order: string[] = [];
79
- const actionA = defineAction<void, string>({
80
- name: "test.chain_A",
81
- scope: "chain",
82
- run: () => {
83
- order.push("A-run");
84
- return Promise.resolve("A-result");
85
- },
86
- });
87
- const actionB = defineAction<void, string>({
88
- name: "test.chain_B",
89
- scope: "chain",
90
- run: () => {
91
- order.push("B-run");
92
- return Promise.resolve("B-result");
93
- },
94
- });
95
- let chainedPromise: Promise<string | null> | null = null;
96
- const pA = actionA.dispatch(undefined, {
97
- onSuccess: () => {
98
- order.push("A-onSuccess");
99
- chainedPromise = actionB.dispatch();
100
- },
101
- });
102
- const rA = await pA;
103
- expect(rA).toBe("A-result");
104
- const rB = await chainedPromise!;
105
- expect(rB).toBe("B-result");
106
- expect(order).toEqual(["A-run", "A-onSuccess", "B-run"]);
107
- });
108
- });
109
-
110
- describe("cancellation during retry unblocks queued action", () => {
111
- beforeEach(() => {
112
- vi.useFakeTimers();
113
- });
114
- afterEach(() => {
115
- vi.useRealTimers();
116
- });
117
-
118
- it("cancelling a retrying action lets the queued action proceed", async () => {
119
- let attemptA = 0;
120
- let attemptB = 0;
121
- const actionA = defineAction<void, string>({
122
- name: "test.cancel_retry_A",
123
- scope: "cancel-scope",
124
- retryable: (err) => err.code !== "cancelled",
125
- retry: { count: 5, delay: 100 },
126
- error: false,
127
- run: () => {
128
- attemptA++;
129
- throw new ActionError("fail", { status: 500 });
130
- },
131
- });
132
- const actionB = defineAction<void, string>({
133
- name: "test.cancel_retry_B",
134
- scope: "cancel-scope",
135
- error: false,
136
- run: () => {
137
- attemptB++;
138
- return Promise.resolve("B-done");
139
- },
140
- });
141
- const pA = actionA.dispatch();
142
- await Promise.resolve();
143
- const pB = actionB.dispatch();
144
- await Promise.resolve();
145
- expect(attemptA).toBe(1);
146
- expect(attemptB).toBe(0);
147
- actionA.cancel();
148
- const rA = await pA;
149
- expect(rA).toBeNull();
150
- await Promise.resolve();
151
- const rB = await pB;
152
- expect(rB).toBe("B-done");
153
- expect(attemptB).toBe(1);
154
- });
155
- });
156
-
157
- describe("throwing callbacks don't break scope chain", () => {
158
- it("onSuccess throwing does not re-record action as error", async () => {
159
- const action = defineAction<void, string>({
160
- name: "test.cb_throw_success",
161
- scope: "cb-scope",
162
- run: () => Promise.resolve("ok"),
163
- });
164
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
165
- const result = await action.dispatch(undefined, {
166
- onSuccess: () => {
167
- throw new Error("callback boom");
168
- },
169
- });
170
- consoleSpy.mockRestore();
171
- expect(result).toBe("ok");
172
- const log = recentLog();
173
- const entry = log.find((e) => e.name === "test.cb_throw_success");
174
- expect(entry?.status).toBe("success");
175
- });
176
- });
@@ -1,231 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- vi.mock("./notifier.js", () => ({
4
- configure: vi.fn(),
5
- notifySuccess: vi.fn(),
6
- notifyError: vi.fn(),
7
- _resetNotifierForTest: vi.fn(),
8
- }));
9
- import { defineAction, _resetForTest as resetDefine, _internalsForTest } from "./define.js";
10
- import { _resetForTest as resetRegistry } from "./registry.js";
11
- import { _resetForTest as resetCleanup } from "./cleanup.js";
12
- import { ActionError, retryNetwork } from "./error.js";
13
-
14
- beforeEach(() => {
15
- resetDefine();
16
- resetRegistry();
17
- resetCleanup();
18
- vi.clearAllMocks();
19
- });
20
-
21
- describe("dedupe + scope combined behavior", () => {
22
- it("deduped dispatch shares the scope-queued promise without double-queuing", async () => {
23
- let runCount = 0;
24
- let resolveRun: ((v: string) => void) | null = null;
25
- const action = defineAction<string, string>({
26
- name: "test.dedupe_scope",
27
- dedupe: true,
28
- scope: "ds",
29
- run: () => {
30
- runCount++;
31
- return new Promise<string>((r) => {
32
- resolveRun = r;
33
- });
34
- },
35
- });
36
- const p1 = action.dispatch("x");
37
- const p2 = action.dispatch("x");
38
- await Promise.resolve();
39
- expect(runCount).toBe(1);
40
- resolveRun!("done");
41
- const [r1, r2] = await Promise.all([p1, p2]);
42
- expect(r1).toBe("done");
43
- expect(r2).toBe("done");
44
- });
45
- });
46
-
47
- describe("optimistic persistence across retries", () => {
48
- beforeEach(() => {
49
- vi.useFakeTimers();
50
- });
51
- afterEach(() => {
52
- vi.useRealTimers();
53
- });
54
-
55
- it("rollback fires exactly once after all retries exhaust", async () => {
56
- const rollbackCalls: string[] = [];
57
- let attempt = 0;
58
- const action = defineAction<string, string, string>({
59
- name: "test.opt_retry_rollback",
60
- retryable: (err) => err.code !== "cancelled",
61
- retry: { count: 2, delay: 50 },
62
- error: false,
63
- optimistic: (args) => `snap-${args}`,
64
- rollback: (_args, op) => {
65
- rollbackCalls.push(op ?? "none");
66
- },
67
- run: () => {
68
- attempt++;
69
- throw new ActionError("fail", { status: 500 });
70
- },
71
- });
72
- const p = action.dispatch("x");
73
- await vi.advanceTimersByTimeAsync(50);
74
- await vi.advanceTimersByTimeAsync(100);
75
- await p;
76
- expect(attempt).toBe(3);
77
- expect(rollbackCalls).toEqual(["snap-x"]);
78
- });
79
-
80
- it("rollback does NOT fire when retries eventually succeed", async () => {
81
- const rollbackCalls: string[] = [];
82
- let attempt = 0;
83
- const action = defineAction<string, string, string>({
84
- name: "test.opt_retry_success",
85
- retryable: retryNetwork,
86
- retry: { count: 2, delay: 30 },
87
- optimistic: (args) => `snap-${args}`,
88
- rollback: (_args, op) => {
89
- rollbackCalls.push(op ?? "none");
90
- },
91
- run: () => {
92
- attempt++;
93
- if (attempt < 3) {
94
- throw new ActionError("net", { code: "network" });
95
- }
96
- return Promise.resolve("recovered");
97
- },
98
- });
99
- const p = action.dispatch("y");
100
- await vi.advanceTimersByTimeAsync(30);
101
- await vi.advanceTimersByTimeAsync(60);
102
- const result = await p;
103
- expect(result).toBe("recovered");
104
- expect(rollbackCalls).toEqual([]);
105
- });
106
- });
107
-
108
- describe("onSettled re-dispatch with dedupe", () => {
109
- it("dispatch from onSettled with same dedupe key starts a fresh run", async () => {
110
- let runCount = 0;
111
- const action = defineAction<string, string>({
112
- name: "test.settled_redispatch",
113
- dedupe: true,
114
- run: () => {
115
- runCount++;
116
- return Promise.resolve(`run-${String(runCount)}`);
117
- },
118
- });
119
- let chainedPromise: Promise<string | null> | null = null;
120
- await action.dispatch("k", {
121
- onSettled: () => {
122
- chainedPromise = action.dispatch("k");
123
- },
124
- });
125
- const result = await chainedPromise!;
126
- expect(runCount).toBe(2);
127
- expect(result).toBe("run-2");
128
- });
129
- });
130
-
131
- describe("scope chain after optimistic throw", () => {
132
- it("next action in scope runs after optimistic throw", async () => {
133
- const order: string[] = [];
134
- const broken = defineAction<void, string>({
135
- name: "test.opt_throw",
136
- scope: "opt-throw",
137
- error: false,
138
- optimistic: () => {
139
- throw new Error("optimistic boom");
140
- },
141
- run: () => Promise.resolve("never"),
142
- });
143
- const follower = defineAction<void, string>({
144
- name: "test.opt_throw_follower",
145
- scope: "opt-throw",
146
- run: () => {
147
- order.push("follower-run");
148
- return Promise.resolve("ok");
149
- },
150
- });
151
- const pBroken = broken.dispatch();
152
- const pFollower = follower.dispatch();
153
- const rBroken = await pBroken;
154
- expect(rBroken).toBeNull();
155
- const rFollower = await pFollower;
156
- expect(rFollower).toBe("ok");
157
- expect(order).toEqual(["follower-run"]);
158
- });
159
- });
160
-
161
- describe("rapid cancel + re-dispatch", () => {
162
- it("re-dispatch after cancel uses a fresh AbortController", async () => {
163
- const signalAborted: boolean[] = [];
164
- const action = defineAction<void, string>({
165
- name: "test.rapid_cancel",
166
- error: false,
167
- run: (_args, signal) => {
168
- signalAborted.push(signal.aborted);
169
- if (signal.aborted) {
170
- throw new DOMException("aborted", "AbortError");
171
- }
172
- return Promise.resolve("ok");
173
- },
174
- });
175
- const p1 = action.dispatch();
176
- action.cancel();
177
- await p1;
178
- const p2 = action.dispatch();
179
- const r2 = await p2;
180
- expect(r2).toBe("ok");
181
- expect(signalAborted[1]).toBe(false);
182
- });
183
- });
184
-
185
- describe("idempotency key stability across retries", () => {
186
- beforeEach(() => {
187
- vi.useFakeTimers();
188
- });
189
- afterEach(() => {
190
- vi.useRealTimers();
191
- });
192
-
193
- it("same idempotency key is passed to all retry attempts", async () => {
194
- const keys: (string | undefined)[] = [];
195
- const action = defineAction<void, string>({
196
- name: "test.idem_retry",
197
- idempotencyKey: true,
198
- retryable: (err) => err.code !== "cancelled",
199
- retry: { count: 2, delay: 20 },
200
- error: false,
201
- run: (_args, _signal, ctx) => {
202
- keys.push(ctx?.idempotencyKey);
203
- throw new ActionError("fail", { status: 500 });
204
- },
205
- });
206
- const p = action.dispatch();
207
- await vi.advanceTimersByTimeAsync(20);
208
- await vi.advanceTimersByTimeAsync(40);
209
- await p;
210
- expect(keys.length).toBe(3);
211
- expect(keys[0]).toBeDefined();
212
- expect(keys[0]).toBe(keys[1]);
213
- expect(keys[1]).toBe(keys[2]);
214
- });
215
-
216
- it("different dispatches get different idempotency keys", async () => {
217
- const keys: (string | undefined)[] = [];
218
- const action = defineAction<void, string>({
219
- name: "test.idem_unique",
220
- idempotencyKey: true,
221
- run: (_args, _signal, ctx) => {
222
- keys.push(ctx?.idempotencyKey);
223
- return Promise.resolve("ok");
224
- },
225
- });
226
- await action.dispatch();
227
- await action.dispatch();
228
- expect(keys.length).toBe(2);
229
- expect(keys[0]).not.toBe(keys[1]);
230
- });
231
- });
@@ -1,213 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- vi.mock("./notifier.js", () => ({
4
- configure: vi.fn(),
5
- notifySuccess: vi.fn(),
6
- notifyError: vi.fn(),
7
- _resetNotifierForTest: vi.fn(),
8
- }));
9
- import { defineAction, _resetForTest as resetDefine, _internalsForTest } from "./define.js";
10
- import { _resetForTest as resetRegistry } from "./registry.js";
11
- import { _resetForTest as resetCleanup } from "./cleanup.js";
12
- import { ActionError } from "./error.js";
13
-
14
- beforeEach(() => {
15
- resetDefine();
16
- resetRegistry();
17
- resetCleanup();
18
- vi.clearAllMocks();
19
- });
20
-
21
- describe("dedupe + retry interaction", () => {
22
- beforeEach(() => {
23
- vi.useFakeTimers();
24
- });
25
- afterEach(() => {
26
- vi.useRealTimers();
27
- });
28
-
29
- it("deduped caller's onError fires with the real error after retries exhaust", async () => {
30
- let attempt = 0;
31
- const action = defineAction<string, string>({
32
- name: "test.dedupe_retry",
33
- dedupe: true,
34
- retryable: (err) => err.code !== "cancelled",
35
- retry: { count: 2, delay: 50 },
36
- error: false,
37
- run: () => {
38
- attempt++;
39
- throw new ActionError("server down", { status: 503 });
40
- },
41
- });
42
- const onError1 = vi.fn();
43
- const onError2 = vi.fn();
44
- const p1 = action.dispatch("x", { onError: onError1 });
45
- const p2 = action.dispatch("x", { onError: onError2 });
46
- await vi.advanceTimersByTimeAsync(50);
47
- await vi.advanceTimersByTimeAsync(100);
48
- const [r1, r2] = await Promise.all([p1, p2]);
49
- expect(r1).toBeNull();
50
- expect(r2).toBeNull();
51
- expect(attempt).toBe(3);
52
- expect(onError1).toHaveBeenCalledWith(
53
- expect.objectContaining({ message: "server down", status: 503 }),
54
- "x",
55
- );
56
- expect(onError2).toHaveBeenCalledWith(
57
- expect.objectContaining({ message: "server down", status: 503 }),
58
- "x",
59
- );
60
- });
61
-
62
- it("dedupe map is cleaned up after retry exhaustion", async () => {
63
- const action = defineAction<string, string>({
64
- name: "test.dedupe_cleanup",
65
- dedupe: true,
66
- retryable: (err) => err.code !== "cancelled",
67
- retry: { count: 1, delay: 20 },
68
- error: false,
69
- run: () => {
70
- throw new ActionError("fail", { status: 500 });
71
- },
72
- });
73
- const p = action.dispatch("z");
74
- await vi.advanceTimersByTimeAsync(20);
75
- await p;
76
- const { activeDedupes } = _internalsForTest();
77
- expect(activeDedupes).toBe(0);
78
- });
79
- });
80
-
81
- describe("cancel during scope-queued wait", () => {
82
- it("cancelling a queued action that hasn't started lets subsequent actions proceed", async () => {
83
- let resolveOccupant: (() => void) | null = null;
84
- const order: string[] = [];
85
- const occupant = defineAction<void, string>({
86
- name: "test.queue_cancel_occ",
87
- scope: "q-cancel",
88
- run: () =>
89
- new Promise<string>((r) => {
90
- resolveOccupant = () => {
91
- r("occ");
92
- };
93
- }),
94
- });
95
- const victim = defineAction<void, string>({
96
- name: "test.queue_cancel_victim",
97
- scope: "q-cancel",
98
- error: false,
99
- run: () => {
100
- order.push("victim-run");
101
- return Promise.resolve("victim");
102
- },
103
- });
104
- const follower = defineAction<void, string>({
105
- name: "test.queue_cancel_follower",
106
- scope: "q-cancel",
107
- run: () => {
108
- order.push("follower-run");
109
- return Promise.resolve("follower");
110
- },
111
- });
112
- const pOcc = occupant.dispatch();
113
- await Promise.resolve();
114
- const pVictim = victim.dispatch();
115
- const pFollower = follower.dispatch();
116
- await Promise.resolve();
117
- victim.cancel();
118
- resolveOccupant!();
119
- await pOcc;
120
- const rVictim = await pVictim;
121
- expect(rVictim).toBeNull();
122
- expect(order).not.toContain("victim-run");
123
- const rFollower = await pFollower;
124
- expect(rFollower).toBe("follower");
125
- expect(order).toContain("follower-run");
126
- });
127
- });
128
-
129
- describe("dedupe + cancel interaction", () => {
130
- it("cancelling the original dispatch propagates cancellation to deduped caller", async () => {
131
- const action = defineAction<string, string>({
132
- name: "test.dedupe_cancel",
133
- dedupe: true,
134
- error: false,
135
- run: (_args, signal) =>
136
- new Promise<string>((_, reject) => {
137
- signal.addEventListener("abort", () => {
138
- reject(new DOMException("aborted", "AbortError"));
139
- });
140
- }),
141
- });
142
- const onSettled1 = vi.fn();
143
- const onSettled2 = vi.fn();
144
- const p1 = action.dispatch("a", { onSettled: onSettled1 });
145
- const p2 = action.dispatch("a", { onSettled: onSettled2 });
146
- action.cancel();
147
- const [r1, r2] = await Promise.all([p1, p2]);
148
- expect(r1).toBeNull();
149
- expect(r2).toBeNull();
150
- expect(onSettled1).toHaveBeenCalledTimes(1);
151
- expect(onSettled2).toHaveBeenCalledTimes(1);
152
- });
153
-
154
- it("dedupe map is cleaned up after cancellation", async () => {
155
- const action = defineAction<string, string>({
156
- name: "test.dedupe_cancel_cleanup",
157
- dedupe: true,
158
- error: false,
159
- run: (_args, signal) =>
160
- new Promise<string>((_, reject) => {
161
- signal.addEventListener("abort", () => {
162
- reject(new DOMException("aborted", "AbortError"));
163
- });
164
- }),
165
- });
166
- const p = action.dispatch("b");
167
- action.cancel();
168
- await p;
169
- const { activeDedupes } = _internalsForTest();
170
- expect(activeDedupes).toBe(0);
171
- });
172
- });
173
-
174
- describe("interleaved cross-action scope chain ordering", () => {
175
- it("A-B-A-B dispatches serialize in dispatch order", async () => {
176
- const order: string[] = [];
177
- const actionA = defineAction<number, string>({
178
- name: "test.interleave_A",
179
- scope: "interleave",
180
- run: (n) => {
181
- order.push(`A-${String(n)}`);
182
- return Promise.resolve(`A-${String(n)}`);
183
- },
184
- });
185
- const actionB = defineAction<number, string>({
186
- name: "test.interleave_B",
187
- scope: "interleave",
188
- run: (n) => {
189
- order.push(`B-${String(n)}`);
190
- return Promise.resolve(`B-${String(n)}`);
191
- },
192
- });
193
- const p1 = actionA.dispatch(1);
194
- const p2 = actionB.dispatch(1);
195
- const p3 = actionA.dispatch(2);
196
- const p4 = actionB.dispatch(2);
197
- await Promise.all([p1, p2, p3, p4]);
198
- expect(order).toEqual(["A-1", "B-1", "A-2", "B-2"]);
199
- });
200
-
201
- it("scope chain drains completely — no leaked promises", async () => {
202
- const action = defineAction<number, number>({
203
- name: "test.drain",
204
- scope: "drain",
205
- run: (n) => Promise.resolve(n),
206
- });
207
- await action.dispatch(1);
208
- await action.dispatch(2);
209
- await action.dispatch(3);
210
- const { scopeChains } = _internalsForTest();
211
- expect(scopeChains).toBe(0);
212
- });
213
- });