@ultimat3/action 24.0.0 → 25.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/CLAUDE.md +65 -39
- package/README.md +124 -41
- package/package.json +10 -10
- package/src/action.ts +25 -69
- package/src/api-declaration.ts +1 -1
- package/src/audit-gate.ts +3 -4
- package/src/audit-memory.ts +3 -3
- package/src/audit-postgres.ts +15 -6
- package/src/audit.ts +7 -106
- package/src/client.ts +18 -6
- package/src/contract-test.ts +18 -6
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +21 -3
- package/src/errors.ts +40 -62
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +29 -10
- package/src/idempotency-postgres.ts +27 -12
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +69 -9
- package/src/index.ts +33 -87
- package/src/invoke.ts +54 -19
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +2 -1
- package/src/mutator.ts +1 -2
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/policy-gate.ts +8 -51
- package/src/rate-limit-gate.ts +114 -0
- package/src/record-wire.ts +2 -2
- package/src/registry.ts +2 -2
- package/src/sample-input.ts +35 -6
- package/src/stable.ts +0 -7
- package/src/transition.ts +23 -3
- package/src/tx-scope.ts +1 -1
- package/src/type-pins.ts +103 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -95
- package/src/wire-headers.ts +0 -20
package/src/errors.ts
CHANGED
|
@@ -9,13 +9,18 @@ import {
|
|
|
9
9
|
deniedCallerFix,
|
|
10
10
|
ERROR_DOCS_URL,
|
|
11
11
|
hasErrorCode,
|
|
12
|
-
registerErrorCodes,
|
|
13
12
|
renderFixShellArg,
|
|
14
13
|
retryForStatus,
|
|
15
14
|
UltimateError,
|
|
15
|
+
withStatedDelay,
|
|
16
16
|
} from '@ultimat3/core';
|
|
17
17
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
18
18
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
19
|
+
// The titles are `error-titles.ts`, imported bare: this module is then side-effect-free and the
|
|
20
|
+
// array does not list it, so a chunk keeps only the classes it constructs, and a chunk that
|
|
21
|
+
// constructs one still registers every title first — anchored by use. The barrel imports it bare
|
|
22
|
+
// too, for the browser that rebuilds a SERVER's refusal by code and constructs nothing from here.
|
|
23
|
+
import './error-titles';
|
|
19
24
|
|
|
20
25
|
// Re-exported, not re-declared: the idempotency failures moved to their own file when this
|
|
21
26
|
// one reached the line ceiling, and every importer still reads them from `./errors`.
|
|
@@ -27,52 +32,10 @@ export {
|
|
|
27
32
|
IdempotencyReplayedFailureError,
|
|
28
33
|
IdempotencyReservationLostError,
|
|
29
34
|
IdempotencyStatusUnknownError,
|
|
35
|
+
IdempotentReplayRedactedError,
|
|
30
36
|
MutatorNotIdempotentError,
|
|
31
37
|
} from './errors-idempotency';
|
|
32
38
|
|
|
33
|
-
/**
|
|
34
|
-
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
35
|
-
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
|
|
36
|
-
* enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
|
|
37
|
-
* Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
|
|
38
|
-
*/
|
|
39
|
-
const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
40
|
-
X_ACTION_DUPLICATE: 'two actions are registered under one name',
|
|
41
|
-
X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
|
|
42
|
-
X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
|
|
43
|
-
X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
|
|
44
|
-
X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
|
|
45
|
-
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
|
|
46
|
-
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
|
|
47
|
-
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
|
|
48
|
-
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
|
|
49
|
-
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
|
|
50
|
-
X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
|
|
51
|
-
X_IDEMPOTENCY_NOT_SHARED:
|
|
52
|
-
'idempotency is declared fleet-wide and the installed store is per-process',
|
|
53
|
-
X_IDEMPOTENCY_REPLAYED_FAILURE:
|
|
54
|
-
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
55
|
-
X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
|
|
56
|
-
X_INPUT_INVALID: 'input failed schema validation',
|
|
57
|
-
X_MUTATOR_CLOCK_MISSING:
|
|
58
|
-
"a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
|
|
59
|
-
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
60
|
-
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
61
|
-
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
|
|
62
|
-
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
63
|
-
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
64
|
-
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
65
|
-
X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
|
|
66
|
-
X_IDEMPOTENCY_RESERVATION_LOST:
|
|
67
|
-
"an idempotent action's reservation was taken over before its transaction could settle it",
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
71
|
-
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
72
|
-
registerErrorCodes(
|
|
73
|
-
Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
39
|
/** Thrown when a projection needs a name the action does not have yet. */
|
|
77
40
|
export class ActionUnregisteredError extends UltimateError {
|
|
78
41
|
constructor() {
|
|
@@ -129,7 +92,7 @@ export function denialReason(denial: SurfaceDenial): string {
|
|
|
129
92
|
}
|
|
130
93
|
|
|
131
94
|
/**
|
|
132
|
-
* An authz denial, thrown by `
|
|
95
|
+
* An authz denial, thrown by `guardAction()`. The code and reason come from the policy
|
|
133
96
|
* decision — this package never invents an authz code — and the surface-shaped
|
|
134
97
|
* denial rides along for projections that render it themselves.
|
|
135
98
|
*/
|
|
@@ -224,7 +187,7 @@ export class InputInvalidError extends UltimateError {
|
|
|
224
187
|
super({
|
|
225
188
|
code: 'X_INPUT_INVALID',
|
|
226
189
|
cause: `input for ${subject} "${name}" failed validation: ${detail}`,
|
|
227
|
-
fix: `x actions describe ${name} --json # prints the expected input schema`,
|
|
190
|
+
fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # prints the expected input schema`,
|
|
228
191
|
// A remote caller holds the published schema — the MCP `inputSchema`, the OpenAPI request
|
|
229
192
|
// body — and cannot run `x actions describe`.
|
|
230
193
|
callerFix:
|
|
@@ -245,7 +208,7 @@ export class OutputInvalidError extends UltimateError {
|
|
|
245
208
|
super({
|
|
246
209
|
code: 'X_OUTPUT_INVALID',
|
|
247
210
|
cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
|
|
248
|
-
fix: `x actions describe ${name} --json # compare the handler's return against \`output:\``,
|
|
211
|
+
fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # compare the handler's return against \`output:\``,
|
|
249
212
|
});
|
|
250
213
|
}
|
|
251
214
|
}
|
|
@@ -294,6 +257,13 @@ export interface RemoteFailure {
|
|
|
294
257
|
* so an island reads `error.meta.sessionId` where it used to run a regex over `cause`.
|
|
295
258
|
*/
|
|
296
259
|
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* The document's `title`, already read by core's `remoteTitleOf` — used only when this bundle
|
|
262
|
+
* registered no title for `code`, which is every http refusal in an island.
|
|
263
|
+
*/
|
|
264
|
+
readonly title?: string | undefined;
|
|
265
|
+
/** The delay the answer's `Retry-After` named, in seconds (core's `retryAfterSecondsOf`). */
|
|
266
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
297
267
|
}
|
|
298
268
|
|
|
299
269
|
/** A link, not a string the server happened to put in a field the overlay renders as an href. */
|
|
@@ -346,19 +316,27 @@ export class RemoteActionError extends UltimateError {
|
|
|
346
316
|
// this build may never have heard of. `UltimateError` otherwise fills `retry` from
|
|
347
317
|
// `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
|
|
348
318
|
// `terminal` on the one field the framework promises a client never has to infer.
|
|
349
|
-
retry
|
|
350
|
-
meta
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
319
|
+
// A 429 or 503 that stated a delay is `retry-after` whoever registered the code, and the
|
|
320
|
+
// delay rides on `meta.retryAfterSeconds`, the one key core's `statedDelayMs` reads.
|
|
321
|
+
retry: retryForStatus(failure.code, failure.status, failure.retryAfterSeconds),
|
|
322
|
+
remoteTitle: failure.title,
|
|
323
|
+
// Core's `withStatedDelay`, the rule `problemError` applies too: a `retryAfterSeconds` off the
|
|
324
|
+
// body is dropped and only the header's is carried — the server never writes one there.
|
|
325
|
+
meta: withStatedDelay(
|
|
326
|
+
{
|
|
327
|
+
// The server's declared keys FIRST, so the four this class owns win a collision: a
|
|
328
|
+
// server meta naming `status` would otherwise overwrite the one a report reads the HTTP
|
|
329
|
+
// status from, and `origin: 'remote'` marks the code as one this bundle never declared.
|
|
330
|
+
...failure.meta,
|
|
331
|
+
origin: 'remote',
|
|
332
|
+
action: failure.action,
|
|
333
|
+
status: failure.status,
|
|
334
|
+
// Absent rather than `undefined`: `meta` is rendered into `--json` and the error
|
|
335
|
+
// reporter, and a null member reads as "the server sent an empty list".
|
|
336
|
+
...(failure.issues === undefined ? {} : { issues: failure.issues }),
|
|
337
|
+
},
|
|
338
|
+
failure.retryAfterSeconds,
|
|
339
|
+
),
|
|
362
340
|
});
|
|
363
341
|
this.status = failure.status;
|
|
364
342
|
}
|
|
@@ -375,7 +353,7 @@ export class RpcFailedError extends UltimateError {
|
|
|
375
353
|
super({
|
|
376
354
|
code: 'X_RPC_FAILED',
|
|
377
355
|
cause: `${name} returned HTTP ${status} without a problem+json body`,
|
|
378
|
-
fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
|
|
356
|
+
fix: `check the gateway in front of the app, then: x actions describe ${renderFixShellArg(name, '<action-name>')} --json`,
|
|
379
357
|
retry: retryForStatus('X_RPC_FAILED', status),
|
|
380
358
|
});
|
|
381
359
|
}
|
|
@@ -393,7 +371,7 @@ export class AuditSinkMissingError extends UltimateError {
|
|
|
393
371
|
super({
|
|
394
372
|
code: 'X_AUDIT_SINK_MISSING',
|
|
395
373
|
cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
|
|
396
|
-
fix: "call setAuditSink(yourSink) from '@ultimat3/
|
|
374
|
+
fix: "call setAuditSink(yourSink) from '@ultimat3/core' at boot, before registerActions()",
|
|
397
375
|
});
|
|
398
376
|
}
|
|
399
377
|
}
|
package/src/facade.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The fluent surface: every projection reachable as a method on the action
|
|
3
|
-
* `publishPost.
|
|
4
|
-
* field lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
-
* functions stay exported for the framework's own call sites — this file only
|
|
6
|
-
*
|
|
2
|
+
* The fluent surface: every projection this package owns reachable as a method on the action
|
|
3
|
+
* itself, `publishPost.openapi()` rather than `toOpenApiOperation(publishPost)`, and every
|
|
4
|
+
* declared field lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
+
* functions stay exported for the framework's own call sites — this file only binds them to the
|
|
6
|
+
* action, it never re-implements one. There is no `.tool()`: the MCP tool is `@ultimat3/mcp`'s
|
|
7
|
+
* one projection (`toolFrom(publishPost)`), a tier this package cannot import.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
@@ -13,7 +14,6 @@ import { contractTestsFor } from './contract-test';
|
|
|
13
14
|
import { toOpenApiOperation } from './http';
|
|
14
15
|
import { actionName, invoke } from './invoke';
|
|
15
16
|
import { toJobHandle } from './job-handle';
|
|
16
|
-
import { toMcpTool } from './mcp-tool';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* `self` is a thunk on purpose: the façade is attached while the action is still
|
|
@@ -33,7 +33,6 @@ export function facadeFor<TInput extends StandardSchemaV1, TOutput extends Stand
|
|
|
33
33
|
// the output type is this action's by construction.
|
|
34
34
|
as: (actor, input, options) =>
|
|
35
35
|
invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
|
|
36
|
-
tool: () => toMcpTool(self()),
|
|
37
36
|
openapi: () => toOpenApiOperation(self()),
|
|
38
37
|
client: (options) => clientMethodFor(actionName(self()), options, def.http?.path),
|
|
39
38
|
job: () => toJobHandle(self()),
|
package/src/http-path.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function setActionPathStyle(next: unknown): ActionPathStyle {
|
|
|
69
69
|
return style;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/** Test seam, beside `
|
|
72
|
+
/** Test seam, beside `resetActions`. */
|
|
73
73
|
/**
|
|
74
74
|
* Forget which paths were handed out, keeping the style. `@ultimat3/testing` calls it between two
|
|
75
75
|
* test files of one worker (22.7, no `--isolate`): a path an earlier FILE derived is not one this
|
package/src/http.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
+
import type { Deprecation } from '@ultimat3/core';
|
|
9
10
|
import {
|
|
11
|
+
IDEMPOTENCY_HEADER,
|
|
10
12
|
isMcpExposed,
|
|
11
13
|
RECORDS_OPENAPI_HEADER,
|
|
14
|
+
recordDeprecatedCall,
|
|
12
15
|
recordEnvelopeSchema,
|
|
16
|
+
renderDeprecation,
|
|
17
|
+
useContext,
|
|
13
18
|
withWriteOrigin,
|
|
14
19
|
writeDigest,
|
|
15
20
|
} from '@ultimat3/core';
|
|
@@ -17,10 +22,9 @@ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
|
17
22
|
// `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
|
|
18
23
|
// and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
|
|
19
24
|
// a copy here would be a second answer to "what does this limit mean" for the read half.
|
|
20
|
-
import {
|
|
25
|
+
import { jsonResponse, publishRateLimit, redirect, takeRedirect, toBucket } from '@ultimat3/http';
|
|
26
|
+
import { admitsAnonymous, policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
21
27
|
import type { ActionRateLimit, AnyAction } from './action';
|
|
22
|
-
import type { Deprecation } from './deprecation';
|
|
23
|
-
import { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
24
28
|
import { ActionDeprecationInvalidError } from './errors';
|
|
25
29
|
import { actionPathStyle, servedActionRoute } from './http-path';
|
|
26
30
|
import { actionName, defOf, invoke } from './invoke';
|
|
@@ -31,16 +35,7 @@ import {
|
|
|
31
35
|
schemaRef,
|
|
32
36
|
toOperationId,
|
|
33
37
|
} from './naming';
|
|
34
|
-
import { admitsAnonymous, policyCapability, policyPermissions } from './policy-gate';
|
|
35
38
|
import { carriesRecords, recordResponse } from './record-wire';
|
|
36
|
-
import { IDEMPOTENCY_HEADER } from './wire-headers';
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Re-exported, never re-declared. The two strings moved to `wire-headers.ts` so `client.ts` can
|
|
40
|
-
* name them without pulling this file's server graph into a browser bundle; every importer of
|
|
41
|
-
* `./http` still reads them from here, which is what kept the move from touching a call site.
|
|
42
|
-
*/
|
|
43
|
-
export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
44
39
|
|
|
45
40
|
export const REPLAYED_HEADER = 'x-ultimate-replayed';
|
|
46
41
|
|
|
@@ -90,6 +85,9 @@ function projectRoute(
|
|
|
90
85
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
91
86
|
// not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
|
|
92
87
|
const sunsetting = deprecationHeadersFor(name, def.deprecated);
|
|
88
|
+
// Same rule, for a limit the limiter could not run on: refused at mount (`X_RATE_LIMIT_INVALID`),
|
|
89
|
+
// never on the first request that tries to spend it.
|
|
90
|
+
if (def.rateLimit !== undefined) toBucket(name, def.rateLimit);
|
|
93
91
|
const enveloped = carriesRecords(target.output);
|
|
94
92
|
|
|
95
93
|
const handler = async (req: UltimateRequest): Promise<Response> => {
|
|
@@ -121,6 +119,9 @@ function projectRoute(
|
|
|
121
119
|
onReplay: () => {
|
|
122
120
|
replayed = true;
|
|
123
121
|
},
|
|
122
|
+
clientAddress: req.ctx.ip,
|
|
123
|
+
onRateLimit: (decision) =>
|
|
124
|
+
publishRateLimit(req.ctx, decision, useContext().now().getTime()),
|
|
124
125
|
}),
|
|
125
126
|
);
|
|
126
127
|
// The one thing an action's return value cannot say. `setRedirect()` inside the handler
|
|
@@ -134,7 +135,7 @@ function projectRoute(
|
|
|
134
135
|
? redirect(to.location, to.status)
|
|
135
136
|
: enveloped
|
|
136
137
|
? recordResponse(target.output, result)
|
|
137
|
-
:
|
|
138
|
+
: jsonResponse(result);
|
|
138
139
|
if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
|
|
139
140
|
return response;
|
|
140
141
|
};
|
|
@@ -154,6 +155,9 @@ function projectRoute(
|
|
|
154
155
|
// stage deciding first would decide from `row: null` — a denial for the row's own
|
|
155
156
|
// author, from an authz system that never saw the row.
|
|
156
157
|
enforcedBy: 'handler',
|
|
158
|
+
// Which primitive this is, for `@ultimat3/http`'s bucket check: a `rateLimit.buckets.<name>`
|
|
159
|
+
// named after an action limits nothing since 25.0.0, and is refused rather than ignored.
|
|
160
|
+
primitive: 'action',
|
|
157
161
|
// `input` stays ABSENT, deliberately, exactly as `@ultimat3/query`'s `toQueryRoute` leaves it.
|
|
158
162
|
// Setting it hands the action's schema to the pipeline's `body` stage, which throws
|
|
159
163
|
// `bodyInvalid` — so this route answered `422 X_BODY_INVALID` for every malformed body while
|
|
@@ -167,12 +171,11 @@ function projectRoute(
|
|
|
167
171
|
// failing a plain `route.ts`'s own schema, where no primitive owns the input.
|
|
168
172
|
cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
|
|
169
173
|
tags: [operationTagOf(target)],
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
: { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
|
|
174
|
+
// `invoke` spends the declared bucket for every surface, so the stage spends no caller bucket
|
|
175
|
+
// for this route — not the action's (that was the second, HTTP-only enforcement point MCP,
|
|
176
|
+
// the agent tool and `.job()` never reached) and not `default` (a ceiling an action declaring
|
|
177
|
+
// more than it never had). The tenant allowance is still the stage's.
|
|
178
|
+
...(def.rateLimit === undefined ? {} : { rateLimitedBy: 'handler' as const }),
|
|
176
179
|
...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
|
|
177
180
|
};
|
|
178
181
|
|
|
@@ -254,7 +257,7 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
254
257
|
invalidates: tagKeys(def.cache?.invalidates ?? []),
|
|
255
258
|
// The tool name an agent would call, or `null` when there is no tool. `!== false` here
|
|
256
259
|
// advertised one for every action, so an agent reading the spec asked for a tool the MCP
|
|
257
|
-
// catalog never listed — `isMcpExposed` is the same answer `
|
|
260
|
+
// catalog never listed — `isMcpExposed` is the same answer `@ultimat3/mcp` gives. The NAME was
|
|
258
261
|
// the second half of the same defect: `toToolName` published `publish_post` while
|
|
259
262
|
// `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not
|
|
260
263
|
// exist. Verbatim, and never derived — this is a published contract, not a label.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and for tests, and refused at registration under a `shared` declaration — its `scope` says so.
|
|
4
4
|
* Shaped after `@ultimat3/http`'s `memoryRateLimitStore`, including the deliberate eviction order.
|
|
5
5
|
*/
|
|
6
|
-
import { finiteCount,
|
|
6
|
+
import { finiteCount, uuidV7 } from '@ultimat3/core';
|
|
7
7
|
import { IdempotencyReservationLostError } from './errors-idempotency';
|
|
8
8
|
import type {
|
|
9
9
|
IdempotencyFailure,
|
|
@@ -60,6 +60,7 @@ interface Flight {
|
|
|
60
60
|
export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
61
61
|
/** Declared, never inferred: this map is this process's and nothing else can reach it. */
|
|
62
62
|
readonly scope: IdempotencyScope = 'process';
|
|
63
|
+
readonly keepsRedaction = true as const;
|
|
63
64
|
readonly windowMs: number;
|
|
64
65
|
readonly #maxKeys: number;
|
|
65
66
|
readonly #evictTo: number;
|
|
@@ -74,13 +75,13 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
74
75
|
// size and `now - at > windowMs` is false for every record — a table with no cap, holding keys
|
|
75
76
|
// that never expire, out of a `Math.max` that reads like a guard.
|
|
76
77
|
this.windowMs = finiteCount(
|
|
77
|
-
'
|
|
78
|
+
'memoryIdempotencyStore',
|
|
78
79
|
'windowMs',
|
|
79
80
|
options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS,
|
|
80
81
|
1,
|
|
81
82
|
);
|
|
82
83
|
this.#maxKeys = finiteCount(
|
|
83
|
-
'
|
|
84
|
+
'memoryIdempotencyStore',
|
|
84
85
|
'maxKeys',
|
|
85
86
|
options.maxKeys ?? DEFAULT_MAX_IDEMPOTENCY_KEYS,
|
|
86
87
|
1,
|
|
@@ -109,7 +110,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
109
110
|
return Promise.resolve({ record: existing, created: false });
|
|
110
111
|
}
|
|
111
112
|
const record: IdempotencyRecord = {
|
|
112
|
-
id:
|
|
113
|
+
id: uuidV7(),
|
|
113
114
|
key,
|
|
114
115
|
requestHash,
|
|
115
116
|
status: 'in-flight',
|
|
@@ -132,7 +133,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
132
133
|
if (record.status !== 'in-flight' || flight === undefined) return false;
|
|
133
134
|
if (!flight.bound || flight.settling) return false;
|
|
134
135
|
const reclaimAfterMs = finiteCount(
|
|
135
|
-
'
|
|
136
|
+
'memoryIdempotencyStore',
|
|
136
137
|
'reclaimAfterMs',
|
|
137
138
|
this.#reclaimAfterMs(),
|
|
138
139
|
);
|
|
@@ -148,11 +149,18 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
148
149
|
* again — which is why the id is half the fence. Both stores fence, or the guarantee is
|
|
149
150
|
* whichever store the deployment happens to install.
|
|
150
151
|
*/
|
|
151
|
-
settle(key: string, value: unknown, reservationId: string): Promise<void> {
|
|
152
|
+
settle(key: string, value: unknown, reservationId: string, redacted: boolean): Promise<void> {
|
|
152
153
|
const existing = this.#owned(key, reservationId);
|
|
154
|
+
// Spread only when set, so an unredacted record keeps the exact shape it always had.
|
|
155
|
+
const settled = (from: IdempotencyRecord): IdempotencyRecord => ({
|
|
156
|
+
...from,
|
|
157
|
+
status: 'settled',
|
|
158
|
+
value,
|
|
159
|
+
...(redacted ? { redacted: true } : {}),
|
|
160
|
+
});
|
|
153
161
|
const tx = liveTransaction();
|
|
154
162
|
if (tx === undefined) {
|
|
155
|
-
if (existing !== undefined) this.#records.set(key,
|
|
163
|
+
if (existing !== undefined) this.#records.set(key, settled(existing));
|
|
156
164
|
return Promise.resolve();
|
|
157
165
|
}
|
|
158
166
|
// Inside a transaction the record is settled BY the commit, as the Postgres store's is: a
|
|
@@ -170,7 +178,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
170
178
|
tx.onCommit(() => {
|
|
171
179
|
// Still this reservation's: `settling` kept a reclaim away, and `release` may have dropped it.
|
|
172
180
|
if (this.#records.get(key) === existing) {
|
|
173
|
-
this.#records.set(key,
|
|
181
|
+
this.#records.set(key, settled(existing));
|
|
174
182
|
}
|
|
175
183
|
});
|
|
176
184
|
return Promise.resolve();
|
|
@@ -192,8 +200,9 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
192
200
|
return existing;
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
/** Fenced by `#owned`, as both settlements are: a record that is not this reservation stays. */
|
|
204
|
+
release(key: string, reservationId: string): Promise<void> {
|
|
205
|
+
if (this.#owned(key, reservationId) !== undefined) this.#records.delete(key);
|
|
197
206
|
return Promise.resolve();
|
|
198
207
|
}
|
|
199
208
|
|
|
@@ -235,3 +244,13 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
235
244
|
// becomes sweepable the moment it ages past the window.
|
|
236
245
|
}
|
|
237
246
|
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The one way to build the memory store — the twin of `postgresIdempotencyStore()`. The class
|
|
250
|
+
* stays a type in the barrel (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
|
|
251
|
+
*/
|
|
252
|
+
export function memoryIdempotencyStore(
|
|
253
|
+
options: MemoryIdempotencyStoreOptions = {},
|
|
254
|
+
): MemoryIdempotencyStore {
|
|
255
|
+
return new MemoryIdempotencyStore(options);
|
|
256
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
*/
|
|
7
7
|
import type { PgExecutor } from '@ultimat3/core';
|
|
8
|
-
import { finiteCount, logger,
|
|
8
|
+
import { finiteCount, logger, uuidV7 } from '@ultimat3/core';
|
|
9
9
|
import { IdempotencyReservationLostError, IdempotencyStatusUnknownError } from './errors';
|
|
10
10
|
import type {
|
|
11
11
|
IdempotencyFailure,
|
|
@@ -39,6 +39,8 @@ create table if not exists x_idempotency (
|
|
|
39
39
|
create index if not exists x_idempotency_created_at_idx on x_idempotency (created_at);
|
|
40
40
|
|
|
41
41
|
alter table x_idempotency add column if not exists tx_bound boolean not null default false;
|
|
42
|
+
|
|
43
|
+
alter table x_idempotency add column if not exists redacted boolean not null default false;
|
|
42
44
|
`;
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -62,18 +64,19 @@ on conflict (key) do update
|
|
|
62
64
|
status = 'in-flight',
|
|
63
65
|
value = null,
|
|
64
66
|
failure = null,
|
|
67
|
+
redacted = false,
|
|
65
68
|
tx_bound = excluded.tx_bound,
|
|
66
69
|
created_at = now()
|
|
67
70
|
where x_idempotency.created_at < now() - make_interval(secs => $4::double precision)
|
|
68
71
|
or (x_idempotency.status = 'in-flight'
|
|
69
72
|
and x_idempotency.tx_bound
|
|
70
73
|
and x_idempotency.created_at < now() - make_interval(secs => $6::double precision))
|
|
71
|
-
returning key, id, request_hash, status, value, failure,
|
|
74
|
+
returning key, id, request_hash, status, value, failure, redacted,
|
|
72
75
|
(extract(epoch from created_at) * 1000)::bigint as created_at
|
|
73
76
|
`;
|
|
74
77
|
|
|
75
78
|
export const SQL_IDEMPOTENCY_GET = `
|
|
76
|
-
select key, id, request_hash, status, value, failure,
|
|
79
|
+
select key, id, request_hash, status, value, failure, redacted,
|
|
77
80
|
(extract(epoch from created_at) * 1000)::bigint as created_at
|
|
78
81
|
from x_idempotency
|
|
79
82
|
where key = $1
|
|
@@ -93,18 +96,22 @@ select key, id, request_hash, status, value, failure,
|
|
|
93
96
|
* the refusal observable: an update matching no row is indistinguishable from one that matched.
|
|
94
97
|
*/
|
|
95
98
|
export const SQL_IDEMPOTENCY_SETTLE = `
|
|
96
|
-
update x_idempotency set status = 'settled', value = $2::jsonb, failure = null
|
|
99
|
+
update x_idempotency set status = 'settled', value = $2::jsonb, failure = null,
|
|
100
|
+
redacted = $4::boolean
|
|
97
101
|
where key = $1 and id = $3::uuid and status = 'in-flight'
|
|
98
102
|
returning key
|
|
99
103
|
`;
|
|
100
104
|
|
|
101
105
|
export const SQL_IDEMPOTENCY_FAIL = `
|
|
102
|
-
update x_idempotency set status = 'failed', value = null, failure = $2::jsonb
|
|
106
|
+
update x_idempotency set status = 'failed', value = null, failure = $2::jsonb,
|
|
107
|
+
redacted = false
|
|
103
108
|
where key = $1 and id = $3::uuid and status = 'in-flight'
|
|
104
109
|
returning key
|
|
105
110
|
`;
|
|
106
111
|
|
|
107
|
-
export const SQL_IDEMPOTENCY_RELEASE = `
|
|
112
|
+
export const SQL_IDEMPOTENCY_RELEASE = `
|
|
113
|
+
delete from x_idempotency where key = $1 and id = $2::uuid and status = 'in-flight'
|
|
114
|
+
`;
|
|
108
115
|
|
|
109
116
|
export const SQL_IDEMPOTENCY_PURGE = `
|
|
110
117
|
delete from x_idempotency where created_at < now() - make_interval(secs => $1::double precision)
|
|
@@ -117,6 +124,8 @@ interface IdempotencyRow {
|
|
|
117
124
|
readonly status: string;
|
|
118
125
|
readonly value: unknown;
|
|
119
126
|
readonly failure: unknown;
|
|
127
|
+
/** Absent on a row read by a statement that predates the column — read as `false`. */
|
|
128
|
+
readonly redacted?: boolean | undefined;
|
|
120
129
|
/** `bigint`, which every Postgres client hands back as a string. */
|
|
121
130
|
readonly created_at: number | string;
|
|
122
131
|
}
|
|
@@ -220,6 +229,7 @@ export function postgresIdempotencyStore(
|
|
|
220
229
|
|
|
221
230
|
return {
|
|
222
231
|
scope: 'shared',
|
|
232
|
+
keepsRedaction: true,
|
|
223
233
|
windowMs,
|
|
224
234
|
|
|
225
235
|
async reserve(key, requestHash): Promise<IdempotencyReservation> {
|
|
@@ -232,7 +242,7 @@ export function postgresIdempotencyStore(
|
|
|
232
242
|
// transaction is the SETTLE — and the row says so, which is what makes it reclaimable.
|
|
233
243
|
const claimed = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_RESERVE, [
|
|
234
244
|
key,
|
|
235
|
-
|
|
245
|
+
uuidV7(),
|
|
236
246
|
requestHash,
|
|
237
247
|
windowSecs,
|
|
238
248
|
boundTx() !== undefined,
|
|
@@ -247,7 +257,7 @@ export function postgresIdempotencyStore(
|
|
|
247
257
|
// that matters, so this is the honest answer: the caller sees the in-flight refusal.
|
|
248
258
|
return {
|
|
249
259
|
record: {
|
|
250
|
-
id:
|
|
260
|
+
id: uuidV7(),
|
|
251
261
|
key,
|
|
252
262
|
requestHash,
|
|
253
263
|
status: 'in-flight',
|
|
@@ -258,8 +268,10 @@ export function postgresIdempotencyStore(
|
|
|
258
268
|
};
|
|
259
269
|
},
|
|
260
270
|
|
|
261
|
-
|
|
262
|
-
|
|
271
|
+
// `value` is the resting copy `withIdempotency` redacted; `redacted` is kept beside it so the
|
|
272
|
+
// replay is refused by a column, never by searching the stored JSON for a marker string.
|
|
273
|
+
async settle(key, value, reservationId, redacted): Promise<void> {
|
|
274
|
+
const params = [key, JSON.stringify(value ?? null), reservationId, redacted];
|
|
263
275
|
const tx = boundTx();
|
|
264
276
|
if (tx === undefined) {
|
|
265
277
|
fenced(await exec.query(SQL_IDEMPOTENCY_SETTLE, params), key, reservationId, 'settle');
|
|
@@ -284,8 +296,10 @@ export function postgresIdempotencyStore(
|
|
|
284
296
|
fenced(rows, key, reservationId, 'fail');
|
|
285
297
|
},
|
|
286
298
|
|
|
287
|
-
|
|
288
|
-
|
|
299
|
+
// Fenced, and silent when it matches nothing: a release is a pre-handler cleanup, so a record
|
|
300
|
+
// that is no longer this reservation's is somebody else's to settle.
|
|
301
|
+
async release(key, reservationId): Promise<void> {
|
|
302
|
+
await exec.query(SQL_IDEMPOTENCY_RELEASE, [key, reservationId]);
|
|
289
303
|
},
|
|
290
304
|
|
|
291
305
|
get: fetch,
|
|
@@ -338,6 +352,7 @@ function toRecord(row: IdempotencyRow): IdempotencyRecord {
|
|
|
338
352
|
status: row.status,
|
|
339
353
|
value: row.value,
|
|
340
354
|
...(failure === undefined ? {} : { failure }),
|
|
355
|
+
...(row.redacted === true ? { redacted: true } : {}),
|
|
341
356
|
createdAt: Number(row.created_at),
|
|
342
357
|
};
|
|
343
358
|
}
|