@cplieger/actions 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -5
- package/jsr.json +1 -1
- package/package.json +9 -4
- package/src/api.ts +105 -5
- package/src/debounce.ts +7 -2
- package/src/define.ts +114 -55
- package/src/index.ts +10 -3
- package/src/notifier.ts +32 -3
- package/src/registry.ts +183 -146
- package/src/transport.ts +6 -1
- package/src/types.ts +23 -0
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/actions",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
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.
|
|
17
|
-
"@cplieger/reactive": "1.2.
|
|
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",
|
|
@@ -29,7 +34,7 @@
|
|
|
29
34
|
"happy-dom": "20.10.6",
|
|
30
35
|
"prettier": "3.9.5",
|
|
31
36
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
32
|
-
"typescript-eslint": "8.
|
|
37
|
+
"typescript-eslint": "8.64.0",
|
|
33
38
|
"vitest": "4.1.10"
|
|
34
39
|
},
|
|
35
40
|
"description": "Declarative async UI-action framework for TypeScript",
|
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 {
|
|
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"
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>(
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
3
|
-
// window so memory usage stays flat over a long session.
|
|
2
|
+
// subscribe API. Fires per state transition.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
44
|
-
// truth; these signals expose the derived counts so
|
|
45
|
-
// be read reactively (e.g. by bindLoadingState's
|
|
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
|
-
|
|
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 =
|
|
94
|
+
pendingTotalSig.value = inflight.size;
|
|
64
95
|
});
|
|
65
96
|
}
|
|
66
97
|
|
|
67
|
-
|
|
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 =
|
|
111
|
+
pendingTotalSig.value = inflight.size;
|
|
80
112
|
});
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
if (
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
228
|
-
* MAX_LOG_SIZE entries
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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,
|
|
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
|
|