@cplieger/actions 1.0.0
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/.editorconfig +19 -0
- package/.htmlvalidate.json +28 -0
- package/.stylelintrc.json +67 -0
- package/LICENSE +692 -0
- package/README.md +176 -0
- package/dist/src/api.d.ts +56 -0
- package/dist/src/api.js +158 -0
- package/dist/src/cleanup.d.ts +11 -0
- package/dist/src/cleanup.js +63 -0
- package/dist/src/debounce.d.ts +28 -0
- package/dist/src/debounce.js +91 -0
- package/dist/src/define-helpers.d.ts +20 -0
- package/dist/src/define-helpers.js +83 -0
- package/dist/src/define.d.ts +14 -0
- package/dist/src/define.js +627 -0
- package/dist/src/error.d.ts +42 -0
- package/dist/src/error.js +174 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +22 -0
- package/dist/src/loading.d.ts +15 -0
- package/dist/src/loading.js +114 -0
- package/dist/src/notifier.d.ts +21 -0
- package/dist/src/notifier.js +21 -0
- package/dist/src/poll.d.ts +23 -0
- package/dist/src/poll.js +120 -0
- package/dist/src/registry.d.ts +18 -0
- package/dist/src/registry.js +210 -0
- package/dist/src/retry.d.ts +9 -0
- package/dist/src/retry.js +78 -0
- package/dist/src/transport.d.ts +46 -0
- package/dist/src/transport.js +70 -0
- package/dist/src/types.d.ts +157 -0
- package/dist/src/types.js +7 -0
- package/eslint.config.base.mjs +186 -0
- package/jsr.json +22 -0
- package/package.json +41 -0
- package/src/api.ts +209 -0
- package/src/cleanup.ts +76 -0
- package/src/debounce.ts +128 -0
- package/src/define-helpers.ts +97 -0
- package/src/define.ts +699 -0
- package/src/error.ts +184 -0
- package/src/index.ts +60 -0
- package/src/loading.ts +149 -0
- package/src/notifier.ts +41 -0
- package/src/poll.ts +150 -0
- package/src/registry.ts +225 -0
- package/src/retry.ts +80 -0
- package/src/transport.ts +112 -0
- package/src/types.ts +198 -0
package/src/define.ts
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
// defineAction: the lifecycle runner. Takes an ActionDefinition,
|
|
2
|
+
// returns an Action whose dispatch() executes the full lifecycle.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { notifyError, notifySuccess } from "./notifier.js";
|
|
6
|
+
import { toActionError } from "./error.js";
|
|
7
|
+
import { record } from "./registry.js";
|
|
8
|
+
import { sleep, waitForOnline, attachAttempts, readAttempts } from "./retry.js";
|
|
9
|
+
import { _registerAction } from "./cleanup.js";
|
|
10
|
+
import {
|
|
11
|
+
safeInvoke,
|
|
12
|
+
safeStringify,
|
|
13
|
+
_symbolMap,
|
|
14
|
+
_resetSymbols,
|
|
15
|
+
resolveNotification,
|
|
16
|
+
defaultErrorPrefix,
|
|
17
|
+
} from "./define-helpers.js";
|
|
18
|
+
import type {
|
|
19
|
+
Action,
|
|
20
|
+
ActionContext,
|
|
21
|
+
ActionDefinition,
|
|
22
|
+
ActionErrorLike,
|
|
23
|
+
DispatchHandle,
|
|
24
|
+
DispatchOptions,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
|
|
27
|
+
let instanceCounter = 0;
|
|
28
|
+
|
|
29
|
+
const NO_OPTS = Object.freeze({}) as DispatchOptions;
|
|
30
|
+
const NOOP = (): void => {
|
|
31
|
+
/* noop */
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Create the appropriate DOMException for an aborted signal, preserving
|
|
35
|
+
* TimeoutError when the signal was aborted by AbortSignal.timeout(). */
|
|
36
|
+
function signalAbortError(signal: AbortSignal): DOMException {
|
|
37
|
+
if (signal.reason instanceof DOMException && signal.reason.name === "TimeoutError") {
|
|
38
|
+
return signal.reason;
|
|
39
|
+
}
|
|
40
|
+
return new DOMException("aborted", "AbortError");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nextInstanceID(name: string): string {
|
|
44
|
+
instanceCounter += 1;
|
|
45
|
+
return `${name}#${String(instanceCounter)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Header name used by apiAction when an idempotency key is generated. */
|
|
49
|
+
export const IDEMPOTENCY_HEADER = "Idempotency-Key";
|
|
50
|
+
|
|
51
|
+
function generateIdempotencyKey(): string {
|
|
52
|
+
const ts = Date.now().toString(36);
|
|
53
|
+
const rnd = Math.random().toString(36).slice(2, 16).padEnd(14, "0");
|
|
54
|
+
return `${ts}-${rnd}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const scopeChains = new Map<string, Promise<unknown>>();
|
|
58
|
+
|
|
59
|
+
/** Create a DispatchHandle: a Promise augmented with abort(). */
|
|
60
|
+
function makeHandle<T>(promise: Promise<T | null>, abortFn: () => void): DispatchHandle<T> {
|
|
61
|
+
const handle = promise as DispatchHandle<T>;
|
|
62
|
+
handle.abort = abortFn;
|
|
63
|
+
return handle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface DedupeSlot {
|
|
67
|
+
promise: Promise<unknown> | undefined;
|
|
68
|
+
error?: ActionErrorLike;
|
|
69
|
+
cancelled?: boolean;
|
|
70
|
+
}
|
|
71
|
+
const activeDedupes = new Map<string, DedupeSlot>();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create an action from a declarative definition.
|
|
75
|
+
*/
|
|
76
|
+
export function defineAction<TArgs, TResult, TOp = unknown>(
|
|
77
|
+
def: ActionDefinition<TArgs, TResult, TOp>,
|
|
78
|
+
): Action<TArgs, TResult> {
|
|
79
|
+
const inFlight = new Map<string, AbortController>();
|
|
80
|
+
const started = new Set<string>();
|
|
81
|
+
const scopeSkipResolvers = new Map<string, () => void>();
|
|
82
|
+
const scopePrevs = new Map<string, Promise<unknown>>();
|
|
83
|
+
const scopeCancelResolvers = new Map<string, () => void>();
|
|
84
|
+
const activeDedupeKeys = new Set<string>();
|
|
85
|
+
|
|
86
|
+
function fireDefSuccess(result: TResult, args: TArgs): void {
|
|
87
|
+
if (def.onSuccess) {
|
|
88
|
+
const cb = def.onSuccess;
|
|
89
|
+
safeInvoke(def.name, "def.onSuccess", () => {
|
|
90
|
+
cb(result, args);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function fireDefError(err: ActionErrorLike, args: TArgs): void {
|
|
95
|
+
if (def.onError) {
|
|
96
|
+
const cb = def.onError;
|
|
97
|
+
safeInvoke(def.name, "def.onError", () => {
|
|
98
|
+
cb(err, args);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function fireDefSettled(args: TArgs): void {
|
|
103
|
+
if (def.onSettled) {
|
|
104
|
+
const cb = def.onSettled;
|
|
105
|
+
safeInvoke(def.name, "def.onSettled", () => {
|
|
106
|
+
cb(args);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dispatch(
|
|
112
|
+
args: TArgs,
|
|
113
|
+
opts: DispatchOptions<TArgs, TResult> = NO_OPTS,
|
|
114
|
+
): DispatchHandle<TResult> {
|
|
115
|
+
const dedupeKey = dedupeKeyFor(args);
|
|
116
|
+
if (dedupeKey !== null) {
|
|
117
|
+
const entry = activeDedupes.get(dedupeKey);
|
|
118
|
+
if (entry !== undefined) {
|
|
119
|
+
const shared = entry.promise;
|
|
120
|
+
if (shared === undefined) {
|
|
121
|
+
const onSettledCb = opts.onSettled;
|
|
122
|
+
if (onSettledCb) {
|
|
123
|
+
safeInvoke(def.name, "onSettled", () => {
|
|
124
|
+
onSettledCb(args);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return makeHandle(Promise.resolve(null) as Promise<TResult | null>, NOOP);
|
|
128
|
+
}
|
|
129
|
+
const joined = (shared as Promise<TResult | null>).then(
|
|
130
|
+
(v) => {
|
|
131
|
+
if (v !== null) {
|
|
132
|
+
const onSuccessCb = opts.onSuccess;
|
|
133
|
+
if (onSuccessCb) {
|
|
134
|
+
safeInvoke(def.name, "onSuccess", () => {
|
|
135
|
+
onSuccessCb(v, args);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
} else if (entry.error !== undefined) {
|
|
139
|
+
const capturedErr = entry.error;
|
|
140
|
+
const onErrorCb = opts.onError;
|
|
141
|
+
if (onErrorCb) {
|
|
142
|
+
safeInvoke(def.name, "onError", () => {
|
|
143
|
+
onErrorCb(capturedErr, args);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} else if (entry.cancelled !== true) {
|
|
147
|
+
const onErrorCb = opts.onError;
|
|
148
|
+
if (onErrorCb) {
|
|
149
|
+
safeInvoke(def.name, "onError", () => {
|
|
150
|
+
onErrorCb({ message: "deduped dispatch did not succeed", code: "dedupe" }, args);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const onSettledCb = opts.onSettled;
|
|
155
|
+
if (onSettledCb) {
|
|
156
|
+
safeInvoke(def.name, "onSettled", () => {
|
|
157
|
+
onSettledCb(args);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return v;
|
|
161
|
+
},
|
|
162
|
+
() => {
|
|
163
|
+
const onSettledCb = opts.onSettled;
|
|
164
|
+
if (onSettledCb) {
|
|
165
|
+
safeInvoke(def.name, "onSettled", () => {
|
|
166
|
+
onSettledCb(args);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
return makeHandle(joined, NOOP);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const scopeKey =
|
|
177
|
+
typeof def.scope === "function"
|
|
178
|
+
? def.scope(args)
|
|
179
|
+
: typeof def.scope === "string"
|
|
180
|
+
? def.scope
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
const ac = new AbortController();
|
|
184
|
+
const id = nextInstanceID(def.name);
|
|
185
|
+
inFlight.set(id, ac);
|
|
186
|
+
const dispatchedAt = Date.now();
|
|
187
|
+
|
|
188
|
+
const dedupeEntry: DedupeSlot | null = dedupeKey !== null ? { promise: undefined } : null;
|
|
189
|
+
|
|
190
|
+
let result: Promise<TResult | null>;
|
|
191
|
+
if (scopeKey === null) {
|
|
192
|
+
result = runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt);
|
|
193
|
+
} else {
|
|
194
|
+
const prev = scopeChains.get(scopeKey) ?? Promise.resolve();
|
|
195
|
+
const next = prev.then(() =>
|
|
196
|
+
runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt),
|
|
197
|
+
);
|
|
198
|
+
let tailResolve!: () => void;
|
|
199
|
+
const tail = new Promise<void>((r) => {
|
|
200
|
+
tailResolve = r;
|
|
201
|
+
});
|
|
202
|
+
scopeSkipResolvers.set(id, tailResolve);
|
|
203
|
+
scopePrevs.set(id, prev);
|
|
204
|
+
void next.then(tailResolve, tailResolve);
|
|
205
|
+
scopeChains.set(scopeKey, tail);
|
|
206
|
+
void next
|
|
207
|
+
.finally(() => {
|
|
208
|
+
scopeSkipResolvers.delete(id);
|
|
209
|
+
scopeCancelResolvers.delete(id);
|
|
210
|
+
scopePrevs.delete(id);
|
|
211
|
+
if (scopeChains.get(scopeKey) === tail) {
|
|
212
|
+
scopeChains.delete(scopeKey);
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
.catch(NOOP);
|
|
216
|
+
let earlyCancelResolve!: (v: TResult | null) => void;
|
|
217
|
+
const earlyCancel = new Promise<TResult | null>((r) => {
|
|
218
|
+
earlyCancelResolve = r;
|
|
219
|
+
});
|
|
220
|
+
scopeCancelResolvers.set(id, () => {
|
|
221
|
+
try {
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
if (dedupeEntry !== null) {
|
|
224
|
+
dedupeEntry.cancelled = true;
|
|
225
|
+
}
|
|
226
|
+
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
227
|
+
record({
|
|
228
|
+
id,
|
|
229
|
+
name: def.name,
|
|
230
|
+
status: "cancelled",
|
|
231
|
+
args,
|
|
232
|
+
dispatchedAt,
|
|
233
|
+
startedAt: now,
|
|
234
|
+
completedAt: now,
|
|
235
|
+
});
|
|
236
|
+
} finally {
|
|
237
|
+
fireDefSettled(args);
|
|
238
|
+
const onSettledCb = opts.onSettled;
|
|
239
|
+
if (onSettledCb) {
|
|
240
|
+
safeInvoke(def.name, "onSettled", () => {
|
|
241
|
+
onSettledCb(args);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
earlyCancelResolve(null);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
result = Promise.race([next, earlyCancel]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (dedupeKey !== null && dedupeEntry !== null) {
|
|
251
|
+
dedupeEntry.promise = result;
|
|
252
|
+
activeDedupes.set(dedupeKey, dedupeEntry);
|
|
253
|
+
activeDedupeKeys.add(dedupeKey);
|
|
254
|
+
void result.finally(() => {
|
|
255
|
+
if (activeDedupes.get(dedupeKey) === dedupeEntry) {
|
|
256
|
+
activeDedupes.delete(dedupeKey);
|
|
257
|
+
activeDedupeKeys.delete(dedupeKey);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return makeHandle(result, () => {
|
|
263
|
+
ac.abort();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function dedupeKeyFor(args: TArgs): string | null {
|
|
268
|
+
const cfg = def.dedupe;
|
|
269
|
+
if (cfg === undefined || cfg === false) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const argKey = typeof cfg === "function" ? cfg(args) : safeStringify(args);
|
|
273
|
+
return `${def.name}::${argKey}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function evictDedupeSlot(dk: string | null, entry: DedupeSlot | null): void {
|
|
277
|
+
if (dk !== null && entry !== null && activeDedupes.get(dk) === entry) {
|
|
278
|
+
activeDedupes.delete(dk);
|
|
279
|
+
activeDedupeKeys.delete(dk);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runOnce(
|
|
284
|
+
args: TArgs,
|
|
285
|
+
opts: DispatchOptions<TArgs, TResult>,
|
|
286
|
+
ac: AbortController,
|
|
287
|
+
id: string,
|
|
288
|
+
dedupeEntry: DedupeSlot | null,
|
|
289
|
+
dedupeKey: string | null,
|
|
290
|
+
dispatchedAt: number,
|
|
291
|
+
): Promise<TResult | null> {
|
|
292
|
+
started.add(id);
|
|
293
|
+
if (!inFlight.has(id) && ac.signal.aborted) {
|
|
294
|
+
started.delete(id);
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const settle = (): void => {
|
|
298
|
+
inFlight.delete(id);
|
|
299
|
+
started.delete(id);
|
|
300
|
+
const onSettledCb = opts.onSettled;
|
|
301
|
+
if (onSettledCb) {
|
|
302
|
+
safeInvoke(def.name, "onSettled", () => {
|
|
303
|
+
onSettledCb(args);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (ac.signal.aborted) {
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
if (dedupeEntry !== null) {
|
|
311
|
+
dedupeEntry.cancelled = true;
|
|
312
|
+
}
|
|
313
|
+
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
314
|
+
record({
|
|
315
|
+
id,
|
|
316
|
+
name: def.name,
|
|
317
|
+
status: "cancelled",
|
|
318
|
+
args,
|
|
319
|
+
dispatchedAt,
|
|
320
|
+
startedAt: now,
|
|
321
|
+
completedAt: now,
|
|
322
|
+
});
|
|
323
|
+
fireDefSettled(args);
|
|
324
|
+
settle();
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const startedAt = Date.now();
|
|
329
|
+
|
|
330
|
+
const idemKey =
|
|
331
|
+
typeof def.idempotencyKey === "function"
|
|
332
|
+
? def.idempotencyKey(args)
|
|
333
|
+
: def.idempotencyKey === true
|
|
334
|
+
? generateIdempotencyKey()
|
|
335
|
+
: null;
|
|
336
|
+
const ctx: ActionContext =
|
|
337
|
+
idemKey !== null ? { instanceID: id, idempotencyKey: idemKey } : { instanceID: id };
|
|
338
|
+
|
|
339
|
+
// Compose timeout signal if configured
|
|
340
|
+
const runSignal =
|
|
341
|
+
def.timeout !== undefined
|
|
342
|
+
? AbortSignal.any([ac.signal, AbortSignal.timeout(def.timeout)])
|
|
343
|
+
: ac.signal;
|
|
344
|
+
|
|
345
|
+
let optOp: TOp | undefined;
|
|
346
|
+
if (def.optimistic !== undefined) {
|
|
347
|
+
try {
|
|
348
|
+
optOp = def.optimistic(args);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
const raw = toActionError(e);
|
|
351
|
+
const err: ActionErrorLike =
|
|
352
|
+
raw.code !== undefined ? raw : { ...raw, code: "optimistic_failed" };
|
|
353
|
+
if (dedupeEntry !== null) {
|
|
354
|
+
dedupeEntry.error = err;
|
|
355
|
+
}
|
|
356
|
+
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
357
|
+
record({
|
|
358
|
+
id,
|
|
359
|
+
name: def.name,
|
|
360
|
+
status: "error",
|
|
361
|
+
args,
|
|
362
|
+
dispatchedAt,
|
|
363
|
+
startedAt,
|
|
364
|
+
completedAt: Date.now(),
|
|
365
|
+
error: err,
|
|
366
|
+
});
|
|
367
|
+
emitErrorToast(args, err);
|
|
368
|
+
fireDefError(err, args);
|
|
369
|
+
const onErrorCb = opts.onError;
|
|
370
|
+
if (onErrorCb) {
|
|
371
|
+
safeInvoke(def.name, "onError", () => {
|
|
372
|
+
onErrorCb(err, args);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
fireDefSettled(args);
|
|
376
|
+
settle();
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
record({
|
|
382
|
+
id,
|
|
383
|
+
name: def.name,
|
|
384
|
+
status: "pending",
|
|
385
|
+
args,
|
|
386
|
+
dispatchedAt,
|
|
387
|
+
startedAt,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const { result, attempts } = await runWithRetry(args, runSignal, ctx);
|
|
392
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal state changes during async
|
|
393
|
+
if (ac.signal.aborted) {
|
|
394
|
+
if (dedupeEntry !== null) {
|
|
395
|
+
dedupeEntry.cancelled = true;
|
|
396
|
+
}
|
|
397
|
+
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
398
|
+
record({
|
|
399
|
+
id,
|
|
400
|
+
name: def.name,
|
|
401
|
+
status: "cancelled",
|
|
402
|
+
args,
|
|
403
|
+
dispatchedAt,
|
|
404
|
+
startedAt,
|
|
405
|
+
completedAt: Date.now(),
|
|
406
|
+
attempts,
|
|
407
|
+
});
|
|
408
|
+
if (def.rollback !== undefined) {
|
|
409
|
+
try {
|
|
410
|
+
def.rollback(args, optOp, { message: "cancelled", code: "cancelled" });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
console.error(`[actions] rollback (cancellation) for ${def.name} threw`, e);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
fireDefSettled(args);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
record({
|
|
419
|
+
id,
|
|
420
|
+
name: def.name,
|
|
421
|
+
status: "success",
|
|
422
|
+
args,
|
|
423
|
+
dispatchedAt,
|
|
424
|
+
startedAt,
|
|
425
|
+
completedAt: Date.now(),
|
|
426
|
+
result,
|
|
427
|
+
attempts,
|
|
428
|
+
});
|
|
429
|
+
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
430
|
+
emitSuccessToast(args, result, opts);
|
|
431
|
+
fireDefSuccess(result, args);
|
|
432
|
+
const onSuccessCb = opts.onSuccess;
|
|
433
|
+
if (onSuccessCb) {
|
|
434
|
+
safeInvoke(def.name, "onSuccess", () => {
|
|
435
|
+
onSuccessCb(result, args);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
fireDefSettled(args);
|
|
439
|
+
return result;
|
|
440
|
+
} catch (e: unknown) {
|
|
441
|
+
const err = toActionError(e);
|
|
442
|
+
const attempts = readAttempts(e);
|
|
443
|
+
const cancelled = ac.signal.aborted as boolean;
|
|
444
|
+
const status = cancelled ? "cancelled" : "error";
|
|
445
|
+
if (dedupeEntry !== null) {
|
|
446
|
+
if (cancelled) {
|
|
447
|
+
dedupeEntry.cancelled = true;
|
|
448
|
+
} else {
|
|
449
|
+
dedupeEntry.error = err;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
453
|
+
record({
|
|
454
|
+
id,
|
|
455
|
+
name: def.name,
|
|
456
|
+
status,
|
|
457
|
+
args,
|
|
458
|
+
dispatchedAt,
|
|
459
|
+
startedAt,
|
|
460
|
+
completedAt: Date.now(),
|
|
461
|
+
...(!cancelled && { error: err }),
|
|
462
|
+
...(attempts !== undefined && { attempts }),
|
|
463
|
+
});
|
|
464
|
+
if (def.rollback !== undefined) {
|
|
465
|
+
try {
|
|
466
|
+
const rbError = cancelled ? { message: "cancelled", code: "cancelled" } : err;
|
|
467
|
+
def.rollback(args, optOp, rbError);
|
|
468
|
+
} catch (rbCaught) {
|
|
469
|
+
console.error(`[actions] rollback for ${def.name} threw`, rbCaught);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!cancelled) {
|
|
473
|
+
emitErrorToast(args, err);
|
|
474
|
+
fireDefError(err, args);
|
|
475
|
+
const onErrorCb = opts.onError;
|
|
476
|
+
if (onErrorCb) {
|
|
477
|
+
safeInvoke(def.name, "onError", () => {
|
|
478
|
+
onErrorCb(err, args);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
fireDefSettled(args);
|
|
483
|
+
return null;
|
|
484
|
+
} finally {
|
|
485
|
+
settle();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function runWithRetry(
|
|
490
|
+
args: TArgs,
|
|
491
|
+
signal: AbortSignal,
|
|
492
|
+
ctx: ActionContext,
|
|
493
|
+
): Promise<{ result: TResult; attempts: number }> {
|
|
494
|
+
const cfg = def.retry;
|
|
495
|
+
const maxAttempts = (cfg?.count ?? 0) + 1;
|
|
496
|
+
const baseDelay = cfg?.delay;
|
|
497
|
+
const factor = cfg?.factor ?? 2;
|
|
498
|
+
const networkMode = def.networkMode ?? "online";
|
|
499
|
+
let attempt = 0;
|
|
500
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with throw exits
|
|
501
|
+
while (true) {
|
|
502
|
+
if (signal.aborted) {
|
|
503
|
+
const abortErr = signalAbortError(signal);
|
|
504
|
+
attachAttempts(abortErr, attempt);
|
|
505
|
+
throw abortErr;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
attempt++;
|
|
509
|
+
const result = await def.run(args, signal, ctx);
|
|
510
|
+
return { result, attempts: attempt };
|
|
511
|
+
} catch (e) {
|
|
512
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal state changes during async
|
|
513
|
+
if (signal.aborted) {
|
|
514
|
+
attachAttempts(e, attempt);
|
|
515
|
+
throw e;
|
|
516
|
+
}
|
|
517
|
+
if (attempt >= maxAttempts) {
|
|
518
|
+
attachAttempts(e, attempt);
|
|
519
|
+
throw e;
|
|
520
|
+
}
|
|
521
|
+
const err = toActionError(e);
|
|
522
|
+
if (!shouldRetry(err)) {
|
|
523
|
+
attachAttempts(e, attempt);
|
|
524
|
+
throw e;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (networkMode === "online") {
|
|
528
|
+
try {
|
|
529
|
+
await waitForOnline(signal);
|
|
530
|
+
} catch {
|
|
531
|
+
const abortErr = signalAbortError(signal);
|
|
532
|
+
attachAttempts(abortErr, attempt);
|
|
533
|
+
throw abortErr;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let delayMs: number;
|
|
538
|
+
if (typeof baseDelay === "function") {
|
|
539
|
+
try {
|
|
540
|
+
delayMs = baseDelay(attempt, err);
|
|
541
|
+
} catch {
|
|
542
|
+
delayMs = 0;
|
|
543
|
+
}
|
|
544
|
+
} else if (typeof baseDelay === "number") {
|
|
545
|
+
delayMs = Math.min(baseDelay * Math.pow(factor, attempt - 1), 5000);
|
|
546
|
+
} else {
|
|
547
|
+
delayMs = 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
await sleep(delayMs, signal);
|
|
552
|
+
} catch {
|
|
553
|
+
const abortErr = signalAbortError(signal);
|
|
554
|
+
attachAttempts(abortErr, attempt);
|
|
555
|
+
throw abortErr;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function shouldRetry(err: ActionErrorLike): boolean {
|
|
562
|
+
if (def.retryable === undefined) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
return def.retryable(err);
|
|
567
|
+
} catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function emitSuccessToast(
|
|
573
|
+
args: TArgs,
|
|
574
|
+
result: TResult,
|
|
575
|
+
opts: DispatchOptions<TArgs, TResult>,
|
|
576
|
+
): void {
|
|
577
|
+
if (opts.silent === true) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const msg = resolveNotification(def.success, args, result);
|
|
582
|
+
if (msg !== null) {
|
|
583
|
+
notifySuccess(msg);
|
|
584
|
+
}
|
|
585
|
+
} catch (e) {
|
|
586
|
+
console.error(`[actions] emitSuccessToast for ${def.name} threw`, e);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function emitErrorToast(args: TArgs, err: ActionErrorLike): void {
|
|
591
|
+
const spec = def.error;
|
|
592
|
+
if (spec === false) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const fallbackMsg = `${defaultErrorPrefix(def.name)}: ${err.message}`;
|
|
596
|
+
const retry = buildRetryButton(args, err);
|
|
597
|
+
try {
|
|
598
|
+
let msg: string;
|
|
599
|
+
if (typeof spec === "string") {
|
|
600
|
+
msg = `${spec}: ${err.message}`;
|
|
601
|
+
} else if (typeof spec === "function") {
|
|
602
|
+
msg = spec(args, err);
|
|
603
|
+
} else {
|
|
604
|
+
msg = fallbackMsg;
|
|
605
|
+
}
|
|
606
|
+
notifyError(msg, retry);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
console.error(`[actions] emitErrorToast for ${def.name} threw`, e);
|
|
609
|
+
notifyError(fallbackMsg, retry);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function buildRetryButton(
|
|
614
|
+
args: TArgs,
|
|
615
|
+
err: ActionErrorLike,
|
|
616
|
+
): { onClick: () => void } | undefined {
|
|
617
|
+
if (!shouldRetry(err)) {
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
let frozenArgs: TArgs;
|
|
621
|
+
try {
|
|
622
|
+
frozenArgs = structuredClone(args);
|
|
623
|
+
} catch {
|
|
624
|
+
if (args === null || args === undefined || typeof args !== "object") {
|
|
625
|
+
frozenArgs = args;
|
|
626
|
+
} else {
|
|
627
|
+
try {
|
|
628
|
+
frozenArgs = (Array.isArray(args) ? [...args] : { ...args }) as TArgs;
|
|
629
|
+
} catch {
|
|
630
|
+
frozenArgs = args;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
onClick: () => {
|
|
636
|
+
void dispatch(frozenArgs);
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function cancel(): void {
|
|
642
|
+
if (inFlight.size === 0) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
for (const dk of activeDedupeKeys) {
|
|
646
|
+
const entry = activeDedupes.get(dk);
|
|
647
|
+
if (entry !== undefined) {
|
|
648
|
+
entry.cancelled = true;
|
|
649
|
+
}
|
|
650
|
+
activeDedupes.delete(dk);
|
|
651
|
+
}
|
|
652
|
+
activeDedupeKeys.clear();
|
|
653
|
+
for (const [id, controller] of [...inFlight.entries()]) {
|
|
654
|
+
controller.abort();
|
|
655
|
+
if (!started.has(id)) {
|
|
656
|
+
inFlight.delete(id);
|
|
657
|
+
const skip = scopeSkipResolvers.get(id);
|
|
658
|
+
if (skip !== undefined) {
|
|
659
|
+
const prev = scopePrevs.get(id);
|
|
660
|
+
scopeSkipResolvers.delete(id);
|
|
661
|
+
scopePrevs.delete(id);
|
|
662
|
+
if (prev !== undefined) {
|
|
663
|
+
void prev.then(skip, skip);
|
|
664
|
+
} else {
|
|
665
|
+
skip();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const earlyCancel = scopeCancelResolvers.get(id);
|
|
669
|
+
if (earlyCancel !== undefined) {
|
|
670
|
+
scopeCancelResolvers.delete(id);
|
|
671
|
+
earlyCancel();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const action: Action<TArgs, TResult> = {
|
|
678
|
+
name: def.name,
|
|
679
|
+
dispatch,
|
|
680
|
+
cancel,
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
_registerAction(action);
|
|
684
|
+
|
|
685
|
+
return action;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Test-only: reset the instance counter + scope chains + dedupe map. */
|
|
689
|
+
export function _resetForTest(): void {
|
|
690
|
+
instanceCounter = 0;
|
|
691
|
+
_resetSymbols();
|
|
692
|
+
scopeChains.clear();
|
|
693
|
+
activeDedupes.clear();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Test-only: expose internal map sizes for leak verification. */
|
|
697
|
+
export function _internalsForTest(): { scopeChains: number; activeDedupes: number } {
|
|
698
|
+
return { scopeChains: scopeChains.size, activeDedupes: activeDedupes.size };
|
|
699
|
+
}
|