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