@ultimat3/action 23.0.0 → 25.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 +91 -42
- package/README.md +167 -47
- package/package.json +10 -9
- 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 +5 -3
- package/src/audit-postgres.ts +16 -8
- package/src/audit.ts +7 -106
- package/src/cache-gate.ts +25 -6
- package/src/client.ts +18 -6
- package/src/contract-test.ts +50 -14
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +57 -5
- package/src/errors.ts +44 -61
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +92 -8
- package/src/idempotency-postgres.ts +91 -39
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +58 -7
- package/src/index.ts +37 -88
- package/src/invoke.ts +57 -21
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +6 -10
- package/src/mutator.ts +14 -4
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/openapi.ts +25 -2
- 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 +5 -4
- package/src/request-deadline.ts +25 -0
- package/src/stable.ts +0 -7
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
- package/src/type-pins.ts +98 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -87
- package/src/wire-headers.ts +0 -20
package/src/idempotency.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
IdempotencyConflictError,
|
|
10
10
|
IdempotencyNotSharedError,
|
|
11
11
|
IdempotencyReplayedFailureError,
|
|
12
|
+
IdempotentReplayRedactedError,
|
|
12
13
|
} from './errors';
|
|
13
|
-
import {
|
|
14
|
+
import { memoryIdempotencyStore } from './idempotency-memory';
|
|
15
|
+
import { restingAnswer } from './idempotency-redact';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Where a store's records live. Declared by the driver, never inferred — the same rule
|
|
@@ -68,6 +70,12 @@ export interface IdempotencyRecord {
|
|
|
68
70
|
readonly value: unknown;
|
|
69
71
|
/** Present exactly when `status === 'failed'` — what the replay re-throws. */
|
|
70
72
|
readonly failure?: IdempotencyFailure | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* True when `value` had a field redacted at rest (`idempotency-redact.ts`). Its replay is
|
|
75
|
+
* `X_IDEMPOTENT_REPLAY_REDACTED`, decided by this flag — never by scanning the value for
|
|
76
|
+
* `[redacted]`, a string a legitimate answer may hold. Absent or false: replayed as stored.
|
|
77
|
+
*/
|
|
78
|
+
readonly redacted?: boolean | undefined;
|
|
71
79
|
/** Epoch milliseconds. The store's own dedupe window is measured from here. */
|
|
72
80
|
readonly createdAt: number;
|
|
73
81
|
}
|
|
@@ -91,6 +99,14 @@ export interface IdempotencyStore {
|
|
|
91
99
|
* is one immortal entry per write, forever.
|
|
92
100
|
*/
|
|
93
101
|
readonly windowMs?: number | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* The store's declaration that it keeps `settle`'s `redacted` flag and hands it back as
|
|
104
|
+
* `IdempotencyRecord.redacted` (#591). REQUIRED, and a literal: a store written before answers
|
|
105
|
+
* were redacted at rest drops the flag and would replay `[redacted]` as the answer, and a
|
|
106
|
+
* required parameter alone cannot refuse it — TypeScript accepts an implementation with fewer
|
|
107
|
+
* parameters. Without this member, such a store is a build error (`type-pins.ts`).
|
|
108
|
+
*/
|
|
109
|
+
readonly keepsRedaction: true;
|
|
94
110
|
/** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
|
|
95
111
|
reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
|
|
96
112
|
/**
|
|
@@ -104,8 +120,13 @@ export interface IdempotencyStore {
|
|
|
104
120
|
* exactly, overwrote a live reservation, and the replacement's own settle was then fenced out —
|
|
105
121
|
* so the retry replayed a value produced for a different request. A settlement that matches no
|
|
106
122
|
* record is logged, never thrown: it lands after the handler has committed.
|
|
123
|
+
*
|
|
124
|
+
* `value` is already the RESTING copy — `withIdempotency` redacts before it calls, so no store
|
|
125
|
+
* ever receives a credential. `redacted` says it did, and a store MUST keep it and hand it back
|
|
126
|
+
* as `IdempotencyRecord.redacted`: one that drops it replays `[redacted]` as the answer. Required
|
|
127
|
+
* of every caller, so a store that wraps another cannot drop it on the way through.
|
|
107
128
|
*/
|
|
108
|
-
settle(key: string, value: unknown, reservationId: string): Promise<void>;
|
|
129
|
+
settle(key: string, value: unknown, reservationId: string, redacted: boolean): Promise<void>;
|
|
109
130
|
/**
|
|
110
131
|
* Settle a FAILURE, so the retry replays it instead of re-running a handler that may already
|
|
111
132
|
* have committed. Fenced on the same reservation id as `settle`, for the same case — a
|
|
@@ -136,7 +157,7 @@ export interface IdempotencyConfig {
|
|
|
136
157
|
export const DEFAULT_IDEMPOTENCY_CONFIG: IdempotencyConfig = Object.freeze({ scope: 'process' });
|
|
137
158
|
|
|
138
159
|
let config: IdempotencyConfig = DEFAULT_IDEMPOTENCY_CONFIG;
|
|
139
|
-
let defaultStore: IdempotencyStore =
|
|
160
|
+
let defaultStore: IdempotencyStore = memoryIdempotencyStore();
|
|
140
161
|
|
|
141
162
|
/** Declared at boot, before `registerActions()`. Nothing infers a replica count. */
|
|
142
163
|
export function configureIdempotency(next: IdempotencyConfig): void {
|
|
@@ -158,7 +179,7 @@ export function getIdempotencyStore(): IdempotencyStore {
|
|
|
158
179
|
/** Test-only. A process configures its idempotency once at boot and never reconfigures it. */
|
|
159
180
|
export function resetIdempotency(): void {
|
|
160
181
|
config = DEFAULT_IDEMPOTENCY_CONFIG;
|
|
161
|
-
defaultStore =
|
|
182
|
+
defaultStore = memoryIdempotencyStore();
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
/**
|
|
@@ -190,19 +211,30 @@ export interface IdempotentOutcome<T> {
|
|
|
190
211
|
* X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a duplicate still in
|
|
191
212
|
* flight.
|
|
192
213
|
*
|
|
193
|
-
* **Everything `run()` throws is treated as possibly-committed.** `
|
|
214
|
+
* **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `validateInput`
|
|
194
215
|
* both run before this gate is reached (`invoke.ts`), so by the time `run` is called the only
|
|
195
216
|
* things left are the handler and `validateOutput` — and the second of those throws *after* the
|
|
196
217
|
* first has committed. Releasing the reservation there is what turned a rounding change in an
|
|
197
218
|
* output schema into a second charge: `X_OUTPUT_INVALID` dropped the record, and the client's
|
|
198
219
|
* automatic retry re-ran a handler that had already taken the money. So the failure is SETTLED
|
|
199
|
-
* and replayed, and `release` is reserved for a pre-handler failure —
|
|
220
|
+
* and replayed, and `release` is reserved for a pre-handler failure — `beforeRun` is the only one.
|
|
200
221
|
*/
|
|
222
|
+
export interface WithIdempotencyOptions {
|
|
223
|
+
/**
|
|
224
|
+
* Called only for a reservation this call CREATED, before `run` — `invoke`'s rate-limit spend
|
|
225
|
+
* for a key its pre-parse peek took for a replay (a stale record reclaimed in between). A throw
|
|
226
|
+
* here is a PRE-handler failure, so the reservation is released, never settled: a refusal is not
|
|
227
|
+
* the key's outcome, and a retry once the bucket refills must still be able to run.
|
|
228
|
+
*/
|
|
229
|
+
readonly beforeRun?: () => Promise<void>;
|
|
230
|
+
}
|
|
231
|
+
|
|
201
232
|
export async function withIdempotency<T>(
|
|
202
233
|
store: IdempotencyStore,
|
|
203
234
|
key: string,
|
|
204
235
|
input: unknown,
|
|
205
236
|
run: () => Promise<T>,
|
|
237
|
+
options: WithIdempotencyOptions = {},
|
|
206
238
|
): Promise<IdempotentOutcome<T>> {
|
|
207
239
|
const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
|
|
208
240
|
const { record, created } = await store.reserve(key, requestHash);
|
|
@@ -210,9 +242,20 @@ export async function withIdempotency<T>(
|
|
|
210
242
|
if (!created) {
|
|
211
243
|
if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
|
|
212
244
|
if (record.status === 'failed') throw new IdempotencyReplayedFailureError(key, record.failure);
|
|
245
|
+
// The stored value lost a credential at rest: serving it would hand `[redacted]` back as the
|
|
246
|
+
// answer, so the replay is refused — and, like any replay, spends and runs nothing.
|
|
247
|
+
if (record.redacted === true) throw new IdempotentReplayRedactedError(key);
|
|
213
248
|
// The stored value is the previous return of this very handler.
|
|
214
249
|
return { value: record.value as T, replayed: true };
|
|
215
250
|
}
|
|
251
|
+
if (options.beforeRun !== undefined) {
|
|
252
|
+
try {
|
|
253
|
+
await options.beforeRun();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
await store.release(key);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
216
259
|
let value: T;
|
|
217
260
|
try {
|
|
218
261
|
value = await run();
|
|
@@ -226,7 +269,15 @@ export async function withIdempotency<T>(
|
|
|
226
269
|
//
|
|
227
270
|
// `record.id` is THIS reservation's, so a straggler from an attempt whose window has since
|
|
228
271
|
// lapsed cannot land on the replacement that reclaimed the key.
|
|
229
|
-
|
|
272
|
+
//
|
|
273
|
+
// Inside a transaction the shared store settles on that transaction's connection, so "settled"
|
|
274
|
+
// and "the write is durable" are one COMMIT — and a settle that lost its reservation THROWS
|
|
275
|
+
// (`X_IDEMPOTENCY_RESERVATION_LOST`), which is the rollback of an attempt a retry replaced.
|
|
276
|
+
//
|
|
277
|
+
// The CALLER gets `value` whole; the store gets the resting copy, so an answer carrying a
|
|
278
|
+
// credential is never written down in plaintext for the window (#591).
|
|
279
|
+
const resting = restingAnswer(value);
|
|
280
|
+
await store.settle(key, resting.value, record.id, resting.redacted);
|
|
230
281
|
return { value, replayed: false };
|
|
231
282
|
}
|
|
232
283
|
|
package/src/index.ts
CHANGED
|
@@ -7,38 +7,18 @@
|
|
|
7
7
|
* framework died; there is exactly one here, structurally.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// Imported bare, and the one module `sideEffects` lists: a browser that rebuilds a SERVER's refusal
|
|
11
|
+
// (`problemError`, by code) constructs nothing from `errors.ts`, and still has to render the title
|
|
12
|
+
// this package registered. `SIDE_EFFECTS_ANCHORS` carries the argument and `bun run side-effects`
|
|
13
|
+
// enforces it.
|
|
14
|
+
import './error-titles';
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* one flight map, one gate, one backoff curve for the whole framework — and so is the pipeline
|
|
15
|
-
* itself: it shipped as a byte-identical copy here and in `@ultimat3/query`, and
|
|
16
|
-
* two tier-3 packages may not import each other, so the one copy lives at tier 0.
|
|
17
|
-
*
|
|
18
|
-
* Re-exported rather than re-declared, so every name is importable from this package exactly as
|
|
19
|
-
* before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
|
|
20
|
-
* flight, and it should not cost a second import.
|
|
21
|
-
*/
|
|
22
|
-
export type {
|
|
23
|
-
ClientFlight,
|
|
24
|
-
ClientFlightOptions,
|
|
25
|
-
ClientRetry,
|
|
26
|
-
FlightKeyOptions,
|
|
27
|
-
FlightPlan,
|
|
28
|
-
WireAnswer,
|
|
29
|
-
} from '@ultimat3/core';
|
|
30
|
-
export {
|
|
31
|
-
createClientFlight,
|
|
32
|
-
DEFAULT_CLIENT_RETRY,
|
|
33
|
-
isSuperseded,
|
|
34
|
-
isTransientFailure,
|
|
35
|
-
} from '@ultimat3/core';
|
|
36
|
-
/**
|
|
37
|
-
* `toBucket` is `@ultimat3/http`'s — http owns `Bucket` and the limiter maths, and `action` and
|
|
38
|
-
* `query` are the same tier, so a copy in either is a second answer for the other. Re-exported
|
|
39
|
-
* here, not re-implemented, so an action file still reaches it through one import.
|
|
17
|
+
* Types only: the barrel's own declarations name them. Flight control's VALUES are imported from
|
|
18
|
+
* `@ultimat3/core`, which holds the one pipeline for action and query alike; a value re-export
|
|
19
|
+
* here is `X_HELPER_COPY`.
|
|
40
20
|
*/
|
|
41
|
-
export {
|
|
21
|
+
export type { ClientFlight, ClientRetry, PgExecutor } from '@ultimat3/core';
|
|
42
22
|
/** Re-exported so an `action` file needs one import, not two. Same object as schema's. */
|
|
43
23
|
export type { Infer } from '@ultimat3/schema';
|
|
44
24
|
export { t } from '@ultimat3/schema';
|
|
@@ -54,7 +34,6 @@ export type {
|
|
|
54
34
|
ActionRowArgs,
|
|
55
35
|
AnyAction,
|
|
56
36
|
InvokeOptions,
|
|
57
|
-
McpAnnotationHints,
|
|
58
37
|
McpDescriptorMeta,
|
|
59
38
|
} from './action';
|
|
60
39
|
export { action, describeAction, isAction } from './action';
|
|
@@ -73,25 +52,21 @@ export { apiDeclaration } from './api-declaration';
|
|
|
73
52
|
/**
|
|
74
53
|
* The audit seam. `AuditSink` is the whole extension point: the framework supplies the record
|
|
75
54
|
* and never the row. `audit-gate.ts` stays unexported — the sink has one caller, and that
|
|
76
|
-
* absence is what keeps it one.
|
|
55
|
+
* absence is what keeps it one. The record and the sink are `@ultimat3/core`'s, re-exported as
|
|
56
|
+
* TYPES only because this barrel's sinks name them; the slot (`setAuditSink`) is core's alone.
|
|
77
57
|
*/
|
|
78
|
-
export type {
|
|
79
|
-
AuditFailure,
|
|
80
|
-
AuditOutcome,
|
|
81
|
-
AuditRecord,
|
|
82
|
-
AuditSink,
|
|
83
|
-
} from './audit';
|
|
84
|
-
export { getAuditSink, resetAuditSink, setAuditSink } from './audit';
|
|
58
|
+
export type { AuditRecord, AuditSink } from './audit';
|
|
85
59
|
export { AUDIT_INPUT_MAX_DEPTH, auditableInput, UNREPRESENTABLE } from './audit-input';
|
|
86
60
|
export type { MemoryAuditSink, MemoryAuditSinkOptions } from './audit-memory';
|
|
87
61
|
export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
|
|
88
62
|
/**
|
|
89
|
-
* The DURABLE sink, and the only one an app that must keep its trail may install. The
|
|
90
|
-
*
|
|
91
|
-
* boot, never by an app migration.
|
|
63
|
+
* The DURABLE sink, and the only one an app that must keep its trail may install. The table's DDL
|
|
64
|
+
* is exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
|
|
65
|
+
* boot, never by an app migration. The insert is not: it is the sink's own statement, and an
|
|
66
|
+
* export with no reader outside this package is `X_SQL_EXPORT_UNREAD` (`bun run sql-export-readers`).
|
|
92
67
|
*/
|
|
93
68
|
export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
|
|
94
|
-
export { postgresAuditSink,
|
|
69
|
+
export { postgresAuditSink, SQL_AUDIT_TABLE } from './audit-postgres';
|
|
95
70
|
export type {
|
|
96
71
|
ActionLike,
|
|
97
72
|
ActionMap,
|
|
@@ -106,14 +81,6 @@ export type { ContractTest, ContractTestOptions } from './contract-test';
|
|
|
106
81
|
export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test';
|
|
107
82
|
export type { Api, ApiDef, ApiModule, ApiModules } from './define-api';
|
|
108
83
|
export { defineApi } from './define-api';
|
|
109
|
-
/**
|
|
110
|
-
* The compat window a retirement gets. Versioning itself is NOT here and never will be: two
|
|
111
|
-
* versions of one action side by side is two deployments behind one ingress (axiom 7), not a
|
|
112
|
-
* router feature. `renderDeprecation` is exported so a plain `route` can announce the same pair
|
|
113
|
-
* of headers the action projection does.
|
|
114
|
-
*/
|
|
115
|
-
export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
|
|
116
|
-
export { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
117
84
|
export type { IdempotencyConflictReason, IdempotencyKeyProblem, RemoteFailure } from './errors';
|
|
118
85
|
export {
|
|
119
86
|
ActionDeniedError,
|
|
@@ -130,8 +97,11 @@ export {
|
|
|
130
97
|
IdempotencyKeyInvalidError,
|
|
131
98
|
IdempotencyNotSharedError,
|
|
132
99
|
IdempotencyReplayedFailureError,
|
|
100
|
+
IdempotencyReservationLostError,
|
|
133
101
|
IdempotencyStatusUnknownError,
|
|
102
|
+
IdempotentReplayRedactedError,
|
|
134
103
|
InputInvalidError,
|
|
104
|
+
MutatorNotIdempotentError,
|
|
135
105
|
OutputInvalidError,
|
|
136
106
|
RemoteActionError,
|
|
137
107
|
RpcFailedError,
|
|
@@ -143,8 +113,6 @@ export {
|
|
|
143
113
|
} from './errors-http';
|
|
144
114
|
export type { OpenApiOperation } from './http';
|
|
145
115
|
export {
|
|
146
|
-
BUILD_ID_HEADER,
|
|
147
|
-
IDEMPOTENCY_HEADER,
|
|
148
116
|
operationTagOf,
|
|
149
117
|
REPLAYED_HEADER,
|
|
150
118
|
toOpenApiOperation,
|
|
@@ -183,40 +151,27 @@ export {
|
|
|
183
151
|
withIdempotency,
|
|
184
152
|
} from './idempotency';
|
|
185
153
|
export { idempotencyKeyFor, MAX_IDEMPOTENCY_KEY_LENGTH } from './idempotency-key';
|
|
186
|
-
export type {
|
|
154
|
+
export type {
|
|
155
|
+
MemoryIdempotencyStore,
|
|
156
|
+
MemoryIdempotencyStoreOptions,
|
|
157
|
+
} from './idempotency-memory';
|
|
187
158
|
export {
|
|
188
159
|
DEFAULT_IDEMPOTENCY_WINDOW_MS,
|
|
189
160
|
DEFAULT_MAX_IDEMPOTENCY_KEYS,
|
|
190
|
-
|
|
161
|
+
memoryIdempotencyStore,
|
|
191
162
|
} from './idempotency-memory';
|
|
192
|
-
/**
|
|
193
|
-
* The SHARED store, and the only one an app running more than one replica may install. The
|
|
194
|
-
* statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
|
|
195
|
-
* by `x db up` in development and by the release-phase `ROLE=migrate` in production.
|
|
196
|
-
*/
|
|
197
163
|
export type {
|
|
198
|
-
PgExecutor,
|
|
199
164
|
PostgresIdempotencyStore,
|
|
200
165
|
PostgresIdempotencyStoreOptions,
|
|
201
166
|
} from './idempotency-postgres';
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
SQL_IDEMPOTENCY_FAIL,
|
|
205
|
-
SQL_IDEMPOTENCY_GET,
|
|
206
|
-
SQL_IDEMPOTENCY_PURGE,
|
|
207
|
-
SQL_IDEMPOTENCY_RELEASE,
|
|
208
|
-
SQL_IDEMPOTENCY_RESERVE,
|
|
209
|
-
SQL_IDEMPOTENCY_SETTLE,
|
|
210
|
-
SQL_IDEMPOTENCY_TABLE,
|
|
211
|
-
} from './idempotency-postgres';
|
|
167
|
+
/** The table's DDL alone: the boot applies it. The store's own statements stay its own. */
|
|
168
|
+
export { postgresIdempotencyStore, SQL_IDEMPOTENCY_TABLE } from './idempotency-postgres';
|
|
212
169
|
/** The one execution path. `defOf` stays unexported — that is the enforcement. */
|
|
213
170
|
export { actionName, invoke } from './invoke';
|
|
214
171
|
export type { ActionJobHandle } from './job-handle';
|
|
215
172
|
export { toJobHandle } from './job-handle';
|
|
216
173
|
export type { JsonSchemaObject } from './json-schema';
|
|
217
|
-
export { jsonSchemaOf
|
|
218
|
-
export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
|
|
219
|
-
export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
|
|
174
|
+
export { jsonSchemaOf } from './json-schema';
|
|
220
175
|
export type {
|
|
221
176
|
LocalTable,
|
|
222
177
|
LocalTableName,
|
|
@@ -228,7 +183,7 @@ export type {
|
|
|
228
183
|
} from './mutator';
|
|
229
184
|
export { custom, isMutator, mutator } from './mutator';
|
|
230
185
|
export type { ActionPath } from './naming';
|
|
231
|
-
export { derivePath, inputSchemaName, outputSchemaName
|
|
186
|
+
export { derivePath, inputSchemaName, outputSchemaName } from './naming';
|
|
232
187
|
export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
|
|
233
188
|
export { buildOpenApi, serializeOpenApi } from './openapi';
|
|
234
189
|
export {
|
|
@@ -244,19 +199,12 @@ export {
|
|
|
244
199
|
export { explainActionPathMiss } from './path-style-miss';
|
|
245
200
|
export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
|
|
246
201
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
202
|
+
* The one authz gate, named after what it guards. The display label (`policyCapability`), what a
|
|
203
|
+
* report MATCHES on (`policyPermissions`) and what `toRoute` derives `meta.auth` from
|
|
204
|
+
* (`admitsAnonymous`) are `@ultimat3/policy`'s, and the anonymous → `null` mapping (`actorOf`) is
|
|
205
|
+
* `@ultimat3/core`'s — imported from there, so `query` reads the same walk through one import path.
|
|
251
206
|
*/
|
|
252
|
-
export {
|
|
253
|
-
actorOf,
|
|
254
|
-
admitsAnonymous,
|
|
255
|
-
guard,
|
|
256
|
-
guardBeforeInput,
|
|
257
|
-
policyCapability,
|
|
258
|
-
policyPermissions,
|
|
259
|
-
} from './policy-gate';
|
|
207
|
+
export { guardAction, guardActionBeforeInput } from './policy-gate';
|
|
260
208
|
export {
|
|
261
209
|
actionHttpPath,
|
|
262
210
|
configureActionPathStyle,
|
|
@@ -265,8 +213,9 @@ export {
|
|
|
265
213
|
listActions,
|
|
266
214
|
registerAction,
|
|
267
215
|
registerActions,
|
|
268
|
-
|
|
216
|
+
resetActions,
|
|
269
217
|
} from './registry';
|
|
218
|
+
export { requestDeadlineMs } from './request-deadline';
|
|
270
219
|
/**
|
|
271
220
|
* A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
|
|
272
221
|
* a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
|
package/src/invoke.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one invocation core:
|
|
3
|
-
* output. The declaration lives in this module's private store, so `handle` is
|
|
2
|
+
* The one invocation core: ask who is calling, spend the rate limit, parse input, evaluate
|
|
3
|
+
* policy, run the handler, parse output. The declaration lives in this module's private store, so `handle` is
|
|
4
4
|
* unreachable from anywhere else — HTTP, MCP, jobs and `.as()` hand `invoke` a
|
|
5
5
|
* payload, and none of them can become a second execution path.
|
|
6
6
|
*
|
|
7
7
|
* `audit: true` wraps that path, it never forks it: `execute` is the same body either way, and
|
|
8
8
|
* the audited branch only observes it. Wrapping rather than hooking is what lets a DENIED attempt
|
|
9
|
-
* be recorded at all — `
|
|
9
|
+
* be recorded at all — `guardAction` throws before `handle`, so nothing an app writes around its own
|
|
10
10
|
* handler could ever see one.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { Ctx } from '@ultimat3/core';
|
|
14
14
|
import {
|
|
15
|
+
actorOf,
|
|
15
16
|
anonymousActor,
|
|
16
|
-
|
|
17
|
+
ctxOf,
|
|
17
18
|
isUltimateError,
|
|
18
19
|
runWithContext,
|
|
19
20
|
tryUseContext,
|
|
@@ -34,7 +35,8 @@ import { bustAfterCommit } from './cache-gate';
|
|
|
34
35
|
import { ActionForeignError, ActionUnregisteredError } from './errors';
|
|
35
36
|
import { getIdempotencyStore, withIdempotency } from './idempotency';
|
|
36
37
|
import { idempotencyKeyFor } from './idempotency-key';
|
|
37
|
-
import {
|
|
38
|
+
import { guardAction, guardActionBeforeInput } from './policy-gate';
|
|
39
|
+
import { spendActionLimit, withCallerAddress, withJobFrame } from './rate-limit-gate';
|
|
38
40
|
import { parsedOrNothing, validateInput, validateOutput } from './validate';
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -77,6 +79,15 @@ export function invoke(
|
|
|
77
79
|
raw: unknown,
|
|
78
80
|
options: InvokeOptions = {},
|
|
79
81
|
): Promise<unknown> {
|
|
82
|
+
// The address a surface handed over becomes the one every nested call inherits — an agent's
|
|
83
|
+
// tool calls are keyed by the visitor who asked, not shared by every anonymous visitor.
|
|
84
|
+
// A job run opens a fresh frame instead: no visitor's address follows work into the worker.
|
|
85
|
+
return options.surface === 'job'
|
|
86
|
+
? withJobFrame(() => invokeAs(target, raw, options))
|
|
87
|
+
: withCallerAddress(options.clientAddress, () => invokeAs(target, raw, options));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function invokeAs(target: AnyAction, raw: unknown, options: InvokeOptions): Promise<unknown> {
|
|
80
91
|
if (options.actor === undefined) {
|
|
81
92
|
// INSTALLED, never only handed over. An explicit `ctx` used to be passed to `core` and to
|
|
82
93
|
// nothing else, so everything downstream that reads the ambient context — most importantly
|
|
@@ -96,7 +107,7 @@ export function invoke(
|
|
|
96
107
|
const run = (): Promise<unknown> => core(target, raw, useContext(), options);
|
|
97
108
|
const base = options.ctx ?? tryUseContext();
|
|
98
109
|
return base === undefined
|
|
99
|
-
? runWithContext(
|
|
110
|
+
? runWithContext(ctxOf(patch), run)
|
|
100
111
|
: runWithContext(base, () => withChildContext(patch, run));
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -128,7 +139,8 @@ async function core(
|
|
|
128
139
|
const draft = {
|
|
129
140
|
// When the attempt began, from the context's clock — never `new Date()`.
|
|
130
141
|
at: ctx.now(),
|
|
131
|
-
|
|
142
|
+
name,
|
|
143
|
+
primitive: 'action',
|
|
132
144
|
// The brand `mutator()` stamps, read structurally — the same read `describeAction` makes,
|
|
133
145
|
// and for the same reason: importing `isMutator` would point this module at the one that
|
|
134
146
|
// imports it, for a check that needs the brand and not the predicate.
|
|
@@ -163,7 +175,7 @@ async function core(
|
|
|
163
175
|
/**
|
|
164
176
|
* The span covers the WHOLE invocation, not just `handle`. Wrapping the handler alone reported
|
|
165
177
|
* 40ms for an action whose p99 was 2s, because `def.row()` — the loader a row-level policy needs
|
|
166
|
-
* — ran outside it, along with the input parse and `
|
|
178
|
+
* — ran outside it, along with the input parse and `guardAction()`. The 1.96s was inside no span at
|
|
167
179
|
* all, so the only reading available was "framework overhead" and the only fix was hand
|
|
168
180
|
* instrumentation. One span, one extent, and the attributes that make it answerable.
|
|
169
181
|
*
|
|
@@ -219,15 +231,42 @@ async function perform(
|
|
|
219
231
|
const surface = options.surface ?? 'server';
|
|
220
232
|
// Who is asking is decided before what they sent is read: a caller the policy refuses whatever
|
|
221
233
|
// the input is gets 403/401 here, not a 400 whose issues describe this action's input schema.
|
|
222
|
-
// Only the actor half runs — a predicate over `input` or `row` waits for `
|
|
234
|
+
// Only the actor half runs — a predicate over `input` or `row` waits for `guardAction` below.
|
|
223
235
|
try {
|
|
224
|
-
|
|
236
|
+
guardActionBeforeInput(def.policy, { actor: actorOf(ctx), ctx, action: name }, surface);
|
|
225
237
|
} catch (denial) {
|
|
226
238
|
// The audit record still names what was attempted — the denial is already decided, so parsing
|
|
227
239
|
// now tells the CALLER nothing, and an unparseable payload is a record with no input.
|
|
228
240
|
if (def.audit === true) trace.input = await parsedOrNothing(def.input, raw);
|
|
229
241
|
throw denial;
|
|
230
242
|
}
|
|
243
|
+
// The ONE place a declared `rateLimit:` is spent, so every surface draws on the same bucket.
|
|
244
|
+
// HERE — after the actor half (a refused caller learns that, not a 429), BEFORE the parse, the
|
|
245
|
+
// row load and `guardAction`: spent later, a flood of invalid input (400) or row-denied calls (403)
|
|
246
|
+
// was never refused, and every 429 had already paid for a row load.
|
|
247
|
+
const spend = (): Promise<void> =>
|
|
248
|
+
spendActionLimit(name, def.rateLimit, ctx, {
|
|
249
|
+
surface,
|
|
250
|
+
clientAddress: options.clientAddress,
|
|
251
|
+
onRateLimit: options.onRateLimit,
|
|
252
|
+
});
|
|
253
|
+
const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
|
|
254
|
+
const store = key === null ? undefined : (options.store ?? getIdempotencyStore());
|
|
255
|
+
// The namespaced key, not the caller's: the same key under two actions — or from two callers
|
|
256
|
+
// — is two keys, and an audit row keyed on the raw header would collide across both. The
|
|
257
|
+
// ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
|
|
258
|
+
if (key !== null) trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
|
|
259
|
+
// A replay is its stored answer and spends nothing — a 429 in its place told a client its
|
|
260
|
+
// committed write had not happened. A key the store already holds is a replay (or a duplicate in
|
|
261
|
+
// flight, answered 409); only a key it does not hold spends here. The peek can be stale both
|
|
262
|
+
// ways, so `withIdempotency` meters a reservation it CREATED that this peek did not pay for.
|
|
263
|
+
const spentEarly =
|
|
264
|
+
trace.idempotencyKey === null ||
|
|
265
|
+
store === undefined ||
|
|
266
|
+
def.rateLimit === undefined ||
|
|
267
|
+
(await store.get(trace.idempotencyKey)) === undefined;
|
|
268
|
+
if (spentEarly) await spend();
|
|
269
|
+
|
|
231
270
|
const input = await validateInput(def.input, raw, name);
|
|
232
271
|
trace.input = input;
|
|
233
272
|
// The one place a row-level rule gets its row. Once per invocation, never per row:
|
|
@@ -236,7 +275,7 @@ async function perform(
|
|
|
236
275
|
// action with no loader hands the rule `null` — unchanged, and never a silent allow,
|
|
237
276
|
// because a rule that reads `row` has to decide what `null` means.
|
|
238
277
|
const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
|
|
239
|
-
|
|
278
|
+
guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
|
|
240
279
|
|
|
241
280
|
// Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
|
|
242
281
|
// one shape on the wire, first call and every retry. No span of its own: `execute` above holds
|
|
@@ -246,18 +285,14 @@ async function perform(
|
|
|
246
285
|
return validateOutput(def.output, produced, name);
|
|
247
286
|
};
|
|
248
287
|
|
|
249
|
-
const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
|
|
250
288
|
let value: unknown;
|
|
251
289
|
let wrote = true;
|
|
252
|
-
if (
|
|
290
|
+
if (store === undefined || trace.idempotencyKey === null) {
|
|
253
291
|
value = await run();
|
|
254
292
|
} else {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
|
|
259
|
-
trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
|
|
260
|
-
const outcome = await withIdempotency(store, trace.idempotencyKey, input, run);
|
|
293
|
+
const outcome = await withIdempotency(store, trace.idempotencyKey, input, run, {
|
|
294
|
+
...(spentEarly ? {} : { beforeRun: spend }),
|
|
295
|
+
});
|
|
261
296
|
if (outcome.replayed) options.onReplay?.();
|
|
262
297
|
trace.replayed = outcome.replayed;
|
|
263
298
|
wrote = !outcome.replayed;
|
|
@@ -268,8 +303,9 @@ async function perform(
|
|
|
268
303
|
}
|
|
269
304
|
// Only for a run that actually happened, and only through the gate. A replay ran no handler
|
|
270
305
|
// and changed nothing the first call had not already busted — re-busting per retry re-purges
|
|
271
|
-
// the CDN and re-queues ISR for a write nobody made. And the bust is post-
|
|
272
|
-
//
|
|
306
|
+
// the CDN and re-queues ISR for a write nobody made. And the bust is post-COMMIT either way —
|
|
307
|
+
// invoked inside a transaction, it waits for the root commit and is dropped by a rollback — so
|
|
308
|
+
// `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
|
|
273
309
|
// durable write into a failed action.
|
|
274
310
|
if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
|
|
275
311
|
return value;
|
package/src/job-handle.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Projection 5: an action as durable work — its input schema, a payload-derived
|
|
3
3
|
* idempotency key, and an `invoke` that runs the action's one execution path under
|
|
4
|
-
* `surface: 'job'`, so a queued run
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* `surface: 'job'`, so a queued run takes the same validation and policy path as the HTTP call —
|
|
5
|
+
* under the worker's system authority: the enqueuer is `enqueuedBy`, attribution only, and never
|
|
6
|
+
* authorises the run. An action that acts for a user takes that user's id and re-authorises it.
|
|
7
|
+
* `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any `Action` and
|
|
8
|
+
* composes `job()` around this shape — and an app may write the same three lines.
|
|
7
9
|
*/
|
|
8
10
|
import type { Ctx } from '@ultimat3/core';
|
|
9
11
|
import { fingerprint } from '@ultimat3/core';
|
package/src/json-schema.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Standard Schema -> JSON Schema, normalized to a plain record
|
|
3
|
-
*
|
|
2
|
+
* Standard Schema -> JSON Schema, normalized to a plain record, for OpenAPI and the manifest. An
|
|
3
|
+
* MCP tool's schema is `@ultimat3/schema`'s `toWireSchema` — the one `tools/list` serves.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { isJsonObject } from '@ultimat3/core';
|
|
6
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
-
import { SchemaUnsupportedError, toJsonSchema
|
|
8
|
-
import {
|
|
8
|
+
import { SchemaUnsupportedError, toJsonSchema } from '@ultimat3/schema';
|
|
9
|
+
import { stableStringify } from './stable';
|
|
9
10
|
|
|
10
11
|
export type JsonSchemaObject = Record<string, unknown>;
|
|
11
12
|
|
|
@@ -23,13 +24,8 @@ export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
|
|
|
23
24
|
return normalizeJsonSchema(() => toJsonSchema(schema));
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
|
|
27
|
-
export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
|
|
28
|
-
return normalizeJsonSchema(() => toMcpInputSchema(schema));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
27
|
/**
|
|
32
|
-
* The narrowing
|
|
28
|
+
* The narrowing `jsonSchemaOf` applies, and the refusal it earns: a converter that answered with
|
|
33
29
|
* something that is not a JSON object is the same failure by a quieter route, so it gets the same
|
|
34
30
|
* shipped code rather than a permissive node. Exported for its own test and nothing else — it is
|
|
35
31
|
* absent from `src/index.ts`, exactly as `sortSchema` is.
|
package/src/mutator.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* contract tests for free, and its authz is the same single evaluation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
|
|
8
|
+
import type { ConflictPolicy, Ctx, Deprecation, Row } from '@ultimat3/core';
|
|
9
9
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
10
|
import type {
|
|
11
11
|
Action,
|
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
ActionRowArgs,
|
|
18
18
|
} from './action';
|
|
19
19
|
import { action, isAction } from './action';
|
|
20
|
-
import
|
|
20
|
+
import { MutatorNotIdempotentError } from './errors';
|
|
21
21
|
import type { ActionHttp } from './http-path';
|
|
22
22
|
import { assertConflictClock } from './mutator-clock';
|
|
23
23
|
import type { ActionPolicy } from './policy-gate';
|
|
@@ -97,7 +97,14 @@ export interface MutatorDef<
|
|
|
97
97
|
readonly deprecated?: Deprecation;
|
|
98
98
|
/** Same key, same meaning as an action's: `http: { path }` pins the URL. */
|
|
99
99
|
readonly http?: ActionHttp;
|
|
100
|
-
|
|
100
|
+
/**
|
|
101
|
+
* REQUIRED, and only `true`. A mutator's write is replayed under one `Idempotency-Key` —
|
|
102
|
+
* `useMutation`'s retry, the offline queue's drain after a dropped response — and the server
|
|
103
|
+
* reads that key only for an idempotent action. Declared rather than defaulted because the
|
|
104
|
+
* replay is answered from the idempotency store, and which store that is (`configureIdempotency`)
|
|
105
|
+
* is the app's decision to see. Omitted: a compile error, and `X_MUTATOR_NOT_IDEMPOTENT`.
|
|
106
|
+
*/
|
|
107
|
+
readonly idempotent: true;
|
|
101
108
|
/**
|
|
102
109
|
* The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
|
|
103
110
|
* the action until 2026-09-23, so such a policy received `row === null` and denied every call.
|
|
@@ -158,6 +165,9 @@ export function mutator<
|
|
|
158
165
|
TOutput extends StandardSchemaV1,
|
|
159
166
|
TRow = unknown,
|
|
160
167
|
>(def: MutatorDef<TInput, TOutput, TRow>): Mutator<TInput, TOutput> {
|
|
168
|
+
// First, and for a caller the compiler never saw: a replay that runs the server half twice is
|
|
169
|
+
// silent, so the declaration is refused where it is written rather than on the second POST.
|
|
170
|
+
if (def.idempotent !== true) throw new MutatorNotIdempotentError();
|
|
161
171
|
const row = def.row;
|
|
162
172
|
const actionDef: ActionDef<TInput, TOutput, TRow> = {
|
|
163
173
|
input: def.input,
|
|
@@ -169,7 +179,7 @@ export function mutator<
|
|
|
169
179
|
...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
|
|
170
180
|
...(def.http === undefined ? {} : { http: def.http }),
|
|
171
181
|
...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
|
|
172
|
-
|
|
182
|
+
idempotent: true,
|
|
173
183
|
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
174
184
|
handle: ({ input, ctx }) => def.server(ctx, input),
|
|
175
185
|
};
|
package/src/naming.ts
CHANGED
|
@@ -11,10 +11,9 @@ import { resolveActionRoute } from './http-path';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
|
|
14
|
-
* spec derive one URL from one function
|
|
15
|
-
*
|
|
14
|
+
* spec derive one URL from one function; `pluralize` and `splitWords` are imported from core by
|
|
15
|
+
* whoever needs them, never re-exported here (`X_HELPER_COPY`).
|
|
16
16
|
*/
|
|
17
|
-
export { pluralize, splitWords } from '@ultimat3/core';
|
|
18
17
|
|
|
19
18
|
export type ActionPath = ActionRoute;
|
|
20
19
|
|