@ultimat3/action 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +34 -10
- package/README.md +18 -2
- package/package.json +6 -6
- package/src/errors-idempotency.ts +147 -0
- package/src/errors.ts +14 -112
- package/src/http.ts +8 -4
- package/src/idempotency-memory.ts +21 -11
- package/src/idempotency-postgres.ts +57 -19
- package/src/idempotency.ts +53 -10
- package/src/index.ts +16 -2
- package/src/policy-gate.ts +18 -1
- package/src/sample-input.ts +15 -3
package/CLAUDE.md
CHANGED
|
@@ -61,6 +61,21 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
61
61
|
author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping
|
|
62
62
|
it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts`
|
|
63
63
|
drives a row-level action over the real pipeline and counts the evaluations: exactly one.
|
|
64
|
+
- **`meta.auth` is derived from a WALK of the policy tree**, never from the root combinator.
|
|
65
|
+
`def.policy.kind === 'allow'` answered `'required'` for `or(allow(), can('x:y'))`, so the
|
|
66
|
+
pipeline's `auth` stage 401'd an anonymous caller the policy itself ALLOWS — while the MCP tool
|
|
67
|
+
and the job handle let that caller through the same object. One policy, a different answer per
|
|
68
|
+
surface, which is the thing `enforcedBy: 'handler'` exists to prevent. `'public'` here is not
|
|
69
|
+
"unguarded": `invoke` still evaluates the policy for every call.
|
|
70
|
+
**`admitsAnonymous` is `@ultimat3/policy`'s** (`policy.ts`, beside `policyPermissions`) and
|
|
71
|
+
reaches this package through `policy-gate.ts` like every other authz question — never a copy
|
|
72
|
+
here. It cannot be one: `@ultimat3/query` needs the identical answer and is the same tier, so a
|
|
73
|
+
copy in either is a second answer for the other, and the walk is a property of the combinators
|
|
74
|
+
`policy.ts` declares. It is EXACT rather than heuristic — with `actor === null`, `can()`
|
|
75
|
+
short-circuits before its predicate and `allow()`/`deny()` ignore their arguments, so the tree
|
|
76
|
+
alone decides. `packages/policy/src/policy.test.ts` asserts it against
|
|
77
|
+
`policy.run({ actor: null })` itself, case for case; `http.test.ts` proves this projection reads
|
|
78
|
+
the answer, over the real pipeline.
|
|
64
79
|
- **`stable.ts` holds the DOCUMENT form and NOTHING else, `As of 2026-08`.** `stableStringify` is
|
|
65
80
|
published as `openapi.json` by `serializeOpenApi` and re-read with `JSON.parse` by
|
|
66
81
|
`json-schema.ts`, so a non-finite number has to be `null` and a `Date` has to be its ISO string —
|
|
@@ -140,16 +155,25 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
140
155
|
What this does NOT close: an anonymous actor is one identity, so anonymous callers of a public
|
|
141
156
|
idempotent action still share a key space — nothing at this tier can tell two apart, and keying
|
|
142
157
|
on an IP or a cookie would break the retry the header exists to serve.
|
|
143
|
-
- **Both stores FENCE a settlement on `in-flight`**, as
|
|
144
|
-
`state = 'running'`. A reservation whose window
|
|
145
|
-
(`on conflict … do update`), so a straggler from the first
|
|
146
|
-
longer owned and the next replay answered a retry with a
|
|
147
|
-
Postgres fences in SQL and returns `key`, so the no-op is
|
|
148
|
-
the status it holds. It is logged and never thrown —
|
|
149
|
-
there would turn a durable write into the caller's error.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
- **Both stores FENCE a settlement on the reservation `id` AND on `in-flight`**, as
|
|
159
|
+
`@ultimat3/jobs`' `SQL_ACK` fences on `id = $1 and state = 'running'`. A reservation whose window
|
|
160
|
+
lapsed is reclaimed by the next caller (`on conflict … do update`), so a straggler from the first
|
|
161
|
+
one used to overwrite a record it no longer owned and the next replay answered a retry with a
|
|
162
|
+
value produced for a different request. Postgres fences in SQL and returns `key`, so the no-op is
|
|
163
|
+
observable and logged; memory checks the id and status it holds. It is logged and never thrown —
|
|
164
|
+
a settlement is post-commit, so raising there would turn a durable write into the caller's error.
|
|
165
|
+
**The status alone was not enough**, which is why `settle(key, value, reservationId)` and
|
|
166
|
+
`fail(key, failure, reservationId)` carry the id: a reclaimed record is `in-flight` AGAIN, so a
|
|
167
|
+
straggler satisfied a status-only fence exactly and overwrote a LIVE reservation — and the
|
|
168
|
+
replacement's own settle was then fenced out. Public API, changed in the 8.0.0 major; callers
|
|
169
|
+
pass `reservation.record.id`, which `withIdempotency` already holds.
|
|
170
|
+
- **A stored status is NARROWED, never cast.** `isIdempotencyStatus` decides, and an unknown word
|
|
171
|
+
is `X_IDEMPOTENCY_STATUS_UNKNOWN` at `toRecord`. `row.status as IdempotencyStatus` let one
|
|
172
|
+
through and `withIdempotency` has no branch for it: the record fell past `in-flight` and
|
|
173
|
+
`failed` and answered `{ value: null, replayed: true }` — "this already ran, here is its result"
|
|
174
|
+
— for a row nobody could read. The record was written by whatever build was deployed when the
|
|
175
|
+
first attempt ran, which on a rolling deploy is not this one. Same rule, same column shape, as
|
|
176
|
+
`@ultimat3/jobs`' `statusIn`.
|
|
153
177
|
- **Where the idempotency records live is DECLARED, and refused at registration.**
|
|
154
178
|
`IdempotencyStore.scope` says what a driver provides; `configureIdempotency({ scope })` says what
|
|
155
179
|
the deployment requires; `assertIdempotencyScope` compares them inside `registerAction` — the
|
package/README.md
CHANGED
|
@@ -267,7 +267,16 @@ import {
|
|
|
267
267
|
setIdempotencyStore,
|
|
268
268
|
} from '@ultimat3/action';
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
|
|
271
|
+
// template whose positional form is `unsafe`), so that line compiles and throws on the first
|
|
272
|
+
// reservation.
|
|
273
|
+
// The framework boot installs this store for you; reach for it by hand only from a host that
|
|
274
|
+
// boots the framework itself, and wrap the client that host already opened.
|
|
275
|
+
setIdempotencyStore(
|
|
276
|
+
postgresIdempotencyStore({
|
|
277
|
+
executor: { query: (text, values) => client.query({ text, values }) },
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
271
280
|
configureIdempotency({ scope: 'shared' });
|
|
272
281
|
```
|
|
273
282
|
|
|
@@ -296,6 +305,12 @@ const outcome = await withIdempotency(
|
|
|
296
305
|
);
|
|
297
306
|
```
|
|
298
307
|
|
|
308
|
+
`settle` and `fail` take the reservation's own id — `outcome`'s reservation, never the key alone.
|
|
309
|
+
Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/jobs`' `SQL_ACK` fences on
|
|
310
|
+
`id = $1 and state = 'running'`: a reservation whose window lapsed is reclaimed by the next caller,
|
|
311
|
+
so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
|
|
312
|
+
reservation.
|
|
313
|
+
|
|
299
314
|
A `query` has none and never will: a read has nothing to be idempotent about.
|
|
300
315
|
|
|
301
316
|
## `deprecated:` — a compat window, not a version
|
|
@@ -447,8 +462,9 @@ never a pass — the assertion says which code got in the way and names `input:`
|
|
|
447
462
|
| `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
|
|
448
463
|
| `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
|
|
449
464
|
| `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
|
|
450
|
-
| `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor
|
|
465
|
+
| `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
|
|
451
466
|
| `X_IDEMPOTENCY_REPLAYED_FAILURE` | a retried key replays a first attempt that failed and carried no framework code of its own | read the first attempt, then send a fresh key |
|
|
467
|
+
| `X_IDEMPOTENCY_STATUS_UNKNOWN` | `x_idempotency.status` holds a word this build has no branch for — written by a newer deploy | finish the rollout onto the build that writes it, then reconcile those requests — never DELETE the rows, which frees the key to run an already-committed action a second time |
|
|
452
468
|
| `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
|
|
453
469
|
| `X_RPC_FAILED` | non-`problem+json` failure, or a body naming no `X_` code | check the gateway |
|
|
454
470
|
| `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/http": "
|
|
37
|
-
"@ultimat3/policy": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/cache": "8.0.0",
|
|
35
|
+
"@ultimat3/core": "8.0.0",
|
|
36
|
+
"@ultimat3/http": "8.0.0",
|
|
37
|
+
"@ultimat3/policy": "8.0.0",
|
|
38
|
+
"@ultimat3/schema": "8.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
|
|
3
|
+
* stable code, exactly as there — the codes and their titles stay in `errors.ts`'s one
|
|
4
|
+
* `registerErrorCodes` call, because a second registration is how two modules end up deciding a
|
|
5
|
+
* title by load order.
|
|
6
|
+
*/
|
|
7
|
+
import { errorDocsUrl, renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
8
|
+
// Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
|
|
9
|
+
// cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
|
|
10
|
+
import type { IdempotencyFailure } from './idempotency';
|
|
11
|
+
|
|
12
|
+
// Core's spelling, aliased — never a second one, and the same alias `errors.ts` takes.
|
|
13
|
+
const docs = errorDocsUrl;
|
|
14
|
+
|
|
15
|
+
export type IdempotencyConflictReason = 'payload-mismatch' | 'in-flight';
|
|
16
|
+
|
|
17
|
+
export class IdempotencyConflictError extends UltimateError {
|
|
18
|
+
constructor(key: string, reason: IdempotencyConflictReason) {
|
|
19
|
+
super({
|
|
20
|
+
code: 'X_IDEMPOTENCY_CONFLICT',
|
|
21
|
+
cause:
|
|
22
|
+
reason === 'payload-mismatch'
|
|
23
|
+
? `idempotency key "${key}" was already used with a different payload`
|
|
24
|
+
: `idempotency key "${key}" is still in flight from an earlier request`,
|
|
25
|
+
// A paste-able call, the spelling `IdempotencyKeyInvalidError` already uses: both failures
|
|
26
|
+
// are the CLIENT's to act on, and one header built two ways is two ways to get it wrong.
|
|
27
|
+
fix:
|
|
28
|
+
reason === 'payload-mismatch'
|
|
29
|
+
? 'set the Idempotency-Key header to a fresh crypto.randomUUID() — one key per payload, since this one already names a different request'
|
|
30
|
+
: 'resend this request with the same Idempotency-Key once the first one settles — a fresh crypto.randomUUID() here would run the mutation twice',
|
|
31
|
+
docs: docs('X_IDEMPOTENCY_CONFLICT'),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type IdempotencyKeyProblem = 'empty' | 'too-long';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The header arrived and cannot name one request. Refused, never read as absent: `Headers.get()`
|
|
40
|
+
* answers `''` for `Idempotency-Key:` rather than `null`, so a blank value became a live key that
|
|
41
|
+
* every caller sending a blank header shared — and reading it as "no key" is the quieter failure,
|
|
42
|
+
* because a client whose key interpolation produced nothing would lose the protection silently
|
|
43
|
+
* and double-charge on its own retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the
|
|
44
|
+
* same reason; it uses `assert` because the empty key there is the app's own declaration, while
|
|
45
|
+
* this one is a caller's header and therefore a 4xx.
|
|
46
|
+
*
|
|
47
|
+
* The length bound is the one the OpenAPI operation has always published (`maxLength: 255`).
|
|
48
|
+
* A contract that disagrees with the runtime is worse than no contract.
|
|
49
|
+
*/
|
|
50
|
+
export class IdempotencyKeyInvalidError extends UltimateError {
|
|
51
|
+
constructor(action: string, problem: IdempotencyKeyProblem, length: number) {
|
|
52
|
+
super({
|
|
53
|
+
code: 'X_IDEMPOTENCY_KEY_INVALID',
|
|
54
|
+
cause:
|
|
55
|
+
problem === 'empty'
|
|
56
|
+
? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
|
|
57
|
+
: `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
|
|
58
|
+
fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
|
|
59
|
+
docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
|
|
60
|
+
meta: { action, problem, length },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The deployment declared `scope: 'shared'` and the installed store cannot keep it. Refused at
|
|
67
|
+
* registration, before a socket opens, because the failure it replaces is silent and expensive:
|
|
68
|
+
* a per-process store under `replicas: 3` means the retry that lands on another replica finds no
|
|
69
|
+
* record, re-runs the handler, and charges the card again — with nothing anywhere saying it did.
|
|
70
|
+
* An UNDECLARED scope is refused the same way: what cannot be shown to be shared is not assumed
|
|
71
|
+
* to be, the rule `assertRouteBuckets` already applies to a limiter that publishes no table.
|
|
72
|
+
*/
|
|
73
|
+
export class IdempotencyNotSharedError extends UltimateError {
|
|
74
|
+
constructor(storeScope: string | undefined) {
|
|
75
|
+
super({
|
|
76
|
+
code: 'X_IDEMPOTENCY_NOT_SHARED',
|
|
77
|
+
cause:
|
|
78
|
+
storeScope === undefined
|
|
79
|
+
? "configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store declares no scope"
|
|
80
|
+
: `configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store is ${storeScope}`,
|
|
81
|
+
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` (it is a tagged template whose
|
|
82
|
+
// positional form is `unsafe`), so that line compiled and would have thrown on the first
|
|
83
|
+
// reservation. The framework's own boot already installs this store; a host booting the
|
|
84
|
+
// framework itself wraps the client it opened.
|
|
85
|
+
fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
|
|
86
|
+
docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
|
|
87
|
+
meta: { storeScope: storeScope ?? null },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
|
|
94
|
+
* and the input parse both run before the idempotency gate, so everything the gate can see throw
|
|
95
|
+
* is post-authorization and possibly post-commit — a handler that took the money and then failed
|
|
96
|
+
* its own `output:` schema is the case this exists for. Releasing the reservation there let the
|
|
97
|
+
* client's automatic retry charge a second time, which made idempotency the cause of the double
|
|
98
|
+
* charge it exists to prevent.
|
|
99
|
+
*
|
|
100
|
+
* The first attempt's code is re-used verbatim, the way `RemoteActionError` re-uses the server's:
|
|
101
|
+
* the caller is owed the failure it would have got, not a new one. `X_IDEMPOTENCY_REPLAYED_FAILURE`
|
|
102
|
+
* is the code only when the original throw carried none of its own.
|
|
103
|
+
*/
|
|
104
|
+
export class IdempotencyReplayedFailureError extends UltimateError {
|
|
105
|
+
/** The recorded first attempt, so a caller reads the original code without parsing a message. */
|
|
106
|
+
readonly failure: IdempotencyFailure;
|
|
107
|
+
|
|
108
|
+
constructor(key: string, failure: IdempotencyFailure | undefined) {
|
|
109
|
+
const recorded: IdempotencyFailure = failure ?? {
|
|
110
|
+
code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
|
|
111
|
+
cause: 'the first attempt under this key failed and the store kept no detail of it',
|
|
112
|
+
fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
|
|
113
|
+
};
|
|
114
|
+
super({
|
|
115
|
+
code: recorded.code,
|
|
116
|
+
cause: `${recorded.cause} — replayed from the first attempt under Idempotency-Key "${key}", which may have committed before it failed`,
|
|
117
|
+
fix: recorded.fix,
|
|
118
|
+
...(recorded.docs === undefined ? {} : { docs: recorded.docs }),
|
|
119
|
+
// `replayed` is what tells an operator this is not a second execution: nothing ran here.
|
|
120
|
+
meta: { origin: 'idempotent-replay', key, replayed: true, code: recorded.code },
|
|
121
|
+
});
|
|
122
|
+
this.failure = recorded;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The stored record holds a status word this build has no branch for. Refused, never cast: with
|
|
128
|
+
* `row.status as IdempotencyStatus`, an unknown word fell through every branch of
|
|
129
|
+
* `withIdempotency` and answered `{ value: null, replayed: true }` — "this already ran, here is
|
|
130
|
+
* its result" — for a record nobody could read, which is the silent wrong answer idempotency
|
|
131
|
+
* exists to prevent. The mirror of `@ultimat3/jobs`' `X_JOB_ROW_STATUS_UNKNOWN`, for the same
|
|
132
|
+
* column shape and the same cause: the record was written by whatever build was deployed when the
|
|
133
|
+
* first attempt ran, which on a rolling deploy is not this one.
|
|
134
|
+
*/
|
|
135
|
+
export class IdempotencyStatusUnknownError extends UltimateError {
|
|
136
|
+
constructor(input: { key: string; value: unknown; known: readonly string[] }) {
|
|
137
|
+
super({
|
|
138
|
+
code: 'X_IDEMPOTENCY_STATUS_UNKNOWN',
|
|
139
|
+
cause:
|
|
140
|
+
`x_idempotency.status holds ${renderCauseValue(input.value)} for key "${input.key}", ` +
|
|
141
|
+
`which this build does not know — it reads ${input.known.join(', ')}`,
|
|
142
|
+
fix: `psql "$DATABASE_URL" -c "select key, status from x_idempotency where status not in ('in-flight', 'settled', 'failed')" # then drain the older processes: a status this build cannot read was written by a newer deploy`,
|
|
143
|
+
docs: docs('X_IDEMPOTENCY_STATUS_UNKNOWN'),
|
|
144
|
+
meta: { key: input.key, value: renderCauseValue(input.value), known: [...input.known] },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Every failure @ultimat3/action can produce, one subclass per stable code so
|
|
3
3
|
* callers `instanceof` a specific failure instead of string-matching a message.
|
|
4
|
+
* The idempotency five live in `errors-idempotency.ts` — this file reached the line ceiling —
|
|
5
|
+
* and are re-exported here, so `./errors` stays the one import path for all of them.
|
|
4
6
|
*/
|
|
5
7
|
import {
|
|
6
8
|
assertNever,
|
|
@@ -11,9 +13,17 @@ import {
|
|
|
11
13
|
UltimateError,
|
|
12
14
|
} from '@ultimat3/core';
|
|
13
15
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
// Re-exported, not re-declared: the five idempotency failures moved to their own file when this
|
|
18
|
+
// one reached the line ceiling, and every importer still reads them from `./errors`.
|
|
19
|
+
export type { IdempotencyConflictReason, IdempotencyKeyProblem } from './errors-idempotency';
|
|
20
|
+
export {
|
|
21
|
+
IdempotencyConflictError,
|
|
22
|
+
IdempotencyKeyInvalidError,
|
|
23
|
+
IdempotencyNotSharedError,
|
|
24
|
+
IdempotencyReplayedFailureError,
|
|
25
|
+
IdempotencyStatusUnknownError,
|
|
26
|
+
} from './errors-idempotency';
|
|
17
27
|
|
|
18
28
|
// Core's spelling, aliased — never a second one. A local template drifts from what
|
|
19
29
|
// `x errors explain` prints the moment `ERROR_DOCS_BASE` moves.
|
|
@@ -41,6 +51,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
41
51
|
'idempotency is declared fleet-wide and the installed store is per-process',
|
|
42
52
|
X_IDEMPOTENCY_REPLAYED_FAILURE:
|
|
43
53
|
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
54
|
+
X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
|
|
44
55
|
X_INPUT_INVALID: 'input failed schema validation',
|
|
45
56
|
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
46
57
|
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
@@ -202,115 +213,6 @@ export class OutputInvalidError extends UltimateError {
|
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
215
|
|
|
205
|
-
export type IdempotencyConflictReason = 'payload-mismatch' | 'in-flight';
|
|
206
|
-
|
|
207
|
-
export class IdempotencyConflictError extends UltimateError {
|
|
208
|
-
constructor(key: string, reason: IdempotencyConflictReason) {
|
|
209
|
-
super({
|
|
210
|
-
code: 'X_IDEMPOTENCY_CONFLICT',
|
|
211
|
-
cause:
|
|
212
|
-
reason === 'payload-mismatch'
|
|
213
|
-
? `idempotency key "${key}" was already used with a different payload`
|
|
214
|
-
: `idempotency key "${key}" is still in flight from an earlier request`,
|
|
215
|
-
fix:
|
|
216
|
-
reason === 'payload-mismatch'
|
|
217
|
-
? 'send a fresh Idempotency-Key header for a different payload'
|
|
218
|
-
: 'retry the same Idempotency-Key after the first request settles',
|
|
219
|
-
docs: docs('X_IDEMPOTENCY_CONFLICT'),
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export type IdempotencyKeyProblem = 'empty' | 'too-long';
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* The header arrived and cannot name one request. Refused, never read as absent: `Headers.get()`
|
|
228
|
-
* answers `''` for `Idempotency-Key:` rather than `null`, so a blank value became a live key that
|
|
229
|
-
* every caller sending a blank header shared — and reading it as "no key" is the quieter failure,
|
|
230
|
-
* because a client whose key interpolation produced nothing would lose the protection silently
|
|
231
|
-
* and double-charge on its own retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the
|
|
232
|
-
* same reason; it uses `assert` because the empty key there is the app's own declaration, while
|
|
233
|
-
* this one is a caller's header and therefore a 4xx.
|
|
234
|
-
*
|
|
235
|
-
* The length bound is the one the OpenAPI operation has always published (`maxLength: 255`).
|
|
236
|
-
* A contract that disagrees with the runtime is worse than no contract.
|
|
237
|
-
*/
|
|
238
|
-
export class IdempotencyKeyInvalidError extends UltimateError {
|
|
239
|
-
constructor(action: string, problem: IdempotencyKeyProblem, length: number) {
|
|
240
|
-
super({
|
|
241
|
-
code: 'X_IDEMPOTENCY_KEY_INVALID',
|
|
242
|
-
cause:
|
|
243
|
-
problem === 'empty'
|
|
244
|
-
? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
|
|
245
|
-
: `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
|
|
246
|
-
fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
|
|
247
|
-
docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
|
|
248
|
-
meta: { action, problem, length },
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* The deployment declared `scope: 'shared'` and the installed store cannot keep it. Refused at
|
|
255
|
-
* registration, before a socket opens, because the failure it replaces is silent and expensive:
|
|
256
|
-
* a per-process store under `replicas: 3` means the retry that lands on another replica finds no
|
|
257
|
-
* record, re-runs the handler, and charges the card again — with nothing anywhere saying it did.
|
|
258
|
-
* An UNDECLARED scope is refused the same way: what cannot be shown to be shared is not assumed
|
|
259
|
-
* to be, the rule `assertRouteBuckets` already applies to a limiter that publishes no table.
|
|
260
|
-
*/
|
|
261
|
-
export class IdempotencyNotSharedError extends UltimateError {
|
|
262
|
-
constructor(storeScope: string | undefined) {
|
|
263
|
-
super({
|
|
264
|
-
code: 'X_IDEMPOTENCY_NOT_SHARED',
|
|
265
|
-
cause:
|
|
266
|
-
storeScope === undefined
|
|
267
|
-
? "configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store declares no scope"
|
|
268
|
-
: `configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store is ${storeScope}`,
|
|
269
|
-
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` (it is a tagged template whose
|
|
270
|
-
// positional form is `unsafe`), so that line compiled and would have thrown on the first
|
|
271
|
-
// reservation. The framework's own boot already installs this store; a host booting the
|
|
272
|
-
// framework itself wraps the client it opened.
|
|
273
|
-
fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
|
|
274
|
-
docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
|
|
275
|
-
meta: { storeScope: storeScope ?? null },
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
|
|
282
|
-
* and the input parse both run before the idempotency gate, so everything the gate can see throw
|
|
283
|
-
* is post-authorization and possibly post-commit — a handler that took the money and then failed
|
|
284
|
-
* its own `output:` schema is the case this exists for. Releasing the reservation there let the
|
|
285
|
-
* client's automatic retry charge a second time, which made idempotency the cause of the double
|
|
286
|
-
* charge it exists to prevent.
|
|
287
|
-
*
|
|
288
|
-
* The first attempt's code is re-used verbatim, the way `RemoteActionError` re-uses the server's:
|
|
289
|
-
* the caller is owed the failure it would have got, not a new one. `X_IDEMPOTENCY_REPLAYED_FAILURE`
|
|
290
|
-
* is the code only when the original throw carried none of its own.
|
|
291
|
-
*/
|
|
292
|
-
export class IdempotencyReplayedFailureError extends UltimateError {
|
|
293
|
-
/** The recorded first attempt, so a caller reads the original code without parsing a message. */
|
|
294
|
-
readonly failure: IdempotencyFailure;
|
|
295
|
-
|
|
296
|
-
constructor(key: string, failure: IdempotencyFailure | undefined) {
|
|
297
|
-
const recorded: IdempotencyFailure = failure ?? {
|
|
298
|
-
code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
|
|
299
|
-
cause: 'the first attempt under this key failed and the store kept no detail of it',
|
|
300
|
-
fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
|
|
301
|
-
};
|
|
302
|
-
super({
|
|
303
|
-
code: recorded.code,
|
|
304
|
-
cause: `${recorded.cause} — replayed from the first attempt under Idempotency-Key "${key}", which may have committed before it failed`,
|
|
305
|
-
fix: recorded.fix,
|
|
306
|
-
...(recorded.docs === undefined ? {} : { docs: recorded.docs }),
|
|
307
|
-
// `replayed` is what tells an operator this is not a second execution: nothing ran here.
|
|
308
|
-
meta: { origin: 'idempotent-replay', key, replayed: true, code: recorded.code },
|
|
309
|
-
});
|
|
310
|
-
this.failure = recorded;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
216
|
/**
|
|
315
217
|
* A `deprecated:` block whose dates cannot become the headers it promises. Refused where the
|
|
316
218
|
* declaration is converted, so the route and the OpenAPI operation refuse the same value — the
|
package/src/http.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
schemaRef,
|
|
26
26
|
toOperationId,
|
|
27
27
|
} from './naming';
|
|
28
|
-
import { policyCapability } from './policy-gate';
|
|
28
|
+
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
29
29
|
|
|
30
30
|
/** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
|
|
31
31
|
* CLIENT's claim, never `ctx.buildId`, which is the build this process serves. */
|
|
@@ -86,9 +86,13 @@ export function toRoute(target: AnyAction): Route {
|
|
|
86
86
|
|
|
87
87
|
const meta: RouteMeta = {
|
|
88
88
|
name,
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
89
|
+
// Derived from a WALK of the policy tree, never from the root combinator alone. A saying that
|
|
90
|
+
// "`allow(...)` is the only way an action is public" is true of the leaf and was false of the
|
|
91
|
+
// read: `policy.kind === 'allow'` answered `required` for `or(allow(), can('x:y'))`, so the
|
|
92
|
+
// pipeline 401'd an anonymous caller the policy itself allows — while the MCP tool and the job
|
|
93
|
+
// handle let the same caller through the same object. `public` here is not "unguarded":
|
|
94
|
+
// `enforcedBy: 'handler'` below means `invoke` still evaluates the policy for every call.
|
|
95
|
+
auth: admitsAnonymous(def.policy) ? 'public' : 'required',
|
|
92
96
|
policy: policyCapability(def.policy),
|
|
93
97
|
// Named so the pipeline's authz stage stands down: `invoke` is this route's one
|
|
94
98
|
// evaluation, and it is the only one that has run `row` by the time it decides. A
|
|
@@ -81,28 +81,38 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Both settlements are FENCED on
|
|
85
|
-
* `@ultimat3/jobs`' `SQL_ACK` is
|
|
86
|
-
* straggler from the reservation before it would otherwise
|
|
87
|
-
* and the next replay would answer one request with
|
|
88
|
-
*
|
|
84
|
+
* Both settlements are FENCED on the reservation's own `id` AND on `in-flight`, as
|
|
85
|
+
* `SQL_IDEMPOTENCY_SETTLE` is and as `@ultimat3/jobs`' `SQL_ACK` is. A record past the window is
|
|
86
|
+
* reclaimed by the next caller, so a straggler from the reservation before it would otherwise
|
|
87
|
+
* overwrite a record it no longer owns and the next replay would answer one request with
|
|
88
|
+
* another's value. The status alone does not catch it — the reclaimed record is `in-flight`
|
|
89
|
+
* again — which is why the id is half the fence. Both stores fence, or the guarantee is
|
|
90
|
+
* whichever store the deployment happens to install.
|
|
89
91
|
*/
|
|
90
|
-
settle(key: string, value: unknown): Promise<void> {
|
|
91
|
-
const existing = this.#
|
|
92
|
-
if (existing
|
|
92
|
+
settle(key: string, value: unknown, reservationId: string): Promise<void> {
|
|
93
|
+
const existing = this.#owned(key, reservationId);
|
|
94
|
+
if (existing !== undefined) {
|
|
93
95
|
this.#records.set(key, { ...existing, status: 'settled', value });
|
|
94
96
|
}
|
|
95
97
|
return Promise.resolve();
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
fail(key: string, failure: IdempotencyFailure): Promise<void> {
|
|
99
|
-
const existing = this.#
|
|
100
|
-
if (existing
|
|
100
|
+
fail(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void> {
|
|
101
|
+
const existing = this.#owned(key, reservationId);
|
|
102
|
+
if (existing !== undefined) {
|
|
101
103
|
this.#records.set(key, { ...existing, status: 'failed', value: undefined, failure });
|
|
102
104
|
}
|
|
103
105
|
return Promise.resolve();
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/** The record this reservation may still write, or nothing — the fence, in one place. */
|
|
109
|
+
#owned(key: string, reservationId: string): IdempotencyRecord | undefined {
|
|
110
|
+
const existing = this.#records.get(key);
|
|
111
|
+
if (existing === undefined) return undefined;
|
|
112
|
+
if (existing.status !== 'in-flight' || existing.id !== reservationId) return undefined;
|
|
113
|
+
return existing;
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
release(key: string): Promise<void> {
|
|
107
117
|
this.#records.delete(key);
|
|
108
118
|
return Promise.resolve();
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
*/
|
|
7
7
|
import { logger, uuid } from '@ultimat3/core';
|
|
8
|
+
import { IdempotencyStatusUnknownError } from './errors';
|
|
8
9
|
import type {
|
|
9
10
|
IdempotencyFailure,
|
|
10
11
|
IdempotencyRecord,
|
|
11
12
|
IdempotencyReservation,
|
|
12
13
|
IdempotencyScope,
|
|
13
|
-
IdempotencyStatus,
|
|
14
14
|
IdempotencyStore,
|
|
15
15
|
} from './idempotency';
|
|
16
|
+
import { IDEMPOTENCY_STATUSES, isIdempotencyStatus } from './idempotency';
|
|
16
17
|
import { DEFAULT_IDEMPOTENCY_WINDOW_MS } from './idempotency-memory';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -82,22 +83,26 @@ select key, id, request_hash, status, value, failure,
|
|
|
82
83
|
`;
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
|
-
* `and status = 'in-flight'` is a FENCE, not a filter — the one `@ultimat3/jobs`'
|
|
86
|
-
* as `and state = 'running'`, for the same failure. A reservation
|
|
87
|
-
* by the next caller (`do update` above), so a straggler from the
|
|
88
|
-
* overwrote a record it no longer owned: the next replay under that
|
|
89
|
-
* value produced for a different request.
|
|
90
|
-
*
|
|
86
|
+
* `and id = $3 and status = 'in-flight'` is a FENCE, not a filter — the one `@ultimat3/jobs`'
|
|
87
|
+
* `SQL_ACK` carries as `where id = $1 and state = 'running'`, for the same failure. A reservation
|
|
88
|
+
* whose window lapsed is reclaimed by the next caller (`do update` above), so a straggler from the
|
|
89
|
+
* first one arriving afterwards overwrote a record it no longer owned: the next replay under that
|
|
90
|
+
* key answered a retry with a value produced for a different request.
|
|
91
|
+
*
|
|
92
|
+
* BOTH halves, because either alone leaves a case open. The status alone misses the reclaimed
|
|
93
|
+
* record — it is `in-flight` again, belonging to someone else — and the id alone would let a
|
|
94
|
+
* straggler overwrite a record its own attempt had already settled. `returning key` is what makes
|
|
95
|
+
* the refusal observable: an update matching no row is indistinguishable from one that matched.
|
|
91
96
|
*/
|
|
92
97
|
export const SQL_IDEMPOTENCY_SETTLE = `
|
|
93
98
|
update x_idempotency set status = 'settled', value = $2::jsonb, failure = null
|
|
94
|
-
where key = $1 and status = 'in-flight'
|
|
99
|
+
where key = $1 and id = $3::uuid and status = 'in-flight'
|
|
95
100
|
returning key
|
|
96
101
|
`;
|
|
97
102
|
|
|
98
103
|
export const SQL_IDEMPOTENCY_FAIL = `
|
|
99
104
|
update x_idempotency set status = 'failed', value = null, failure = $2::jsonb
|
|
100
|
-
where key = $1 and status = 'in-flight'
|
|
105
|
+
where key = $1 and id = $3::uuid and status = 'in-flight'
|
|
101
106
|
returning key
|
|
102
107
|
`;
|
|
103
108
|
|
|
@@ -121,6 +126,12 @@ interface IdempotencyRow {
|
|
|
121
126
|
export interface PostgresIdempotencyStoreOptions {
|
|
122
127
|
readonly executor: PgExecutor;
|
|
123
128
|
readonly windowMs?: number | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* Injectable, exactly as `MemoryIdempotencyStoreOptions.now` is. The two stores are one seam and
|
|
131
|
+
* a caller must be able to drive either from the same clock; a hardcoded `Date.now()` here made
|
|
132
|
+
* the one record this store stamps itself untestable and unfreezable.
|
|
133
|
+
*/
|
|
134
|
+
readonly now?: (() => number) | undefined;
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
export interface PostgresIdempotencyStore extends IdempotencyStore {
|
|
@@ -164,6 +175,7 @@ export function postgresIdempotencyStore(
|
|
|
164
175
|
const windowMs = Math.max(1, Math.floor(options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS));
|
|
165
176
|
const windowSecs = windowMs / 1000;
|
|
166
177
|
const exec = options.executor;
|
|
178
|
+
const now = options.now ?? ((): number => Date.now());
|
|
167
179
|
|
|
168
180
|
const fetch = async (key: string): Promise<IdempotencyRecord | undefined> => {
|
|
169
181
|
const rows = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_GET, [key, windowSecs]);
|
|
@@ -200,20 +212,28 @@ export function postgresIdempotencyStore(
|
|
|
200
212
|
requestHash,
|
|
201
213
|
status: 'in-flight',
|
|
202
214
|
value: undefined,
|
|
203
|
-
createdAt:
|
|
215
|
+
createdAt: now(),
|
|
204
216
|
},
|
|
205
217
|
created: false,
|
|
206
218
|
};
|
|
207
219
|
},
|
|
208
220
|
|
|
209
|
-
async settle(key, value): Promise<void> {
|
|
210
|
-
const rows = await exec.query(SQL_IDEMPOTENCY_SETTLE, [
|
|
211
|
-
|
|
221
|
+
async settle(key, value, reservationId): Promise<void> {
|
|
222
|
+
const rows = await exec.query(SQL_IDEMPOTENCY_SETTLE, [
|
|
223
|
+
key,
|
|
224
|
+
JSON.stringify(value ?? null),
|
|
225
|
+
reservationId,
|
|
226
|
+
]);
|
|
227
|
+
fenced(rows, key, reservationId, 'settle');
|
|
212
228
|
},
|
|
213
229
|
|
|
214
|
-
async fail(key, failure: IdempotencyFailure): Promise<void> {
|
|
215
|
-
const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [
|
|
216
|
-
|
|
230
|
+
async fail(key, failure: IdempotencyFailure, reservationId): Promise<void> {
|
|
231
|
+
const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [
|
|
232
|
+
key,
|
|
233
|
+
JSON.stringify(failure),
|
|
234
|
+
reservationId,
|
|
235
|
+
]);
|
|
236
|
+
fenced(rows, key, reservationId, 'fail');
|
|
217
237
|
},
|
|
218
238
|
|
|
219
239
|
async release(key): Promise<void> {
|
|
@@ -238,18 +258,36 @@ export function postgresIdempotencyStore(
|
|
|
238
258
|
* store that refuses. An operator still has to see it: a fenced settle means this attempt's record
|
|
239
259
|
* belongs to another reservation, and the value this attempt produced is stored nowhere.
|
|
240
260
|
*/
|
|
241
|
-
function fenced(
|
|
261
|
+
function fenced(
|
|
262
|
+
rows: readonly unknown[],
|
|
263
|
+
key: string,
|
|
264
|
+
reservationId: string,
|
|
265
|
+
statement: 'settle' | 'fail',
|
|
266
|
+
): void {
|
|
242
267
|
if (rows.length > 0) return;
|
|
243
|
-
logger.warn('action.idempotency.settlement-fenced', { key, statement });
|
|
268
|
+
logger.warn('action.idempotency.settlement-fenced', { key, reservationId, statement });
|
|
244
269
|
}
|
|
245
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The narrowing, never a cast. `row.status as IdempotencyStatus` let an unknown word through, and
|
|
273
|
+
* `withIdempotency` has no branch for one: it fell past `in-flight` and `failed` and answered
|
|
274
|
+
* `{ value: null, replayed: true }` — "this already ran, here is its result" — for a record nobody
|
|
275
|
+
* could read. The rule `@ultimat3/jobs`' `statusIn` already writes out for the same column.
|
|
276
|
+
*/
|
|
246
277
|
function toRecord(row: IdempotencyRow): IdempotencyRecord {
|
|
247
278
|
const failure = toFailure(row.failure);
|
|
279
|
+
if (!isIdempotencyStatus(row.status)) {
|
|
280
|
+
throw new IdempotencyStatusUnknownError({
|
|
281
|
+
key: row.key,
|
|
282
|
+
value: row.status,
|
|
283
|
+
known: IDEMPOTENCY_STATUSES,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
248
286
|
return {
|
|
249
287
|
id: row.id,
|
|
250
288
|
key: row.key,
|
|
251
289
|
requestHash: row.request_hash,
|
|
252
|
-
status: row.status
|
|
290
|
+
status: row.status,
|
|
253
291
|
value: row.value,
|
|
254
292
|
...(failure === undefined ? {} : { failure }),
|
|
255
293
|
createdAt: Number(row.created_at),
|
package/src/idempotency.ts
CHANGED
|
@@ -32,7 +32,27 @@ export interface IdempotencyFailure {
|
|
|
32
32
|
readonly docs?: string | undefined;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The closed list, in the order a record moves through it — and the ONE declaration. The type is
|
|
37
|
+
* derived from it below rather than restated beside it, so a fourth status cannot be added to one
|
|
38
|
+
* and missed by the other, which is exactly how `isIdempotencyStatus` would start refusing a word
|
|
39
|
+
* this build writes itself.
|
|
40
|
+
*/
|
|
41
|
+
export const IDEMPOTENCY_STATUSES = Object.freeze(['in-flight', 'settled', 'failed'] as const);
|
|
42
|
+
|
|
43
|
+
export type IdempotencyStatus = (typeof IDEMPOTENCY_STATUSES)[number];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The one narrowing for the status column, and never a cast. A record crosses a process boundary —
|
|
47
|
+
* the row under this key was written by whatever build was deployed when the first attempt ran,
|
|
48
|
+
* which on a rolling deploy is not this one — so `row.status as IdempotencyStatus` made an unknown
|
|
49
|
+
* word answer `{ value: null, replayed: true }` in `withIdempotency`: the caller was told "this
|
|
50
|
+
* already ran, here is its result" for a row nobody could read. The rule `@ultimat3/jobs`'
|
|
51
|
+
* `statusIn` already writes out for the same column in the same situation.
|
|
52
|
+
*/
|
|
53
|
+
export function isIdempotencyStatus(value: string): value is IdempotencyStatus {
|
|
54
|
+
return (IDEMPOTENCY_STATUSES as readonly string[]).includes(value);
|
|
55
|
+
}
|
|
36
56
|
|
|
37
57
|
export interface IdempotencyRecord {
|
|
38
58
|
readonly id: string;
|
|
@@ -68,14 +88,29 @@ export interface IdempotencyStore {
|
|
|
68
88
|
readonly windowMs?: number | undefined;
|
|
69
89
|
/** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
|
|
70
90
|
reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
|
|
71
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Settle the record `reservationId` owns — `IdempotencyReservation.record.id`, never the key
|
|
93
|
+
* alone. Fenced on the id AND the status, the way `@ultimat3/jobs`' `SQL_ACK` fences on
|
|
94
|
+
* `id = $1 and state = 'running'`.
|
|
95
|
+
*
|
|
96
|
+
* The status alone was not enough, and the gap it left is silent: a reservation whose window
|
|
97
|
+
* lapsed is reclaimed by the next caller, so the record under that key is `in-flight` AGAIN and
|
|
98
|
+
* belongs to someone else. A straggler from the first attempt satisfied the status fence
|
|
99
|
+
* exactly, overwrote a live reservation, and the replacement's own settle was then fenced out —
|
|
100
|
+
* so the retry replayed a value produced for a different request. A settlement that matches no
|
|
101
|
+
* record is logged, never thrown: it lands after the handler has committed.
|
|
102
|
+
*/
|
|
103
|
+
settle(key: string, value: unknown, reservationId: string): Promise<void>;
|
|
72
104
|
/**
|
|
73
105
|
* Settle a FAILURE, so the retry replays it instead of re-running a handler that may already
|
|
74
|
-
* have committed.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
106
|
+
* have committed. Fenced on the same reservation id as `settle`, for the same case — a
|
|
107
|
+
* straggler's failure marking a live reservation `failed` is the worse half of it.
|
|
108
|
+
*
|
|
109
|
+
* Optional so an existing store still type-checks — and when it is absent the gate leaves the
|
|
110
|
+
* reservation standing rather than releasing it, because refusing the retry is the safe answer
|
|
111
|
+
* and re-running it is the double charge.
|
|
77
112
|
*/
|
|
78
|
-
fail?(key: string, failure: IdempotencyFailure): Promise<void>;
|
|
113
|
+
fail?(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void>;
|
|
79
114
|
/** Drop a reservation, so a retry can run. Only ever correct BEFORE the handler starts. */
|
|
80
115
|
release(key: string): Promise<void>;
|
|
81
116
|
get(key: string): Promise<IdempotencyRecord | undefined>;
|
|
@@ -176,13 +211,16 @@ export async function withIdempotency<T>(
|
|
|
176
211
|
try {
|
|
177
212
|
value = await run();
|
|
178
213
|
} catch (error) {
|
|
179
|
-
await settleFailure(store, key, error);
|
|
214
|
+
await settleFailure(store, key, record.id, error);
|
|
180
215
|
throw error;
|
|
181
216
|
}
|
|
182
217
|
// Outside the `try` on purpose: a `settle` that refuses is itself post-commit, and the record
|
|
183
218
|
// stays in flight rather than being released — a retry then gets a 409 it can act on instead of
|
|
184
219
|
// re-running a handler that has already committed.
|
|
185
|
-
|
|
220
|
+
//
|
|
221
|
+
// `record.id` is THIS reservation's, so a straggler from an attempt whose window has since
|
|
222
|
+
// lapsed cannot land on the replacement that reclaimed the key.
|
|
223
|
+
await store.settle(key, value, record.id);
|
|
186
224
|
return { value, replayed: false };
|
|
187
225
|
}
|
|
188
226
|
|
|
@@ -191,7 +229,12 @@ export async function withIdempotency<T>(
|
|
|
191
229
|
* here would otherwise surface as the caller's error, hiding the `X_OUTPUT_INVALID` or the
|
|
192
230
|
* handler's own throw that is the thing worth reading — the same rule `auditThrew` follows.
|
|
193
231
|
*/
|
|
194
|
-
async function settleFailure(
|
|
232
|
+
async function settleFailure(
|
|
233
|
+
store: IdempotencyStore,
|
|
234
|
+
key: string,
|
|
235
|
+
reservationId: string,
|
|
236
|
+
error: unknown,
|
|
237
|
+
): Promise<void> {
|
|
195
238
|
if (store.fail === undefined) {
|
|
196
239
|
// Deliberately NOT `release`. A store with no failure slot cannot say "this already ran", and
|
|
197
240
|
// the retry-safe reading of that is "refuse the retry", not "run it again".
|
|
@@ -199,7 +242,7 @@ async function settleFailure(store: IdempotencyStore, key: string, error: unknow
|
|
|
199
242
|
return;
|
|
200
243
|
}
|
|
201
244
|
try {
|
|
202
|
-
await store.fail(key, failureOf(error));
|
|
245
|
+
await store.fail(key, failureOf(error), reservationId);
|
|
203
246
|
} catch (sinkError) {
|
|
204
247
|
// Never the error's own text: rendering an `unknown` into a message is the second throw this
|
|
205
248
|
// branch exists to prevent. The logger takes it as a field and shapes it itself.
|
package/src/index.ts
CHANGED
|
@@ -82,6 +82,7 @@ export {
|
|
|
82
82
|
IdempotencyKeyInvalidError,
|
|
83
83
|
IdempotencyNotSharedError,
|
|
84
84
|
IdempotencyReplayedFailureError,
|
|
85
|
+
IdempotencyStatusUnknownError,
|
|
85
86
|
InputInvalidError,
|
|
86
87
|
OutputInvalidError,
|
|
87
88
|
RemoteActionError,
|
|
@@ -116,7 +117,9 @@ export {
|
|
|
116
117
|
configureIdempotency,
|
|
117
118
|
DEFAULT_IDEMPOTENCY_CONFIG,
|
|
118
119
|
getIdempotencyStore,
|
|
120
|
+
IDEMPOTENCY_STATUSES,
|
|
119
121
|
idempotencyConfig,
|
|
122
|
+
isIdempotencyStatus,
|
|
120
123
|
resetIdempotency,
|
|
121
124
|
setIdempotencyStore,
|
|
122
125
|
withIdempotency,
|
|
@@ -174,8 +177,19 @@ export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './nami
|
|
|
174
177
|
export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
|
|
175
178
|
export { buildOpenApi, serializeOpenApi } from './openapi';
|
|
176
179
|
export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
|
|
177
|
-
/**
|
|
178
|
-
|
|
180
|
+
/**
|
|
181
|
+
* `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
|
|
182
|
+
* `admitsAnonymous` is `@ultimat3/policy`'s, re-exported here beside them: it is what `toRoute`
|
|
183
|
+
* derives `meta.auth` from, so a plain `route` sets that field from the same walk rather than
|
|
184
|
+
* re-reading the root combinator.
|
|
185
|
+
*/
|
|
186
|
+
export {
|
|
187
|
+
actorOf,
|
|
188
|
+
admitsAnonymous,
|
|
189
|
+
guard,
|
|
190
|
+
policyCapability,
|
|
191
|
+
policyPermissions,
|
|
192
|
+
} from './policy-gate';
|
|
179
193
|
export {
|
|
180
194
|
describeActions,
|
|
181
195
|
getAction,
|
package/src/policy-gate.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
8
|
import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
enforce,
|
|
12
|
+
policyPermissions as flattenedPermissions,
|
|
13
|
+
admitsAnonymous as policyAdmitsAnonymous,
|
|
14
|
+
} from '@ultimat3/policy';
|
|
11
15
|
import { ActionDeniedError } from './errors';
|
|
12
16
|
|
|
13
17
|
/**
|
|
@@ -91,3 +95,16 @@ export function policyCapability(policy: ActionPolicy): string {
|
|
|
91
95
|
export function policyPermissions(policy: ActionPolicy): readonly string[] {
|
|
92
96
|
return flattenedPermissions(policy);
|
|
93
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether a policy admits an ANONYMOUS caller — `@ultimat3/policy`'s answer, re-exported here so
|
|
101
|
+
* `http.ts` reads it through this file like every other authz question. `toRoute` derives
|
|
102
|
+
* `meta.auth` from it, never from `policy.kind === 'allow'`: that read looked at the ROOT
|
|
103
|
+
* combinator only, so `or(allow(), can('x:y'))` was 401'd by the pipeline before `invoke` ran
|
|
104
|
+
* while the MCP tool and the job handle allowed it. `true` is not "unguarded" — `invoke` still
|
|
105
|
+
* evaluates the policy for every call. Declared once in `policy.ts`, exactly as
|
|
106
|
+
* `policyPermissions` is: the answer is a property of the combinators it walks.
|
|
107
|
+
*/
|
|
108
|
+
export function admitsAnonymous(policy: ActionPolicy): boolean {
|
|
109
|
+
return policyAdmitsAnonymous(policy);
|
|
110
|
+
}
|
package/src/sample-input.ts
CHANGED
|
@@ -75,13 +75,25 @@ function sampleNumber(node: SchemaNode): number {
|
|
|
75
75
|
return node.integer === true ? Math.floor(node.maximum) : node.maximum;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* `Object.create(null)`, the shape `@ultimat3/schema`'s own object check already builds: on a `{}`
|
|
80
|
+
* literal `sample['__proto__'] = value` reaches `Object.prototype`'s SETTER, so a required field
|
|
81
|
+
* named `__proto__` never became an own key and REPLACED the sample's prototype instead. The
|
|
82
|
+
* payload then failed the very schema it was derived from, and the contract test reported the
|
|
83
|
+
* action as drifted. A field can carry that name through any computed key
|
|
84
|
+
* (`t.object({ [name]: t.string })`) or a provider whose IR was parsed from JSON.
|
|
85
|
+
*
|
|
86
|
+
* The read is guarded for the same reason `patternAt` guards its own: `requiredKeys` happens to
|
|
87
|
+
* answer own keys only (`Object.entries`), so this is the invariant stated locally rather than
|
|
88
|
+
* borrowed from a function two packages away.
|
|
89
|
+
*/
|
|
78
90
|
function sampleObject(node: SchemaNode): Record<string, unknown> {
|
|
79
91
|
const properties = node.properties ?? {};
|
|
80
|
-
const sample: Record<string, unknown> =
|
|
92
|
+
const sample: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
81
93
|
// Required-only is what "minimal" means: an optional key and a defaulted one are both
|
|
82
94
|
// absences the schema already accepts, so adding them would only widen what can go wrong.
|
|
83
95
|
for (const key of requiredKeys(node)) {
|
|
84
|
-
const child = properties[key];
|
|
96
|
+
const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
|
|
85
97
|
if (child !== undefined) sample[key] = sampleFor(child);
|
|
86
98
|
}
|
|
87
99
|
return sample;
|
|
@@ -157,7 +169,7 @@ function gapsIn(node: SchemaNode, path: string): string[] {
|
|
|
157
169
|
const properties = node.properties ?? {};
|
|
158
170
|
const out: string[] = [];
|
|
159
171
|
for (const key of requiredKeys(node)) {
|
|
160
|
-
const child = properties[key];
|
|
172
|
+
const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
|
|
161
173
|
if (child !== undefined) out.push(...gapsIn(child, path === '' ? key : `${path}.${key}`));
|
|
162
174
|
}
|
|
163
175
|
return out;
|