@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,225 @@
1
+ // Action registry: in-memory log of all dispatched actions with a
2
+ // subscribe API. Fires per state transition. Bounded to a recent
3
+ // window so memory usage stays flat over a long session.
4
+ //
5
+ // Performance: eviction uses a head-pointer + tombstones instead of
6
+ // splice + O(n) index re-computation. record() is O(1) amortized.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import type { ActionInstance, RegistryListener } from "./types.js";
10
+
11
+ const MAX_LOG_SIZE = 200;
12
+ const MAX_LOG_HARD = MAX_LOG_SIZE * 5;
13
+
14
+ const log: (ActionInstance | null)[] = [];
15
+ interface LogSlot {
16
+ instance: ActionInstance;
17
+ index: number;
18
+ }
19
+ const idMap = new Map<string, LogSlot>();
20
+ const listeners = new Set<RegistryListener>();
21
+ const namedListeners = new Map<string, Set<RegistryListener>>();
22
+ const pendingByName = new Map<string, Set<string>>();
23
+ let _pendingTotal = 0;
24
+ let _liveCount = 0;
25
+ let _head = 0;
26
+
27
+ function compact(): void {
28
+ while (_head < log.length && log[_head] === null) {
29
+ _head++;
30
+ }
31
+ if (_head > 256) {
32
+ log.splice(0, _head);
33
+ for (const entry of idMap.values()) {
34
+ entry.index -= _head;
35
+ }
36
+ _head = 0;
37
+ }
38
+ }
39
+
40
+ /** Record a state transition. Called by define.ts. */
41
+ export function record(instance: ActionInstance): void {
42
+ const existing = idMap.get(instance.id);
43
+ if (existing !== undefined) {
44
+ const prev = existing.instance;
45
+ if (prev.status === "pending" && instance.status !== "pending") {
46
+ _pendingTotal--;
47
+ const s = pendingByName.get(prev.name);
48
+ if (s !== undefined) {
49
+ s.delete(instance.id);
50
+ if (s.size === 0) {
51
+ pendingByName.delete(prev.name);
52
+ }
53
+ }
54
+ } else if (prev.status !== "pending" && instance.status === "pending") {
55
+ _pendingTotal++;
56
+ let s = pendingByName.get(instance.name);
57
+ if (s === undefined) {
58
+ s = new Set();
59
+ pendingByName.set(instance.name, s);
60
+ }
61
+ s.add(instance.id);
62
+ }
63
+ log[existing.index] = instance;
64
+ existing.instance = instance;
65
+ if (instance.status !== "pending" && _liveCount > MAX_LOG_SIZE) {
66
+ for (let i = _head; i < log.length; i++) {
67
+ const entry = log[i];
68
+ if (
69
+ entry !== null &&
70
+ entry !== undefined &&
71
+ entry.status !== "pending" &&
72
+ entry.id !== instance.id
73
+ ) {
74
+ idMap.delete(entry.id);
75
+ log[i] = null;
76
+ _liveCount--;
77
+ if (_liveCount <= MAX_LOG_SIZE) {
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ compact();
83
+ }
84
+ } else {
85
+ const idx = log.length;
86
+ log.push(instance);
87
+ idMap.set(instance.id, { instance, index: idx });
88
+ _liveCount++;
89
+ if (instance.status === "pending") {
90
+ _pendingTotal++;
91
+ let s = pendingByName.get(instance.name);
92
+ if (s === undefined) {
93
+ s = new Set();
94
+ pendingByName.set(instance.name, s);
95
+ }
96
+ s.add(instance.id);
97
+ }
98
+ if (_liveCount > MAX_LOG_SIZE) {
99
+ for (let i = _head; i < log.length; i++) {
100
+ const entry = log[i];
101
+ if (entry !== null && entry !== undefined && entry.status !== "pending") {
102
+ idMap.delete(entry.id);
103
+ log[i] = null;
104
+ _liveCount--;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ if (_liveCount > MAX_LOG_HARD) {
110
+ for (let i = _head; i < log.length; i++) {
111
+ const entry = log[i];
112
+ if (entry !== null && entry !== undefined) {
113
+ if (entry.status === "pending") {
114
+ _pendingTotal--;
115
+ const s = pendingByName.get(entry.name);
116
+ if (s !== undefined) {
117
+ s.delete(entry.id);
118
+ if (s.size === 0) {
119
+ pendingByName.delete(entry.name);
120
+ }
121
+ }
122
+ }
123
+ idMap.delete(entry.id);
124
+ log[i] = null;
125
+ _liveCount--;
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ compact();
131
+ }
132
+ if (_pendingTotal < 0) {
133
+ console.warn("[actions] _pendingTotal went negative — invariant violation; clamping to 0");
134
+ _pendingTotal = 0;
135
+ }
136
+ for (const fn of listeners) {
137
+ try {
138
+ fn(instance);
139
+ } catch (e) {
140
+ console.error("[actions] registry listener threw", e);
141
+ }
142
+ }
143
+ const named = namedListeners.get(instance.name);
144
+ if (named !== undefined) {
145
+ for (const fn of named) {
146
+ try {
147
+ fn(instance);
148
+ } catch (e) {
149
+ console.error("[actions] registry listener threw", e);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ /** Subscribe to all action lifecycle events. */
156
+ export function subscribe(fn: RegistryListener): () => void {
157
+ listeners.add(fn);
158
+ return () => listeners.delete(fn);
159
+ }
160
+
161
+ /** Subscribe to lifecycle events for a single action name. */
162
+ export function subscribeByName(name: string, fn: RegistryListener): () => void {
163
+ let set = namedListeners.get(name);
164
+ if (set === undefined) {
165
+ set = new Set();
166
+ namedListeners.set(name, set);
167
+ }
168
+ set.add(fn);
169
+ const captured = set;
170
+ return () => {
171
+ captured.delete(fn);
172
+ if (captured.size === 0 && namedListeners.get(name) === captured) {
173
+ namedListeners.delete(name);
174
+ }
175
+ };
176
+ }
177
+
178
+ /** @internal Test-only public surface. */
179
+ export function recentLog(): readonly ActionInstance[] {
180
+ const result: ActionInstance[] = [];
181
+ for (let i = _head; i < log.length; i++) {
182
+ const entry = log[i];
183
+ if (entry != null) {
184
+ result.push(entry);
185
+ }
186
+ }
187
+ return result;
188
+ }
189
+
190
+ /** Read the recent action log. Useful for devtools integration and
191
+ * debugging panels. Returns a snapshot of all live entries. */
192
+ export const getActionLog = recentLog;
193
+
194
+ /** O(1) check: true if at least one instance of the named action is pending. */
195
+ export function isPending(name: string): boolean {
196
+ const s = pendingByName.get(name);
197
+ return s !== undefined && s.size > 0;
198
+ }
199
+
200
+ /** Pending count for action(s). */
201
+ export function pendingCount(names?: readonly string[]): number {
202
+ if (names === undefined) {
203
+ return _pendingTotal;
204
+ }
205
+ let total = 0;
206
+ for (const name of names) {
207
+ const s = pendingByName.get(name);
208
+ if (s !== undefined) {
209
+ total += s.size;
210
+ }
211
+ }
212
+ return total;
213
+ }
214
+
215
+ /** Test-only: clear log + listeners. */
216
+ export function _resetForTest(): void {
217
+ log.length = 0;
218
+ _head = 0;
219
+ _liveCount = 0;
220
+ idMap.clear();
221
+ pendingByName.clear();
222
+ listeners.clear();
223
+ namedListeners.clear();
224
+ _pendingTotal = 0;
225
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,80 @@
1
+ // retry.ts — extracted retry/backoff primitives from define.ts.
2
+ // These are pure utility functions with no dependency on the action
3
+ // framework, making them independently testable.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ /** Abort-aware sleep. Rejects with AbortError if the signal fires
7
+ * before the timeout elapses. Resolves immediately for ms <= 0. */
8
+ export function sleep(ms: number, signal: AbortSignal): Promise<void> {
9
+ if (signal.aborted) {
10
+ return Promise.reject(new DOMException("aborted", "AbortError"));
11
+ }
12
+ if (ms <= 0) {
13
+ return Promise.resolve();
14
+ }
15
+ return new Promise<void>((resolve, reject) => {
16
+ const t = setTimeout(() => {
17
+ signal.removeEventListener("abort", onAbort);
18
+ resolve();
19
+ }, ms);
20
+ const onAbort = (): void => {
21
+ clearTimeout(t);
22
+ reject(new DOMException("aborted", "AbortError"));
23
+ };
24
+ signal.addEventListener("abort", onAbort, { once: true });
25
+ });
26
+ }
27
+
28
+ /** Wait for the browser to come back online, or for the signal to abort. */
29
+ export function waitForOnline(signal: AbortSignal): Promise<void> {
30
+ if (typeof navigator === "undefined" || navigator.onLine) {
31
+ return Promise.resolve();
32
+ }
33
+ if (signal.aborted) {
34
+ return Promise.reject(new DOMException("aborted", "AbortError"));
35
+ }
36
+ return new Promise<void>((resolve, reject) => {
37
+ const onOnline = (): void => {
38
+ cleanup();
39
+ resolve();
40
+ };
41
+ const onAbort = (): void => {
42
+ cleanup();
43
+ reject(new DOMException("aborted", "AbortError"));
44
+ };
45
+ function cleanup(): void {
46
+ if (typeof window !== "undefined") {
47
+ window.removeEventListener("online", onOnline);
48
+ }
49
+ signal.removeEventListener("abort", onAbort);
50
+ }
51
+ if (typeof window !== "undefined") {
52
+ window.addEventListener("online", onOnline, { once: true });
53
+ }
54
+ signal.addEventListener("abort", onAbort, { once: true });
55
+ });
56
+ }
57
+
58
+ /** Attach attempt count to a thrown error (non-enumerable property). */
59
+ export function attachAttempts(e: unknown, attempts: number): void {
60
+ if (typeof e === "object" && e !== null) {
61
+ try {
62
+ Object.defineProperty(e, "_attempts", { value: attempts, configurable: true });
63
+ } catch {
64
+ /* frozen/sealed object — skip */
65
+ }
66
+ }
67
+ }
68
+
69
+ /** Read the attempt count attached by runWithRetry, or undefined. */
70
+ export function readAttempts(e: unknown): number | undefined {
71
+ try {
72
+ if (typeof e === "object" && e !== null && "_attempts" in e) {
73
+ const val = (e as { readonly _attempts: unknown })._attempts;
74
+ return typeof val === "number" ? val : undefined;
75
+ }
76
+ } catch {
77
+ /* Proxy or getter threw — skip */
78
+ }
79
+ return undefined;
80
+ }
@@ -0,0 +1,112 @@
1
+ // Transport injection seam: consumer-provided adapter for streaming/SSE
2
+ // command dispatch. Mirrors the notifier.ts pattern — call
3
+ // configureTransport() at boot to wire up the send function.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import { defineAction } from "./define.js";
7
+ import { ActionError } from "./error.js";
8
+ import type { Action, ActionContext, ActionDefinition } from "./types.js";
9
+
10
+ /** Result returned by the transport send function.
11
+ * `ok: true` means the command was accepted; `ok: false` triggers the
12
+ * action's error branch (rollback + notification). */
13
+ export interface TransportSendResult {
14
+ readonly ok: boolean;
15
+ readonly status: number;
16
+ readonly error?: string;
17
+ readonly code?: string;
18
+ }
19
+
20
+ /** A command object sent via the transport layer. Must include a `type`
21
+ * discriminator; additional fields carry the command payload. */
22
+ export interface TransportCommand {
23
+ readonly type: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /** Signature of the consumer-provided send function.
28
+ * Receives the command and an options bag with an AbortSignal for cancellation. */
29
+ export type TransportSendFn = (
30
+ cmd: TransportCommand,
31
+ opts: { signal: AbortSignal },
32
+ ) => Promise<TransportSendResult>;
33
+
34
+ let _send: TransportSendFn | undefined;
35
+
36
+ /**
37
+ * Configure the global transport adapter. Call once at app boot.
38
+ * Only needed if using `transportAction`.
39
+ */
40
+ export function configureTransport(fn: TransportSendFn): void {
41
+ _send = fn;
42
+ }
43
+
44
+ /** @internal Test-only: reset the transport to unconfigured state. */
45
+ export function _resetTransportForTest(): void {
46
+ _send = undefined;
47
+ }
48
+
49
+ /** Caller-facing shape of a transportAction definition. `command`
50
+ * replaces `run`. Result is `void` because transport.send does not
51
+ * return a payload (the response arrives later via SSE events). */
52
+ interface TransportActionDefinition<TArgs, TOp = unknown> extends Omit<
53
+ ActionDefinition<TArgs, void, TOp>,
54
+ "run"
55
+ > {
56
+ /** Build the command for this dispatch. Re-evaluated per-dispatch. */
57
+ command: (args: TArgs) => TransportCommand;
58
+ }
59
+
60
+ /**
61
+ * Build an Action from a transport command descriptor. The generated
62
+ * `run()` calls the configured transport send function and throws
63
+ * {@link ActionError} on `!ok`, so the dispatcher's error branch
64
+ * (notification + rollback) fires consistently.
65
+ *
66
+ * @param def - Transport action definition where `command` replaces `run`.
67
+ * @returns An {@link Action} backed by the configured transport adapter.
68
+ */
69
+ export function transportAction<TArgs, TOp = unknown>(
70
+ def: TransportActionDefinition<TArgs, TOp>,
71
+ ): Action<TArgs, void> {
72
+ const { command, ...rest } = def;
73
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- void used as generic type argument
74
+ return defineAction<TArgs, void, TOp>({
75
+ ...(rest as Omit<ActionDefinition<TArgs, void, TOp>, "run">),
76
+ run: async (args: TArgs, signal: AbortSignal, ctx?: ActionContext) => {
77
+ if (_send === undefined) {
78
+ throw new ActionError("Transport not configured — call configureTransport() at boot", {
79
+ code: "transport_not_configured",
80
+ });
81
+ }
82
+ const raw = command(args);
83
+ let cmd: TransportCommand;
84
+ if (ctx?.idempotencyKey !== undefined) {
85
+ cmd = { ...raw, idempotency_key: ctx.idempotencyKey };
86
+ } else {
87
+ cmd = raw;
88
+ }
89
+ const r = await _send(cmd, { signal });
90
+ if (!r.ok) {
91
+ if (signal.aborted || r.code === "cancelled") {
92
+ throw new ActionError("cancelled", { code: "cancelled" });
93
+ }
94
+ if (r.code === "timeout") {
95
+ throw new ActionError(r.error ?? "Request timed out", {
96
+ status: r.status,
97
+ code: "timeout",
98
+ });
99
+ }
100
+ if (r.code === "network") {
101
+ throw new ActionError(r.error ?? "network error", { status: r.status, code: "network" });
102
+ }
103
+ const errOpts: { status: number; code?: string } = { status: r.status };
104
+ if (r.code !== undefined) {
105
+ errOpts.code = r.code;
106
+ }
107
+ throw new ActionError(r.error ?? `send failed (${String(r.status)})`, errOpts);
108
+ }
109
+ return undefined;
110
+ },
111
+ });
112
+ }
package/src/types.ts ADDED
@@ -0,0 +1,198 @@
1
+ // Action framework types: defines the contract between callers, the
2
+ // dispatcher, and observers. Pure types — no imports, no runtime
3
+ // behavior — so any module in the codebase can depend on this without
4
+ // pulling in transport / notification / store.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Lifecycle status of a single dispatched action instance.
8
+ *
9
+ * - `"pending"` — optimistic ran (if any), run() in flight
10
+ * - `"success"` — run() resolved
11
+ * - `"error"` — run() threw; rollback ran
12
+ * - `"cancelled"` — action.cancel() called or signal aborted externally */
13
+ export type ActionLifecycleStatus = "pending" | "success" | "error" | "cancelled";
14
+
15
+ /** Errors thrown by an action's run() function. ActionError subclass
16
+ * in error.ts attaches HTTP status + server error code metadata. */
17
+ export interface ActionErrorLike {
18
+ readonly message: string;
19
+ /** HTTP status if applicable. */
20
+ readonly status?: number;
21
+ /** Server-side error code. */
22
+ readonly code?: string;
23
+ readonly cause?: unknown;
24
+ }
25
+
26
+ /** Snapshot of a single in-flight or historical action invocation.
27
+ * Stored in the registry log for observability. */
28
+ export interface ActionInstance<TArgs = unknown, TResult = unknown> {
29
+ /** Unique per dispatch. */
30
+ readonly id: string;
31
+ /** Matches ActionDefinition.name. */
32
+ readonly name: string;
33
+ readonly status: ActionLifecycleStatus;
34
+ readonly args: TArgs;
35
+ /** Date.now() when dispatch() was called. */
36
+ readonly dispatchedAt: number;
37
+ /** Date.now() when run() begins (after scope queue). */
38
+ readonly startedAt: number;
39
+ /** Date.now() at terminal state. */
40
+ readonly completedAt?: number;
41
+ /** Present iff status === "success". */
42
+ readonly result?: TResult;
43
+ /** Present iff status === "error". */
44
+ readonly error?: ActionErrorLike;
45
+ /** Total run() invocations (1 = no retry; >1 = retries fired). */
46
+ readonly attempts?: number;
47
+ }
48
+
49
+ /** Notification wiring: either a literal string (used as-is), or a function
50
+ * computed from action args + result/error at call time. Pass false
51
+ * to opt out of the default notification for that branch. */
52
+ export type NotificationSpec<TArgs, TPayload> =
53
+ | string
54
+ | ((args: TArgs, payload: TPayload) => string)
55
+ | false;
56
+
57
+ /** @deprecated Use {@link NotificationSpec} instead. */
58
+ export type ToastSpec<TArgs, TPayload> = NotificationSpec<TArgs, TPayload>;
59
+
60
+ /** Per-dispatch context passed to run() as the 3rd argument. Mostly
61
+ * populated by the framework so adapters (apiAction, transportAction)
62
+ * can read out values like the idempotency key without the caller
63
+ * having to plumb them. */
64
+ export interface ActionContext {
65
+ /** Stable identifier for this dispatch (matches the registry's
66
+ * ActionInstance.id). */
67
+ readonly instanceID: string;
68
+ /** Set when ActionDefinition.idempotencyKey is configured. The
69
+ * framework generates this once per dispatch (not per retry). */
70
+ readonly idempotencyKey?: string;
71
+ }
72
+
73
+ /** Configuration for automatic retry of transient failures. */
74
+ export interface RetryConfig {
75
+ /** Additional attempts beyond the first (e.g. 2 = up to 3 total). */
76
+ readonly count: number;
77
+ /** Milliseconds before each retry. Number form: exponential backoff via
78
+ * `delay × factor^(attempt-1)`, capped at 5s. Function form: full
79
+ * control — receives the attempt number and the triggering error. */
80
+ readonly delay: number | ((attempt: number, err: ActionErrorLike) => number);
81
+ /** Backoff multiplier per retry. Default 2. Ignored when `delay` is a function. */
82
+ readonly factor?: number;
83
+ }
84
+
85
+ /** Standard retry config: 2 retries, 300ms initial delay. */
86
+ export const RETRY_STANDARD: RetryConfig = { count: 2, delay: 300 } as const;
87
+
88
+ export interface ActionDefinition<TArgs, TResult, TOp = unknown> {
89
+ /** Stable identifier, e.g. "chat.delete", "files.create".
90
+ * Used in the registry log + as a default notification prefix. */
91
+ readonly name: string;
92
+
93
+ /** The work the action performs. Must throw ActionError on failure
94
+ * (or any Error — wrappers will normalise). */
95
+ run: (args: TArgs, signal: AbortSignal, ctx?: ActionContext) => Promise<TResult>;
96
+
97
+ /** Optional optimistic mutation. Runs synchronously before run(). */
98
+ optimistic?: (args: TArgs) => TOp | undefined;
99
+
100
+ /** Undo the optimistic mutation. Called only if run() throws. */
101
+ rollback?: (args: TArgs, op: TOp | undefined, err: ActionErrorLike) => void;
102
+
103
+ /** Notification on success. Default: no notification. */
104
+ success?: NotificationSpec<TArgs, TResult>;
105
+
106
+ /** Notification on error. Default: action name humanised + error message. */
107
+ error?: NotificationSpec<TArgs, ActionErrorLike>;
108
+
109
+ /** Definition-level success callback. Fires on every successful dispatch.
110
+ * Mirrors TanStack Query's mutation-level onSuccess. */
111
+ onSuccess?: (result: TResult, args: TArgs) => void;
112
+
113
+ /** Definition-level error callback. Fires on every failed dispatch.
114
+ * Mirrors TanStack Query's mutation-level onError. */
115
+ onError?: (err: ActionErrorLike, args: TArgs) => void;
116
+
117
+ /** Definition-level settled callback. Fires after every dispatch
118
+ * (success, error, or cancellation). Mirrors TanStack Query's onSettled. */
119
+ onSettled?: (args: TArgs) => void;
120
+
121
+ /** Classify whether an error qualifies for retry. */
122
+ retryable?: (err: ActionErrorLike) => boolean;
123
+
124
+ /** Auto-retry transient failures before surfacing the error notification. */
125
+ retry?: RetryConfig;
126
+
127
+ /** Auto-retry behavior when offline. Default `"online"`. */
128
+ networkMode?: "online" | "always";
129
+
130
+ /** Timeout in milliseconds for the run() function. Uses AbortSignal.timeout()
131
+ * composed with the cancellation signal. If run() exceeds this duration,
132
+ * the signal aborts with a TimeoutError. */
133
+ timeout?: number;
134
+
135
+ /** Serialize concurrent dispatches sharing the same scope key. */
136
+ scope?: string | ((args: TArgs) => string);
137
+
138
+ /** Generate an idempotency key per dispatch. */
139
+ idempotencyKey?: boolean | ((args: TArgs) => string);
140
+
141
+ /** Collapse concurrent dispatches with matching key into one in-flight promise. */
142
+ dedupe?: boolean | ((args: TArgs) => string);
143
+ }
144
+
145
+ /** A registered action, returned by defineAction(). Can be dispatched
146
+ * many times; each dispatch is a separate instance with its own
147
+ * cancellation token. */
148
+ export interface Action<TArgs, TResult> {
149
+ readonly name: string;
150
+
151
+ /** Run the action. Returns a DispatchHandle with the result promise
152
+ * and a per-dispatch abort() method (RTK pattern). */
153
+ dispatch(args: TArgs, opts?: DispatchOptions<TArgs, TResult>): DispatchHandle<TResult>;
154
+
155
+ /** Cancel all in-flight instances. Each instance moves to status
156
+ * "cancelled" and run()'s signal aborts. */
157
+ cancel(): void;
158
+ }
159
+
160
+ /** Handle returned by dispatch(). An augmented Promise with an abort()
161
+ * method for per-dispatch cancellation (mirrors RTK createAsyncThunk).
162
+ * Can be awaited directly as a Promise. */
163
+ export interface DispatchHandle<TResult> extends Promise<TResult | null> {
164
+ /** Abort this specific dispatch. Other in-flight dispatches of the
165
+ * same action are unaffected. Mirrors RTK's promise.abort(). */
166
+ abort(): void;
167
+ }
168
+
169
+ /** Per-dispatch overrides. */
170
+ export interface DispatchOptions<TArgs = unknown, TResult = unknown> {
171
+ /** Suppress the success notification for this call. Errors still notify. */
172
+ readonly silent?: boolean;
173
+ /** Per-call success callback. Fires after the action-level notification. */
174
+ readonly onSuccess?: (result: TResult, args: TArgs) => void;
175
+ /** Per-call error callback. Fires after the action-level error notification. */
176
+ readonly onError?: (err: ActionErrorLike, args: TArgs) => void;
177
+ /** Per-call settled callback. Fires for success, error, AND cancellation. */
178
+ readonly onSettled?: (args: TArgs) => void;
179
+ }
180
+
181
+ /** HTTP request descriptor used by apiAction(). GET is included for
182
+ * read actions that want notification/cancellation semantics. */
183
+ export type RequestSpec =
184
+ | {
185
+ readonly method: "GET";
186
+ readonly path: string;
187
+ readonly headers?: Readonly<Record<string, string>>;
188
+ }
189
+ | {
190
+ readonly method: "POST" | "PUT" | "PATCH" | "DELETE";
191
+ readonly path: string;
192
+ readonly body?: unknown;
193
+ readonly headers?: Readonly<Record<string, string>>;
194
+ };
195
+
196
+ /** Subscriber callback for the registry. Fires once per state
197
+ * transition (pending -> success/error/cancelled). */
198
+ export type RegistryListener = (instance: ActionInstance) => void;