@cplieger/actions 3.0.1 → 3.1.1

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/README.md CHANGED
@@ -49,7 +49,7 @@ await deleteItem.dispatch(itemId);
49
49
 
50
50
  The framework provides three adapter injection points:
51
51
 
52
- - **Notifier** (`configure()`): Provides `success(msg)` and `error(msg, retry?)` methods for displaying notifications. Without configuration, notifications are silently dropped.
52
+ - **Notifier** (`configure()`): Provides `success(msg)` and `error(msg, retry?)` methods for displaying notifications. Without configuration, notifications are dropped — and the framework warns once on the first drop, since a forgotten `configure()` call is otherwise invisible. Call `configure({})` to opt into intentional headless silence (tests, non-UI environments); an explicitly configured notifier never warns, even with missing methods.
53
53
 
54
54
  - **API** (`configureApi()`): Configures the HTTP layer used by all `apiAction` instances — base URL, auth/CSRF headers, credentials mode, or a custom fetch implementation. Without configuration, `apiAction` uses the global `fetch` with relative paths.
55
55
 
@@ -93,17 +93,86 @@ const action = apiAction({
93
93
  });
94
94
  ```
95
95
 
96
+ ### Response decoding (apiAction `decode` / `decodeError`)
97
+
98
+ By default `apiAction` treats any 2xx as success and any failure as an
99
+ `ActionError`. Two optional hooks own the interpretation for servers that
100
+ speak nonstandard envelopes:
101
+
102
+ `decode(data, { status, spec })` runs on every 2xx. Return the action result,
103
+ or **throw** to route the dispatch to the error branch (rollback + error
104
+ notification + registry error status) — the seam for 200-with-error envelopes:
105
+
106
+ ```typescript
107
+ const stage = apiAction<{ repo: string }, { output?: string }>({
108
+ name: "git.stage",
109
+ request: (args) => ({ method: "POST", path: "/api/git/stage", body: args }),
110
+ // The server replies HTTP 200 for both outcomes; a non-empty `error`
111
+ // field is the failure signal.
112
+ decode: (data) => {
113
+ if (hasErrorString(data) && data.error !== "") {
114
+ throw new ActionError(data.error, { code: "git" });
115
+ }
116
+ return data as { output?: string };
117
+ },
118
+ });
119
+ ```
120
+
121
+ `decodeError(info, { spec })` runs on every failure that produced a real HTTP
122
+ response. `info` carries `status`, `message`, `code?`, the parsed JSON
123
+ `body?`, and `headers?`. Return `{ kind: "success", value }` to resolve the
124
+ dispatch as success (a 409 whose body is a meaningful payload), `{ kind:
125
+ "error", error }` to replace the default error, or `undefined` to keep the
126
+ default mapping:
127
+
128
+ ```typescript
129
+ const deleteTool = apiAction<{ name: string }, DeleteToolResult>({
130
+ name: "tools.delete",
131
+ request: ({ name }) => ({ method: "DELETE", path: `/api/tools/${name}` }),
132
+ error: false, // 409 cascade is a normal flow, handled by the caller
133
+ decodeError: (info) =>
134
+ info.status === 409
135
+ ? { kind: "success", value: (info.body ?? {}) as DeleteToolResult }
136
+ : undefined,
137
+ });
138
+ ```
139
+
140
+ Transport-level failures (network / timeout / cancellation — `status` 0)
141
+ never reach `decodeError`, so `retryNetwork` classification and cancellation
142
+ semantics can't be accidentally rewritten. `info.body` is server-controlled
143
+ input: validate its shape before reading fields.
144
+
145
+ For nonstandard **request** bodies, `RequestSpec.rawBody` is the encoder
146
+ seam: a pre-encoded `BodyInit` sent as-is (no JSON encoding, no automatic
147
+ Content-Type — set the type via `headers`), computed per dispatch by the
148
+ `request()` function:
149
+
150
+ ```typescript
151
+ const saveConfig = apiAction<string, unknown>({
152
+ name: "config.save",
153
+ request: (yaml) => ({
154
+ method: "PUT",
155
+ path: "/api/config",
156
+ rawBody: yaml,
157
+ headers: { "Content-Type": "text/yaml" },
158
+ }),
159
+ });
160
+ ```
161
+
162
+ `run()` on a plain `defineAction` remains the universal escape hatch for wire
163
+ contracts beyond these seams.
164
+
96
165
  ## API
97
166
 
98
167
  - `configure(notifier)` — inject the notification adapter
99
168
  - `configureApi(opts)` — configure the HTTP layer (baseUrl, headers, credentials, fetchFn)
100
169
  - `configureTransport(fn)` — inject the streaming transport adapter
101
- - `defineAction(def)` — create an action from a declarative definition
102
- - `apiAction(def)` — create an HTTP-backed action (uses `fetch`)
170
+ - `defineAction(def)` — create an action from a declarative definition. Action names should be unique: a duplicate name gets a one-time console warning, since the registry log and the name-keyed helpers (`isPending`, `subscribeByName`, `bindLoadingState`) conflate same-named definitions
171
+ - `apiAction(def)` — create an HTTP-backed action (uses `fetch`); optional `decode` / `decodeError` hooks own nonstandard-envelope interpretation (see above)
103
172
  - `transportAction(def)` — create a transport/SSE-backed action
104
173
  - `debouncedDispatch(action, opts)` — debounce wrapper
105
174
  - `pollAction(action, args, opts)` — interval polling with pause/backoff
106
- - `bindLoadingState(name, el, opts?)` — bind an element's disabled/aria-busy state to action pending; a reactive effect over the pending signals
175
+ - `bindLoadingState(name, el, opts?)` — bind an element's disabled/aria-busy state to action pending; a reactive effect over the pending signals. The binding auto-disposes when the element leaves the DOM — an element re-attached later (e.g. a list re-render reusing nodes) is no longer bound and needs a fresh `bindLoadingState` call
107
176
  - `pollUntil(step, opts)` — poll until a terminal condition (wait-then-poll, `until` predicate, `maxAttempts`/`timeoutMs` budgets, backoff-on-transient); returns `{status:'done'|'timeout'|'aborted'}`. A standalone sibling to `pollAction` for one-shot terminal-state waits.
108
177
  - `withAsyncFeedback(btn, fn, opts?)` — per-button async feedback (spinner → ✓/✗ → restore) with a re-entry guard + sr-only announce + injectable glyphs. `target?: HTMLElement` runs the cycle on a child slot via in-place element replacement (siblings/label untouched); `resetMs: 0` persists the outcome glyph (no auto-revert).
109
178
  - `subscribeToActions(fn)` — subscribe to all lifecycle events (discrete event stream)
@@ -117,6 +186,8 @@ const action = apiAction({
117
186
  - `classifyFetchError(err)` — classify fetch errors (network vs timeout vs HTTP)
118
187
  - `hasErrorString(err)` — type guard for objects with a `.message` string
119
188
  - `RETRY_STANDARD` — standard retry config (2 retries, 300ms)
189
+ - `IDEMPOTENCY_HEADER` — the `Idempotency-Key` HTTP header name `apiAction` sets from `ctx.idempotencyKey`; import it in custom `run()` implementations instead of hand-copying the literal
190
+ - `IDEMPOTENCY_COMMAND_FIELD` — the `idempotency_key` command field `transportAction` injects; same sharing purpose for custom transport runners
120
191
 
121
192
  > `withTimeout(signal, ms)` and `API_TIMEOUT_MS` moved to [`@cplieger/fetch`](https://github.com/cplieger/fetch) (the layer that owns timeout composition); import them from there.
122
193
 
@@ -167,6 +238,35 @@ handle.abort();
167
238
  const result = await handle;
168
239
  ```
169
240
 
241
+ ### Typed outcome accessor (`handle.outcome`)
242
+
243
+ The handle's own resolution is deliberately never-rejecting `TResult | null`,
244
+ which collapses three terminal states — and makes a legitimately-`null`
245
+ result indistinguishable from failure. `handle.outcome` is the opt-in typed
246
+ accessor for callers that need the distinction:
247
+
248
+ ```typescript
249
+ const handle = action.dispatch(args);
250
+ const outcome = await handle.outcome; // never rejects
251
+ switch (outcome.status) {
252
+ case "success":
253
+ use(outcome.value); // TResult — including a legitimate null
254
+ break;
255
+ case "error":
256
+ show(outcome.error.message); // the normalized ActionErrorLike
257
+ break;
258
+ case "cancelled":
259
+ break; // abort() / action.cancel() / timeout-as-abort
260
+ }
261
+ ```
262
+
263
+ `outcome.attempts` carries the run count when the dispatch actually ran
264
+ (retries increment it); a dedupe-joined dispatch resolves with the shared
265
+ result but no attempts. The never-rejecting promise and the callback tiers
266
+ (`onSuccess`/`onError`/`onSettled`) remain the canonical surface — reach for
267
+ `.outcome` when a call site consumes the terminal state inline instead of
268
+ wiring callbacks.
269
+
170
270
  ### Timeout option
171
271
 
172
272
  `ActionDefinition` accepts a `timeout` (ms) that aborts `run()` via `AbortSignal.timeout()`:
@@ -193,7 +293,6 @@ The following features are intentionally not implemented:
193
293
  | Debounce `maxWait` | Deliberate simplification. Use `flush()` for guaranteed-fire semantics. |
194
294
  | Throttle helper | Not action-specific. Consumers can throttle before calling `dispatch()`. |
195
295
  | `condition` / pre-execution guard | Trivially implemented by callers with `if`. `dedupe` covers the primary use case. |
196
- | Typed discriminated-union result | The callback model (onSuccess/onError/onSettled) is the chosen API shape. |
197
296
  | `onProgress` callback | Transport-specific. Consumers wire progress in their `run()` implementation. |
198
297
  | Batch dispatch | Store-level concern. This library doesn't own a store. |
199
298
  | `dispose()` / action deregistration | Actions are lightweight when idle. Not a leak concern for realistic app sizes. |
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "3.0.1",
3
+ "version": "3.1.1",
4
4
  "exports": {
5
5
  ".": "./src/index.ts",
6
6
  "./testing": "./src/testing.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "3.0.1",
3
+ "version": "3.1.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,8 +13,13 @@
13
13
  "test": "vitest --run"
14
14
  },
15
15
  "dependencies": {
16
- "@cplieger/fetch": "2.0.0",
17
- "@cplieger/reactive": "1.2.5"
16
+ "@cplieger/fetch": "^2.1.0",
17
+ "@cplieger/reactive": "^1.2.5"
18
+ },
19
+ "overrides": {
20
+ "typed-rest-client": {
21
+ "qs": "6.15.3"
22
+ }
18
23
  },
19
24
  "devDependencies": {
20
25
  "@eslint/js": "10.0.1",
package/src/api.ts CHANGED
@@ -11,7 +11,13 @@ import type { ApiErr, FetchConfig, FetchInstance, RequestOptions } from "@cplieg
11
11
 
12
12
  import { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
13
13
  import { ActionError } from "./error.js";
14
- import type { Action, ActionContext, ActionDefinition, RequestSpec } from "./types.js";
14
+ import type {
15
+ Action,
16
+ ActionContext,
17
+ ActionDefinition,
18
+ ActionErrorLike,
19
+ RequestSpec,
20
+ } from "./types.js";
15
21
 
16
22
  const JSON_CT = "application/json";
17
23
 
@@ -91,6 +97,35 @@ export function _resetApiConfigForTest(): void {
91
97
 
92
98
  // ---------------------------------------------------------------------------
93
99
 
100
+ /** Context passed to the {@link ApiActionDefinition.decode} hook. */
101
+ export interface ApiDecodeContext {
102
+ /** HTTP status of the 2xx response being decoded. */
103
+ readonly status: number;
104
+ /** The request descriptor this response answers. */
105
+ readonly spec: RequestSpec;
106
+ }
107
+
108
+ /** A failed request as seen by {@link ApiActionDefinition.decodeError}: the
109
+ * real HTTP response's status, the message the fetch layer lifted, the
110
+ * server-supplied code (when present), the parsed JSON body (when one
111
+ * parsed), and the response headers. Transport-level failures (network /
112
+ * timeout / cancellation / invalid — status 0) never reach the hook. */
113
+ export interface ApiErrorInfo {
114
+ readonly status: number;
115
+ readonly message: string;
116
+ readonly code?: string;
117
+ /** Parsed JSON body of the failed response. Server-controlled input —
118
+ * validate the shape before reading fields. */
119
+ readonly body?: unknown;
120
+ readonly headers?: Headers;
121
+ }
122
+
123
+ /** Return shape of {@link ApiActionDefinition.decodeError}: resolve the
124
+ * dispatch as a success carrying `value`, or replace the default error. */
125
+ export type ApiErrorDecision<TResult> =
126
+ | { readonly kind: "success"; readonly value: TResult }
127
+ | { readonly kind: "error"; readonly error: ActionErrorLike };
128
+
94
129
  /** Caller-facing shape of an apiAction definition. Replaces `run` with
95
130
  * a `request` function that returns an HTTP {@link RequestSpec}. */
96
131
  export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit<
@@ -98,6 +133,26 @@ export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit
98
133
  "run"
99
134
  > {
100
135
  request: (args: TArgs) => RequestSpec;
136
+
137
+ /** Decode / validate a 2xx response body. Receives the raw decoded body
138
+ * (`undefined` for a 204 / empty-body response) and returns the action
139
+ * result; throw (an {@link ActionError}) to route the dispatch to the
140
+ * error branch instead (rollback + error notification + registry error) —
141
+ * the seam for 200-with-error envelopes. Absent: the body is returned as
142
+ * `TResult` unchanged. */
143
+ decode?: (data: unknown, ctx: ApiDecodeContext) => TResult;
144
+
145
+ /** Reinterpret a failed request that produced a real HTTP response. Return
146
+ * `{ kind: "success", value }` to resolve the dispatch as success (e.g. a
147
+ * 409 whose body is a meaningful payload), `{ kind: "error", error }` to
148
+ * replace the default error, or `undefined` to keep the default
149
+ * {@link ActionError} mapping. Transport failures (status 0: network /
150
+ * timeout / cancelled / invalid) never reach this hook, so retry
151
+ * classification and cancellation semantics are preserved. */
152
+ decodeError?: (
153
+ info: ApiErrorInfo,
154
+ ctx: { readonly spec: RequestSpec },
155
+ ) => ApiErrorDecision<TResult> | undefined;
101
156
  }
102
157
 
103
158
  /**
@@ -109,12 +164,12 @@ export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit
109
164
  export function apiAction<TArgs, TResult = unknown, TOp = unknown>(
110
165
  def: ApiActionDefinition<TArgs, TResult, TOp>,
111
166
  ): Action<TArgs, TResult> {
112
- const { request, ...rest } = def;
167
+ const { request, decode, decodeError, ...rest } = def;
113
168
  return defineAction<TArgs, TResult, TOp>({
114
169
  ...rest,
115
170
  run: async (args, signal, ctx) => {
116
171
  const spec = request(args);
117
- return executeRequest<TResult>(spec, signal, ctx);
172
+ return executeRequest<TResult>(spec, signal, ctx, decode, decodeError);
118
173
  },
119
174
  });
120
175
  }
@@ -123,6 +178,11 @@ async function executeRequest<T>(
123
178
  spec: RequestSpec,
124
179
  signal: AbortSignal,
125
180
  ctx?: ActionContext,
181
+ decode?: (data: unknown, dctx: ApiDecodeContext) => T,
182
+ decodeError?: (
183
+ info: ApiErrorInfo,
184
+ dctx: { readonly spec: RequestSpec },
185
+ ) => ApiErrorDecision<T> | undefined,
126
186
  ): Promise<T> {
127
187
  // Build the request headers here (not via fetch's prepareHeaders seam):
128
188
  // actions' hook is spec-aware and receives { spec }, which fetch's plain
@@ -163,12 +223,24 @@ async function executeRequest<T>(
163
223
  timeoutMs: API_TIMEOUT_MS,
164
224
  headers: effectiveHeaders,
165
225
  };
166
- if (spec.method !== "GET" && spec.body !== undefined) {
167
- opts.body = spec.body;
226
+ if (spec.method !== "GET") {
227
+ if (spec.rawBody !== undefined) {
228
+ // Pre-encoded body: fetch sends it as-is (no JSON encoding, no
229
+ // automatic Content-Type — spec.headers carries the type).
230
+ opts.rawBody = spec.rawBody;
231
+ } else if (spec.body !== undefined) {
232
+ opts.body = spec.body;
233
+ }
168
234
  }
169
235
 
170
236
  const result = await apiFetch.requestRaw<T>(spec.method, spec.path, opts);
171
237
  if (result.ok) {
238
+ if (decode !== undefined) {
239
+ // The decoder owns 2xx interpretation: its return is the action result,
240
+ // its throw routes the dispatch to the error branch (the seam for
241
+ // 200-with-error envelopes).
242
+ return decode(result.data, { status: result.status, spec });
243
+ }
172
244
  // fetch collapses a 204 and an empty-body 2xx to data === undefined. Warn
173
245
  // on an unexpected empty body (a non-204, non-DELETE response), as before.
174
246
  if (result.data === undefined && result.status !== 204 && spec.method !== "DELETE") {
@@ -178,6 +250,34 @@ async function executeRequest<T>(
178
250
  }
179
251
  return result.data;
180
252
  }
253
+ // decodeError sees only real HTTP responses (status > 0): transport-level
254
+ // failures keep the default mapping so retryNetwork classification and
255
+ // cancellation semantics cannot be accidentally rewritten by the hook.
256
+ if (decodeError !== undefined && result.status > 0) {
257
+ const decision = decodeError(
258
+ {
259
+ status: result.status,
260
+ message: result.error,
261
+ ...(result.code !== undefined && { code: result.code }),
262
+ ...(result.body !== undefined && { body: result.body }),
263
+ ...(result.headers !== undefined && { headers: result.headers }),
264
+ },
265
+ { spec },
266
+ );
267
+ if (decision !== undefined) {
268
+ if (decision.kind === "success") {
269
+ return decision.value;
270
+ }
271
+ const e = decision.error;
272
+ throw e instanceof ActionError
273
+ ? e
274
+ : new ActionError(e.message, {
275
+ ...(e.status !== undefined && { status: e.status }),
276
+ ...(e.code !== undefined && { code: e.code }),
277
+ ...(e.cause !== undefined && { cause: e.cause }),
278
+ });
279
+ }
280
+ }
181
281
  throw actionErrorFromApiErr(result);
182
282
  }
183
283
 
package/src/debounce.ts CHANGED
@@ -59,7 +59,10 @@ export function debouncedDispatch<TArgs, TResult>(
59
59
  void action.dispatch(args);
60
60
  lastFiredAt = now;
61
61
  lastArgs = undefined;
62
- pending = true;
62
+ // Nothing is scheduled after a leading-edge fire — the timer below is a
63
+ // cooldown re-arm, not a pending dispatch — so isPending() reads false
64
+ // until a call lands inside the quiet window (doc contract above).
65
+ pending = false;
63
66
  if (timer !== undefined) {
64
67
  clearTimeout(timer);
65
68
  }
@@ -88,7 +91,9 @@ export function debouncedDispatch<TArgs, TResult>(
88
91
  lastArgs = undefined;
89
92
  if (a !== undefined) {
90
93
  lastFiredAt = Date.now();
91
- pending = true;
94
+ // The coalesced args fire now; the re-armed timer is a cooldown window
95
+ // with nothing scheduled in it yet, so isPending() reads false.
96
+ pending = false;
92
97
  timer = setTimeout(fireTrailing, opts.wait);
93
98
  void action.dispatch(a);
94
99
  } else {
package/src/define.ts CHANGED
@@ -19,12 +19,21 @@ import type {
19
19
  ActionContext,
20
20
  ActionDefinition,
21
21
  ActionErrorLike,
22
+ ActionOutcome,
22
23
  DispatchHandle,
23
24
  DispatchOptions,
24
25
  } from "./types.js";
25
26
 
26
27
  let instanceCounter = 0;
27
28
 
29
+ /** Monotonic per-definition id, prefixed onto dedupe keys so two definitions
30
+ * that happen to share a name can never join each other's in-flight promise
31
+ * (the cross-definition `as TResult` type-confusion hazard). */
32
+ let defCounter = 0;
33
+
34
+ /** Names seen by defineAction, for the duplicate-name diagnostic. */
35
+ const registeredNames = new Set<string>();
36
+
28
37
  const NO_OPTS = Object.freeze({}) as DispatchOptions;
29
38
  const NOOP = (): void => {
30
39
  /* noop */
@@ -60,10 +69,15 @@ function generateIdempotencyKey(): string {
60
69
 
61
70
  const scopeChains = new Map<string, Promise<unknown>>();
62
71
 
63
- /** Create a DispatchHandle: a Promise augmented with abort(). */
64
- function makeHandle<T>(promise: Promise<T | null>, abortFn: () => void): DispatchHandle<T> {
65
- const handle = promise as DispatchHandle<T>;
72
+ /** Create a DispatchHandle: a Promise augmented with abort() + outcome. */
73
+ function makeHandle<T>(
74
+ promise: Promise<T | null>,
75
+ abortFn: () => void,
76
+ outcome: Promise<ActionOutcome<T>>,
77
+ ): DispatchHandle<T> {
78
+ const handle = promise as DispatchHandle<T> & { outcome: Promise<ActionOutcome<T>> };
66
79
  handle.abort = abortFn;
80
+ handle.outcome = outcome;
67
81
  return handle;
68
82
  }
69
83
 
@@ -81,6 +95,15 @@ const activeDedupes = new Map<string, DedupeSlot>();
81
95
  export function defineAction<TArgs, TResult, TOp = unknown>(
82
96
  def: ActionDefinition<TArgs, TResult, TOp>,
83
97
  ): Action<TArgs, TResult> {
98
+ defCounter += 1;
99
+ const defId = defCounter;
100
+ if (registeredNames.has(def.name)) {
101
+ console.warn(
102
+ `[actions] duplicate action name "${def.name}" — the registry log and name-keyed helpers (isPending, subscribeByName, bindLoadingState) will conflate the two definitions`,
103
+ );
104
+ } else {
105
+ registeredNames.add(def.name);
106
+ }
84
107
  const inFlight = new Map<string, AbortController>();
85
108
  const started = new Set<string>();
86
109
  const scopeSkipResolvers = new Map<string, () => void>();
@@ -112,11 +135,48 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
112
135
  });
113
136
  }
114
137
  }
138
+ /** Fire the definition-level then per-dispatch success callbacks — the one
139
+ * block shared by the executing path and the dedupe-join path. */
140
+ function fireSuccessCallbacks(
141
+ result: TResult,
142
+ args: TArgs,
143
+ opts: DispatchOptions<TArgs, TResult>,
144
+ ): void {
145
+ fireDefSuccess(result, args);
146
+ const onSuccessCb = opts.onSuccess;
147
+ if (onSuccessCb) {
148
+ safeInvoke(def.name, "onSuccess", () => {
149
+ onSuccessCb(result, args);
150
+ });
151
+ }
152
+ }
153
+ /** Fire the definition-level then per-dispatch error callbacks — the one
154
+ * block shared by the executing path and the dedupe-join path. */
155
+ function fireErrorCallbacks(
156
+ err: ActionErrorLike,
157
+ args: TArgs,
158
+ opts: DispatchOptions<TArgs, TResult>,
159
+ ): void {
160
+ fireDefError(err, args);
161
+ const onErrorCb = opts.onError;
162
+ if (onErrorCb) {
163
+ safeInvoke(def.name, "onError", () => {
164
+ onErrorCb(err, args);
165
+ });
166
+ }
167
+ }
115
168
 
116
169
  function dispatch(
117
170
  args: TArgs,
118
171
  opts: DispatchOptions<TArgs, TResult> = NO_OPTS,
119
172
  ): DispatchHandle<TResult> {
173
+ // Typed terminal outcome, resolved exactly once at whichever terminal
174
+ // point this dispatch reaches. Kept OUT of the legacy promise chain so
175
+ // existing resolution timing is untouched.
176
+ let resolveOutcome!: (o: ActionOutcome<TResult>) => void;
177
+ const outcomePromise = new Promise<ActionOutcome<TResult>>((r) => {
178
+ resolveOutcome = r;
179
+ });
120
180
  const fireSettledHooks = (): void => {
121
181
  fireDefSettled(args);
122
182
  const onSettledCb = opts.onSettled;
@@ -133,49 +193,40 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
133
193
  const shared = entry.promise;
134
194
  if (shared === undefined) {
135
195
  fireSettledHooks();
136
- return makeHandle(Promise.resolve(null) as Promise<TResult | null>, NOOP);
196
+ resolveOutcome({ status: "cancelled" });
197
+ return makeHandle(Promise.resolve(null) as Promise<TResult | null>, NOOP, outcomePromise);
137
198
  }
138
199
  const joined = (shared as Promise<TResult | null>).then(
139
200
  (v) => {
140
201
  if (v !== null || entry.succeeded === true) {
141
- fireDefSuccess(v as TResult, args);
142
- const onSuccessCb = opts.onSuccess;
143
- if (onSuccessCb) {
144
- safeInvoke(def.name, "onSuccess", () => {
145
- onSuccessCb(v as TResult, args);
146
- });
147
- }
202
+ fireSuccessCallbacks(v as TResult, args, opts);
203
+ resolveOutcome({ status: "success", value: v as TResult });
148
204
  } else if (entry.error !== undefined) {
149
205
  const capturedErr = entry.error;
150
- fireDefError(capturedErr, args);
151
- const onErrorCb = opts.onError;
152
- if (onErrorCb) {
153
- safeInvoke(def.name, "onError", () => {
154
- onErrorCb(capturedErr, args);
155
- });
156
- }
206
+ fireErrorCallbacks(capturedErr, args, opts);
207
+ resolveOutcome({ status: "error", error: capturedErr });
157
208
  } else if (entry.cancelled !== true) {
158
209
  const dedupeErr: ActionErrorLike = {
159
210
  message: "deduped dispatch did not succeed",
160
211
  code: "dedupe",
161
212
  };
162
- fireDefError(dedupeErr, args);
163
- const onErrorCb = opts.onError;
164
- if (onErrorCb) {
165
- safeInvoke(def.name, "onError", () => {
166
- onErrorCb(dedupeErr, args);
167
- });
168
- }
213
+ fireErrorCallbacks(dedupeErr, args, opts);
214
+ resolveOutcome({ status: "error", error: dedupeErr });
215
+ } else {
216
+ resolveOutcome({ status: "cancelled" });
169
217
  }
170
218
  fireSettledHooks();
171
219
  return v;
172
220
  },
173
- () => {
221
+ (reason: unknown) => {
222
+ // Defensive: runOnce never rejects; a rejection here means the
223
+ // shared promise itself failed. Surface it as an error outcome.
224
+ resolveOutcome({ status: "error", error: toActionError(reason) });
174
225
  fireSettledHooks();
175
226
  return null;
176
227
  },
177
228
  );
178
- return makeHandle(joined, NOOP);
229
+ return makeHandle(joined, NOOP, outcomePromise);
179
230
  }
180
231
  }
181
232
 
@@ -195,11 +246,11 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
195
246
 
196
247
  let result: Promise<TResult | null>;
197
248
  if (scopeKey === null) {
198
- result = runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt);
249
+ result = runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt, resolveOutcome);
199
250
  } else {
200
251
  const prev = scopeChains.get(scopeKey) ?? Promise.resolve();
201
252
  const next = prev.then(() =>
202
- runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt),
253
+ runOnce(args, opts, ac, id, dedupeEntry, dedupeKey, dispatchedAt, resolveOutcome),
203
254
  );
204
255
  let tailResolve!: () => void;
205
256
  const tail = new Promise<void>((r) => {
@@ -241,6 +292,7 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
241
292
  });
242
293
  } finally {
243
294
  fireSettledHooks();
295
+ resolveOutcome({ status: "cancelled" });
244
296
  earlyCancelResolve(null);
245
297
  }
246
298
  });
@@ -259,9 +311,13 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
259
311
  });
260
312
  }
261
313
 
262
- return makeHandle(result, () => {
263
- ac.abort();
264
- });
314
+ return makeHandle(
315
+ result,
316
+ () => {
317
+ ac.abort();
318
+ },
319
+ outcomePromise,
320
+ );
265
321
  }
266
322
 
267
323
  function dedupeKeyFor(args: TArgs): string | null {
@@ -270,7 +326,10 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
270
326
  return null;
271
327
  }
272
328
  const argKey = typeof cfg === "function" ? cfg(args) : safeStringify(args);
273
- return `${def.name}::${argKey}`;
329
+ // defId prefix: dedupe joins are per-definition by contract. Without it,
330
+ // two definitions sharing a name would join each other's in-flight
331
+ // promise and coerce a foreign result via `as TResult`.
332
+ return `${String(defId)}::${def.name}::${argKey}`;
274
333
  }
275
334
 
276
335
  function evictDedupeSlot(dk: string | null, entry: DedupeSlot | null): void {
@@ -288,10 +347,12 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
288
347
  dedupeEntry: DedupeSlot | null,
289
348
  dedupeKey: string | null,
290
349
  dispatchedAt: number,
350
+ resolveOutcome: (o: ActionOutcome<TResult>) => void,
291
351
  ): Promise<TResult | null> {
292
352
  started.add(id);
293
353
  if (!inFlight.has(id) && ac.signal.aborted) {
294
354
  started.delete(id);
355
+ resolveOutcome({ status: "cancelled" });
295
356
  return null;
296
357
  }
297
358
  const settle = (): void => {
@@ -321,6 +382,7 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
321
382
  completedAt: now,
322
383
  });
323
384
  fireDefSettled(args);
385
+ resolveOutcome({ status: "cancelled" });
324
386
  settle();
325
387
  return null;
326
388
  }
@@ -365,14 +427,9 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
365
427
  error: err,
366
428
  });
367
429
  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
- }
430
+ fireErrorCallbacks(err, args, opts);
375
431
  fireDefSettled(args);
432
+ resolveOutcome({ status: "error", error: err });
376
433
  settle();
377
434
  return null;
378
435
  }
@@ -413,6 +470,7 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
413
470
  }
414
471
  }
415
472
  fireDefSettled(args);
473
+ resolveOutcome({ status: "cancelled" });
416
474
  return null;
417
475
  }
418
476
  record({
@@ -431,14 +489,9 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
431
489
  }
432
490
  evictDedupeSlot(dedupeKey, dedupeEntry);
433
491
  emitSuccessToast(args, result, opts);
434
- fireDefSuccess(result, args);
435
- const onSuccessCb = opts.onSuccess;
436
- if (onSuccessCb) {
437
- safeInvoke(def.name, "onSuccess", () => {
438
- onSuccessCb(result, args);
439
- });
440
- }
492
+ fireSuccessCallbacks(result, args, opts);
441
493
  fireDefSettled(args);
494
+ resolveOutcome({ status: "success", value: result, attempts });
442
495
  return result;
443
496
  } catch (e: unknown) {
444
497
  const err = toActionError(e);
@@ -474,15 +527,18 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
474
527
  }
475
528
  if (!cancelled) {
476
529
  emitErrorToast(args, err);
477
- fireDefError(err, args);
478
- const onErrorCb = opts.onError;
479
- if (onErrorCb) {
480
- safeInvoke(def.name, "onError", () => {
481
- onErrorCb(err, args);
482
- });
483
- }
530
+ fireErrorCallbacks(err, args, opts);
484
531
  }
485
532
  fireDefSettled(args);
533
+ if (cancelled) {
534
+ resolveOutcome({ status: "cancelled" });
535
+ } else {
536
+ resolveOutcome({
537
+ status: "error",
538
+ error: err,
539
+ ...(attempts !== undefined && { attempts }),
540
+ });
541
+ }
486
542
  return null;
487
543
  } finally {
488
544
  settle();
@@ -688,9 +744,12 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
688
744
  return action;
689
745
  }
690
746
 
691
- /** Test-only: reset the instance counter + scope chains + dedupe map. */
747
+ /** Test-only: reset the instance/definition counters + name registry +
748
+ * scope chains + dedupe map. */
692
749
  export function _resetForTest(): void {
693
750
  instanceCounter = 0;
751
+ defCounter = 0;
752
+ registeredNames.clear();
694
753
  _resetSymbols();
695
754
  scopeChains.clear();
696
755
  activeDedupes.clear();
package/src/index.ts CHANGED
@@ -6,13 +6,19 @@ export { configure } from "./notifier.js";
6
6
  export type { Notifier, NotifierRetry } from "./notifier.js";
7
7
 
8
8
  // Transport injection
9
- export { configureTransport, transportAction } from "./transport.js";
9
+ export { configureTransport, transportAction, IDEMPOTENCY_COMMAND_FIELD } from "./transport.js";
10
10
  export type { TransportSendResult, TransportCommand, TransportSendFn } from "./transport.js";
11
11
 
12
12
  // Action factories
13
- export { defineAction } from "./define.js";
13
+ export { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
14
14
  export { apiAction, configureApi } from "./api.js";
15
- export type { ApiConfig, ApiActionDefinition } from "./api.js";
15
+ export type {
16
+ ApiConfig,
17
+ ApiActionDefinition,
18
+ ApiDecodeContext,
19
+ ApiErrorInfo,
20
+ ApiErrorDecision,
21
+ } from "./api.js";
16
22
 
17
23
  // Error class + utilities
18
24
  export { ActionError, hasErrorString, classifyFetchError, retryNetwork } from "./error.js";
@@ -56,6 +62,7 @@ export type {
56
62
  ActionErrorLike,
57
63
  ActionInstance,
58
64
  ActionLifecycleStatus,
65
+ ActionOutcome,
59
66
  DispatchHandle,
60
67
  DispatchOptions,
61
68
  NotificationSpec,
package/src/notifier.ts CHANGED
@@ -25,23 +25,52 @@ export interface Notifier {
25
25
  }
26
26
 
27
27
  let _notifier: Notifier = {};
28
+ let _configured = false;
29
+ let _warnedUnconfigured = false;
28
30
 
29
- /** Configure the global notifier adapter. Call once at app boot. */
31
+ /** Configure the global notifier adapter. Call once at app boot. Passing an
32
+ * empty object (`configure({})`) is the explicit headless opt-in: it keeps
33
+ * notifications silently dropped without the unconfigured-drop warning. */
30
34
  export function configure(notifier: Notifier): void {
31
35
  _notifier = notifier;
36
+ _configured = true;
37
+ }
38
+
39
+ /** Warn once, on the first notification dropped because configure() was
40
+ * never called — the silent-by-default footgun. An explicit configure()
41
+ * (even with missing methods) is a deliberate headless choice and stays
42
+ * silent, matching the documented Notifier contract. */
43
+ function warnUnconfiguredDrop(kind: string): void {
44
+ if (_configured || _warnedUnconfigured) {
45
+ return;
46
+ }
47
+ _warnedUnconfigured = true;
48
+ console.warn(
49
+ `[actions] ${kind} notification dropped — configure() was never called. Wire a notifier at boot, or call configure({}) for intentional headless silence. This warning fires once.`,
50
+ );
32
51
  }
33
52
 
34
53
  /** @internal Emit a success notification. */
35
54
  export function notifySuccess(message: string): void {
36
- _notifier.success?.(message);
55
+ if (_notifier.success === undefined) {
56
+ warnUnconfiguredDrop("success");
57
+ return;
58
+ }
59
+ _notifier.success(message);
37
60
  }
38
61
 
39
62
  /** @internal Emit an error notification. */
40
63
  export function notifyError(message: string, retry?: NotifierRetry): void {
41
- _notifier.error?.(message, retry);
64
+ if (_notifier.error === undefined) {
65
+ warnUnconfiguredDrop("error");
66
+ return;
67
+ }
68
+ _notifier.error(message, retry);
42
69
  }
43
70
 
44
71
  /** @internal Test-only: reset the notifier to the default no-op. */
45
72
  export function _resetNotifierForTest(): void {
46
73
  _notifier = {};
74
+ _configured = false;
75
+ _warnedUnconfigured = false;
47
76
  }
package/src/registry.ts CHANGED
@@ -1,16 +1,46 @@
1
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.
2
+ // subscribe API. Fires per state transition.
4
3
  //
5
- // Performance: eviction uses a head-pointer + tombstones instead of
6
- // splice + O(n) index re-computation. record() is O(1) amortized.
4
+ // Shape: two single-purpose structures with disjoint lifetimes, so the
5
+ // pending accounting cannot desync from log eviction by construction.
6
+ //
7
+ // - `inflight` holds the pending dispatches. Entries live exactly as
8
+ // long as their dispatch is in flight; the table never evicts by
9
+ // count (only the leak watchdog reclaims past MAX_INFLIGHT), so a
10
+ // pending entry can never be displaced by log churn.
11
+ // - `settled` holds terminal snapshots, latest-per-dispatch-id,
12
+ // bounded to MAX_LOG_SIZE by evicting the lowest-seq entry
13
+ // (first-record order — the same victim the old single-array
14
+ // shape picked).
15
+ //
16
+ // A per-dispatch monotonic `seq`, stamped at first record, orders the
17
+ // recomposed getActionLog() view identically to the old array's
18
+ // insertion order.
19
+ //
20
+ // Transition table for record(instance). The caller contract
21
+ // (define.ts) records one pending then at most one terminal per
22
+ // dispatch id, and an id's `name` never changes across records (ids
23
+ // embed their action name). Every cell is still defined so an
24
+ // out-of-contract input degrades to sane accounting rather than a
25
+ // desync; reentrant records from synchronous signal effects land on
26
+ // coherent state because each transition completes its map moves
27
+ // before publishing signals:
28
+ //
29
+ // status | id in inflight | id in settled | id unknown
30
+ // ---------|----------------------|------------------------|--------------------
31
+ // pending | overwrite instance | move back to inflight | insert (new seq),
32
+ // | (keep seq) | (keep seq), re-index | index, watchdog
33
+ // terminal | move to settled | overwrite instance | insert into settled
34
+ // | (keep seq), unindex | (keep seq + position) | (new seq), evict
7
35
  //
8
36
  // Pending state (isPending / pendingCount) is mirrored into reactive signals,
9
37
  // so it can be read inside an effect — bindLoadingState is a plain effect over
10
- // these, not a bespoke subscription. The pendingByName Set stays the source of
11
- // truth for which ids are in flight; the signals expose the derived counts.
12
- // The lifecycle fan-out below (record → listeners) is a discrete event stream
13
- // and stays a plain emitter events are not reactive state.
38
+ // these, not a bespoke subscription. The pendingByName Set index over
39
+ // `inflight` is the per-name source of truth; the signals expose the derived
40
+ // counts, and the total is `inflight.size` (membership, not paired
41
+ // arithmetic an unpaired decrement is unrepresentable). The lifecycle
42
+ // fan-out below (record → listeners) is a discrete event stream and stays a
43
+ // plain emitter — events are not reactive state.
14
44
  // ---------------------------------------------------------------------------
15
45
 
16
46
  import { signal, batch, SignalMap } from "@cplieger/reactive";
@@ -18,154 +48,186 @@ import { signal, batch, SignalMap } from "@cplieger/reactive";
18
48
  import type { ActionInstance, RegistryListener } from "./types.js";
19
49
 
20
50
  const MAX_LOG_SIZE = 200;
21
- const MAX_LOG_HARD = MAX_LOG_SIZE * 5;
22
51
 
23
- const log: (ActionInstance | null)[] = [];
24
- interface LogSlot {
52
+ /** Leak watchdog threshold for the in-flight table. `settled` is bounded by
53
+ * construction, so only a dispatch that never settles (a lifecycle bug in
54
+ * the defining layer) can grow state — past this many in-flight entries the
55
+ * oldest is reclaimed with a console.warn naming it. This bounds the
56
+ * in-flight population specifically; the old shape's equivalent tier
57
+ * (MAX_LOG_HARD) bounded the combined log. */
58
+ const MAX_INFLIGHT = 1000;
59
+
60
+ interface LogEntry {
25
61
  instance: ActionInstance;
26
- index: number;
62
+ /** Monotonic stamp assigned at the dispatch id's FIRST record; preserved
63
+ * across every transition so the recomposed log keeps first-record order. */
64
+ readonly seq: number;
27
65
  }
28
- const idMap = new Map<string, LogSlot>();
66
+
67
+ const inflight = new Map<string, LogEntry>();
68
+ const settled = new Map<string, LogEntry>();
69
+ let seqCounter = 0;
70
+
29
71
  const listeners = new Set<RegistryListener>();
30
72
  const namedListeners = new Map<string, Set<RegistryListener>>();
31
73
  const pendingByName = new Map<string, Set<string>>();
32
- let _pendingTotal = 0;
33
- let _liveCount = 0;
34
- let _head = 0;
35
-
36
- // Test-only: when true, a detected _pendingTotal underflow throws instead of
37
- // the production warn + clamp, so an add/removePending pairing bug surfaces in
38
- // tests rather than being silently masked. Production default is false.
39
- // Toggled via _setThrowOnInvariantViolationForTest (same _*ForTest convention
40
- // as _resetForTest); reset to false by _resetForTest.
41
- let _throwOnInvariantViolation = false;
42
74
 
43
- // Reactive mirrors of the pending state. pendingByName remains the source of
44
- // truth; these signals expose the derived counts so isPending/pendingCount can
45
- // be read reactively (e.g. by bindLoadingState's effect).
75
+ // Reactive mirrors of the pending state. The inflight table + its name index
76
+ // remain the source of truth; these signals expose the derived counts so
77
+ // isPending/pendingCount can be read reactively (e.g. by bindLoadingState's
78
+ // effect).
46
79
  const pendingSigs = new SignalMap<number>();
47
80
  const pendingTotalSig = signal(0);
48
81
 
49
- function addPending(name: string, id: string): void {
82
+ /** Add `id` to the name index + refresh the signals. Call AFTER the inflight
83
+ * mutation so `inflight.size` is current. */
84
+ function indexPending(name: string, id: string): void {
50
85
  let s = pendingByName.get(name);
51
86
  if (s === undefined) {
52
87
  s = new Set();
53
88
  pendingByName.set(name, s);
54
89
  }
55
- if (s.has(id)) {
56
- return;
57
- }
58
90
  s.add(id);
59
- _pendingTotal++;
60
91
  const size = s.size;
61
92
  batch(() => {
62
93
  pendingSigs.ensure(name, 0).value = size;
63
- pendingTotalSig.value = _pendingTotal;
94
+ pendingTotalSig.value = inflight.size;
64
95
  });
65
96
  }
66
97
 
67
- function removePending(name: string, id: string): void {
98
+ /** Remove `id` from the name index + refresh the signals. Call AFTER the
99
+ * inflight mutation. No-op when the id was never indexed. */
100
+ function unindexPending(name: string, id: string): void {
68
101
  const s = pendingByName.get(name);
69
102
  if (!s?.delete(id)) {
70
103
  return;
71
104
  }
72
- _pendingTotal--;
73
105
  const size = s.size;
74
106
  if (size === 0) {
75
107
  pendingByName.delete(name);
76
108
  }
77
109
  batch(() => {
78
110
  pendingSigs.ensure(name, 0).value = size;
79
- pendingTotalSig.value = _pendingTotal;
111
+ pendingTotalSig.value = inflight.size;
80
112
  });
81
113
  }
82
114
 
83
- function compact(): void {
84
- while (_head < log.length && log[_head] === null) {
85
- _head++;
115
+ /** Bound `settled` by evicting the lowest-seq (first-record order) entry
116
+ * the same victim the old array shape's oldest-position-first scan picked.
117
+ * Map insertion order alone is settle order, not first-record order (a
118
+ * long-pending dispatch settles late), hence the ≤ (MAX_LOG_SIZE + 1)-entry
119
+ * scan. `protectId` shields the entry being recorded right now — load-
120
+ * bearing on the pending → terminal path (a long-lived pending that finally
121
+ * settles carries the lowest seq of the whole log and would otherwise evict
122
+ * itself the instant it completed; the old shape's own-id guard covered
123
+ * this). On the terminal-only path it is belt-and-braces: a fresh seq is
124
+ * never the lowest among older entries. */
125
+ function evictOldestSettled(protectId: string): void {
126
+ while (settled.size > MAX_LOG_SIZE) {
127
+ let oldestKey: string | undefined;
128
+ let oldestSeq = Infinity;
129
+ for (const [key, entry] of settled) {
130
+ if (key !== protectId && entry.seq < oldestSeq) {
131
+ oldestSeq = entry.seq;
132
+ oldestKey = key;
133
+ }
134
+ }
135
+ if (oldestKey === undefined) {
136
+ return;
137
+ }
138
+ settled.delete(oldestKey);
139
+ }
140
+ }
141
+
142
+ /** Reclaim the oldest in-flight entry once the table exceeds MAX_INFLIGHT,
143
+ * skipping `protectId` (the entry being recorded right now must not reclaim
144
+ * itself — a revived entry keeps its original, possibly-lowest seq). The
145
+ * reclaimed entry is dropped outright (not moved to `settled`: its status is
146
+ * still "pending", and the terminal log must not lie). Runs on every path
147
+ * that grows the table: unknown-pending insert AND terminal → pending
148
+ * revival. */
149
+ function reclaimLeakedPending(protectId: string): void {
150
+ if (inflight.size <= MAX_INFLIGHT) {
151
+ return;
86
152
  }
87
- if (_head > 256) {
88
- log.splice(0, _head);
89
- for (const entry of idMap.values()) {
90
- entry.index -= _head;
153
+ let oldestKey: string | undefined;
154
+ let oldestSeq = Infinity;
155
+ for (const [key, entry] of inflight) {
156
+ if (key !== protectId && entry.seq < oldestSeq) {
157
+ oldestSeq = entry.seq;
158
+ oldestKey = key;
91
159
  }
92
- _head = 0;
160
+ }
161
+ if (oldestKey === undefined) {
162
+ return;
163
+ }
164
+ const entry = inflight.get(oldestKey);
165
+ inflight.delete(oldestKey);
166
+ if (entry !== undefined) {
167
+ unindexPending(entry.instance.name, oldestKey);
168
+ console.warn(
169
+ `[actions] in-flight capacity exceeded (${String(MAX_INFLIGHT)} entries) — reclaiming oldest pending "${entry.instance.name}" (${oldestKey}). Check for dispatches that never settle.`,
170
+ );
93
171
  }
94
172
  }
95
173
 
96
174
  /** Record a state transition. Called by define.ts. */
97
175
  export function record(instance: ActionInstance): void {
98
- const existing = idMap.get(instance.id);
99
- if (existing !== undefined) {
100
- const prev = existing.instance;
101
- if (prev.status === "pending" && instance.status !== "pending") {
102
- removePending(prev.name, instance.id);
103
- } else if (prev.status !== "pending" && instance.status === "pending") {
104
- addPending(instance.name, instance.id);
105
- }
106
- log[existing.index] = instance;
107
- existing.instance = instance;
108
- if (instance.status !== "pending" && _liveCount > MAX_LOG_SIZE) {
109
- for (let i = _head; i < log.length; i++) {
110
- const entry = log[i];
111
- if (
112
- entry !== null &&
113
- entry !== undefined &&
114
- entry.status !== "pending" &&
115
- entry.id !== instance.id
116
- ) {
117
- idMap.delete(entry.id);
118
- log[i] = null;
119
- _liveCount--;
120
- if (_liveCount <= MAX_LOG_SIZE) {
121
- break;
122
- }
123
- }
176
+ const { id } = instance;
177
+ if (instance.status === "pending") {
178
+ const inf = inflight.get(id);
179
+ if (inf !== undefined) {
180
+ // Pending re-record: in-place overwrite, no accounting change.
181
+ inf.instance = instance;
182
+ } else {
183
+ const done = settled.get(id);
184
+ if (done !== undefined) {
185
+ // Terminal → pending re-record. Not produced by define.ts (one
186
+ // pending, then at most one terminal per id); defined anyway: move
187
+ // back, keep the original seq. Grows the table, so the watchdog
188
+ // runs here too (protecting the revived id itself).
189
+ settled.delete(id);
190
+ done.instance = instance;
191
+ inflight.set(id, done);
192
+ indexPending(instance.name, id);
193
+ reclaimLeakedPending(id);
194
+ } else {
195
+ seqCounter += 1;
196
+ inflight.set(id, { instance, seq: seqCounter });
197
+ indexPending(instance.name, id);
198
+ reclaimLeakedPending(id);
124
199
  }
125
- compact();
126
200
  }
127
201
  } else {
128
- const idx = log.length;
129
- log.push(instance);
130
- idMap.set(instance.id, { instance, index: idx });
131
- _liveCount++;
132
- if (instance.status === "pending") {
133
- addPending(instance.name, instance.id);
134
- }
135
- if (_liveCount > MAX_LOG_SIZE) {
136
- for (let i = _head; i < log.length; i++) {
137
- const entry = log[i];
138
- if (entry !== null && entry !== undefined && entry.status !== "pending") {
139
- idMap.delete(entry.id);
140
- log[i] = null;
141
- _liveCount--;
142
- break;
143
- }
144
- }
145
- }
146
- if (_liveCount > MAX_LOG_HARD) {
147
- for (let i = _head; i < log.length; i++) {
148
- const entry = log[i];
149
- if (entry !== null && entry !== undefined) {
150
- if (entry.status === "pending") {
151
- removePending(entry.name, entry.id);
152
- }
153
- idMap.delete(entry.id);
154
- log[i] = null;
155
- _liveCount--;
156
- break;
157
- }
202
+ const inf = inflight.get(id);
203
+ if (inf !== undefined) {
204
+ // The common transition: pending → terminal. Membership moves between
205
+ // the tables; the accounting decrements because the id LEFT inflight,
206
+ // not because a counter was paired correctly. Order matters: the
207
+ // entry is FULLY moved (settled + evicted) before unindexPending
208
+ // publishes signals, because reactive effects flush synchronously at
209
+ // that batch's end — an observer reading getActionLog()/pendingCount
210
+ // there must see the entry as terminal, never absent, and a reentrant
211
+ // same-id record from an effect must find it in `settled` (the revive
212
+ // cell) rather than minting a duplicate wrapper.
213
+ const indexedName = inf.instance.name;
214
+ inflight.delete(id);
215
+ inf.instance = instance;
216
+ settled.set(id, inf);
217
+ evictOldestSettled(id);
218
+ unindexPending(indexedName, id);
219
+ } else {
220
+ const done = settled.get(id);
221
+ if (done !== undefined) {
222
+ // Double-terminal record: latest-per-id, keep seq + position.
223
+ done.instance = instance;
224
+ } else {
225
+ // Terminal-only record (cancelled before start, optimistic failure).
226
+ seqCounter += 1;
227
+ settled.set(id, { instance, seq: seqCounter });
228
+ evictOldestSettled(id);
158
229
  }
159
230
  }
160
- compact();
161
- }
162
- if (_pendingTotal < 0) {
163
- if (_throwOnInvariantViolation) {
164
- throw new Error("[actions] _pendingTotal went negative — invariant violation");
165
- }
166
- console.warn("[actions] _pendingTotal went negative — invariant violation; clamping to 0");
167
- _pendingTotal = 0;
168
- pendingTotalSig.value = 0;
169
231
  }
170
232
  for (const fn of listeners) {
171
233
  try {
@@ -211,23 +273,20 @@ export function subscribeByName(name: string, fn: RegistryListener): () => void
211
273
 
212
274
  /** @internal Test-only public surface. */
213
275
  export function recentLog(): readonly ActionInstance[] {
214
- const result: ActionInstance[] = [];
215
- for (let i = _head; i < log.length; i++) {
216
- const entry = log[i];
217
- if (entry != null) {
218
- result.push(entry);
219
- }
220
- }
221
- return result;
276
+ const entries = [...inflight.values(), ...settled.values()];
277
+ entries.sort((a, b) => a.seq - b.seq);
278
+ return entries.map((e) => e.instance);
222
279
  }
223
280
 
224
281
  /** Read the recent action log. Useful for devtools integration and
225
282
  * debugging panels. Returns a snapshot of all live entries.
226
283
  *
227
- * SECURITY/PRIVACY: each entry retains the full dispatch `args` in memory (up to
228
- * MAX_LOG_SIZE entries), `subscribeToActions` fans `args` out to every listener, and
229
- * buildRetryButton retains a structuredClone of `args` in the error-notification
230
- * retry closure. Do NOT put secrets, tokens, or PII in action args. */
284
+ * SECURITY/PRIVACY: each entry retains the full dispatch `args` in memory (up
285
+ * to MAX_LOG_SIZE settled entries plus every in-flight dispatch, watchdog-
286
+ * bounded at MAX_INFLIGHT), `subscribeToActions` fans `args` out to every
287
+ * listener, and buildRetryButton retains a structuredClone of `args` in the
288
+ * error-notification retry closure. Do NOT put secrets, tokens, or PII in
289
+ * action args. */
231
290
  export const getActionLog = recentLog;
232
291
 
233
292
  /** O(1) check: true if at least one instance of the named action is pending.
@@ -250,34 +309,12 @@ export function pendingCount(names?: readonly string[]): number {
250
309
 
251
310
  /** Test-only: clear log + listeners. */
252
311
  export function _resetForTest(): void {
253
- log.length = 0;
254
- _head = 0;
255
- _liveCount = 0;
256
- idMap.clear();
312
+ inflight.clear();
313
+ settled.clear();
314
+ seqCounter = 0;
257
315
  pendingByName.clear();
258
316
  listeners.clear();
259
317
  namedListeners.clear();
260
- _pendingTotal = 0;
261
- _throwOnInvariantViolation = false;
262
318
  pendingSigs.clearAll();
263
319
  pendingTotalSig.value = 0;
264
320
  }
265
-
266
- /**
267
- * @internal Test-only: toggle whether a detected `_pendingTotal` underflow
268
- * throws (true) instead of the production warn + clamp (false). Lets tests
269
- * assert that an add/removePending pairing bug surfaces loudly. Reset to false
270
- * by `_resetForTest`.
271
- */
272
- export function _setThrowOnInvariantViolationForTest(enabled: boolean): void {
273
- _throwOnInvariantViolation = enabled;
274
- }
275
-
276
- /**
277
- * @internal Test-only: simulate an unpaired `removePending` (an add/remove
278
- * mismatch) by decrementing the pending total without a matching add, driving
279
- * the invariant negative. The next `record()` then hits the clamp branch.
280
- */
281
- export function _forcePendingUnderflowForTest(): void {
282
- _pendingTotal--;
283
- }
package/src/transport.ts CHANGED
@@ -31,6 +31,11 @@ export type TransportSendFn = (
31
31
  opts: { signal: AbortSignal },
32
32
  ) => Promise<TransportSendResult>;
33
33
 
34
+ /** Command field name carrying the per-dispatch idempotency key injected by
35
+ * {@link transportAction}. Exported so consumer-authored custom runners can
36
+ * share the wire convention instead of hand-copying the literal. */
37
+ export const IDEMPOTENCY_COMMAND_FIELD = "idempotency_key";
38
+
34
39
  let _send: TransportSendFn | undefined;
35
40
 
36
41
  /**
@@ -82,7 +87,7 @@ export function transportAction<TArgs, TOp = unknown>(
82
87
  const raw = command(args);
83
88
  let cmd: TransportCommand;
84
89
  if (ctx?.idempotencyKey !== undefined) {
85
- cmd = { ...raw, idempotency_key: ctx.idempotencyKey };
90
+ cmd = { ...raw, [IDEMPOTENCY_COMMAND_FIELD]: ctx.idempotencyKey };
86
91
  } else {
87
92
  cmd = raw;
88
93
  }
package/src/types.ts CHANGED
@@ -157,6 +157,16 @@ export interface Action<TArgs, TResult> {
157
157
  cancel(): void;
158
158
  }
159
159
 
160
+ /** Typed terminal outcome of a single dispatch, resolved by
161
+ * {@link DispatchHandle.outcome}. Distinguishes the three states the
162
+ * handle's own `TResult | null` resolution collapses: success (including a
163
+ * legitimate `null` / `undefined` result), failure (carrying the normalized
164
+ * error), and cancellation. */
165
+ export type ActionOutcome<TResult> =
166
+ | { readonly status: "success"; readonly value: TResult; readonly attempts?: number }
167
+ | { readonly status: "error"; readonly error: ActionErrorLike; readonly attempts?: number }
168
+ | { readonly status: "cancelled" };
169
+
160
170
  /** Handle returned by dispatch(). An augmented Promise with an abort()
161
171
  * method for per-dispatch cancellation (mirrors RTK createAsyncThunk).
162
172
  * Can be awaited directly as a Promise. */
@@ -164,6 +174,12 @@ export interface DispatchHandle<TResult> extends Promise<TResult | null> {
164
174
  /** Abort this specific dispatch. Other in-flight dispatches of the
165
175
  * same action are unaffected. Mirrors RTK's promise.abort(). */
166
176
  abort(): void;
177
+ /** Opt-in typed terminal outcome. Resolves alongside the handle (it never
178
+ * rejects) with a discriminated {@link ActionOutcome}, letting a caller
179
+ * distinguish a legitimate `null` result from failure or cancellation
180
+ * without wiring callbacks. The never-rejecting `TResult | null`
181
+ * resolution and the callback tiers remain the canonical surface. */
182
+ readonly outcome: Promise<ActionOutcome<TResult>>;
167
183
  }
168
184
 
169
185
  /** Per-dispatch overrides. */
@@ -189,7 +205,14 @@ export type RequestSpec =
189
205
  | {
190
206
  readonly method: "POST" | "PUT" | "PATCH" | "DELETE";
191
207
  readonly path: string;
208
+ /** JSON-encoded request body. Mutually exclusive with `rawBody`. */
192
209
  readonly body?: unknown;
210
+ /** Pre-encoded request body, sent as-is with NO JSON encoding and NO
211
+ * automatic Content-Type — set the type via `headers` (e.g. a YAML
212
+ * document with `Content-Type: text/yaml`). The request() function is
213
+ * the encoder seam: compute the encoded payload there per dispatch.
214
+ * Mutually exclusive with `body`. */
215
+ readonly rawBody?: BodyInit;
193
216
  readonly headers?: Readonly<Record<string, string>>;
194
217
  };
195
218