@ultimat3/action 29.4.0 → 30.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 +68 -101
- package/README.md +151 -9
- package/package.json +19 -8
- package/src/action-stream.ts +116 -0
- package/src/action.ts +116 -13
- package/src/audit-subject-target.ts +81 -0
- package/src/client.ts +102 -12
- package/src/contract-test.ts +40 -16
- package/src/declared-errors-contract.ts +38 -0
- package/src/declared-errors.ts +13 -0
- package/src/error-titles.ts +18 -2
- package/src/errors-http.ts +19 -2
- package/src/errors-idempotency.ts +35 -0
- package/src/errors-meter.ts +48 -0
- package/src/errors-stream.ts +18 -0
- package/src/errors.ts +20 -12
- package/src/exercise-errors.ts +74 -0
- package/src/exercise.ts +153 -0
- package/src/facade.ts +1 -1
- package/src/http-path.ts +102 -20
- package/src/http-stream.ts +99 -0
- package/src/http.ts +106 -38
- package/src/idempotency.ts +108 -3
- package/src/index.ts +74 -3
- package/src/invoke.ts +103 -27
- package/src/json-schema.ts +1 -1
- package/src/meter-gate.ts +103 -0
- package/src/meter-memory.ts +124 -0
- package/src/meter-period.ts +34 -0
- package/src/meter-postgres.ts +132 -0
- package/src/meter-store.ts +61 -0
- package/src/meter.ts +124 -0
- package/src/mutator.ts +7 -0
- package/src/openapi-complete.ts +40 -11
- package/src/openapi-errors.ts +18 -0
- package/src/openapi.ts +39 -4
- package/src/rate-limit-gate.ts +43 -3
- package/src/registry.ts +39 -18
- package/src/tool-gate.ts +97 -0
- package/src/tx-scope.ts +71 -3
- package/src/validate.ts +1 -1
- package/src/wire-issues.ts +4 -1
- package/src/sample-input.ts +0 -226
package/src/idempotency.ts
CHANGED
|
@@ -4,15 +4,29 @@
|
|
|
4
4
|
* a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
|
|
5
5
|
* double charge is worse than a 409.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
compareFingerprint,
|
|
9
|
+
finiteCount,
|
|
10
|
+
isUltimateError,
|
|
11
|
+
jsonbRefusal,
|
|
12
|
+
keyedFingerprint,
|
|
13
|
+
logger,
|
|
14
|
+
MAX_TIMER_MS,
|
|
15
|
+
runToCompletion,
|
|
16
|
+
tryUseContext,
|
|
17
|
+
} from '@ultimat3/core';
|
|
8
18
|
import {
|
|
9
19
|
IdempotencyConflictError,
|
|
10
20
|
IdempotencyNotSharedError,
|
|
21
|
+
IdempotencyOutputUnserializableError,
|
|
11
22
|
IdempotencyReplayedFailureError,
|
|
23
|
+
IdempotencyRunCeilingError,
|
|
12
24
|
IdempotentReplayRedactedError,
|
|
13
25
|
} from './errors';
|
|
14
26
|
import { memoryIdempotencyStore } from './idempotency-memory';
|
|
15
27
|
import { restingAnswer } from './idempotency-redact';
|
|
28
|
+
import { requestDeadlineMs } from './request-deadline';
|
|
29
|
+
import { carrySettleOnCommit } from './tx-scope';
|
|
16
30
|
|
|
17
31
|
/**
|
|
18
32
|
* Where a store's records live. Declared by the driver, never inferred — the same rule
|
|
@@ -153,6 +167,8 @@ export interface IdempotencyStore {
|
|
|
153
167
|
*/
|
|
154
168
|
export interface IdempotencyConfig {
|
|
155
169
|
readonly scope: IdempotencyScope;
|
|
170
|
+
/** How long a keyed run may go on, in ms. Default `idempotencyCeilingMs()`. */
|
|
171
|
+
readonly ceilingMs?: number | undefined;
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
/**
|
|
@@ -166,6 +182,10 @@ let defaultStore: IdempotencyStore = memoryIdempotencyStore();
|
|
|
166
182
|
|
|
167
183
|
/** Declared at boot, before `registerActions()`. Nothing infers a replica count. */
|
|
168
184
|
export function configureIdempotency(next: IdempotencyConfig): void {
|
|
185
|
+
// At boot: unscreened, a bad value is refused by every keyed request instead.
|
|
186
|
+
if (next.ceilingMs !== undefined) {
|
|
187
|
+
finiteCount('configureIdempotency', 'ceilingMs', next.ceilingMs, 1);
|
|
188
|
+
}
|
|
169
189
|
config = next;
|
|
170
190
|
}
|
|
171
191
|
|
|
@@ -173,6 +193,21 @@ export function idempotencyConfig(): IdempotencyConfig {
|
|
|
173
193
|
return config;
|
|
174
194
|
}
|
|
175
195
|
|
|
196
|
+
/** The ceiling when the request deadline is `0`, "no deadline": there is nothing to double. */
|
|
197
|
+
const CEILING_WITHOUT_DEADLINE_MS = 60_000;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The hard ceiling on a keyed run: the declared one, else twice the request deadline (60 s by
|
|
201
|
+
* default). A handler the deadline's own socket answer has given up on gets as long again to finish;
|
|
202
|
+
* read per call because the app declares its deadline after the boot built its stores.
|
|
203
|
+
*/
|
|
204
|
+
export function idempotencyCeilingMs(): number {
|
|
205
|
+
if (config.ceilingMs !== undefined) return config.ceilingMs;
|
|
206
|
+
const deadline = requestDeadlineMs();
|
|
207
|
+
// `ceil`: http's screen admits a fraction, and this one counts whole milliseconds.
|
|
208
|
+
return deadline > 0 ? Math.ceil(2 * deadline) : CEILING_WITHOUT_DEADLINE_MS;
|
|
209
|
+
}
|
|
210
|
+
|
|
176
211
|
export function setIdempotencyStore(store: IdempotencyStore): void {
|
|
177
212
|
defaultStore = store;
|
|
178
213
|
}
|
|
@@ -216,7 +251,7 @@ export interface IdempotentOutcome<T> {
|
|
|
216
251
|
* X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a duplicate still in
|
|
217
252
|
* flight.
|
|
218
253
|
*
|
|
219
|
-
* **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `
|
|
254
|
+
* **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `parseActionInput`
|
|
220
255
|
* both run before this gate is reached (`invoke.ts`), so by the time `run` is called the only
|
|
221
256
|
* things left are the handler and `validateOutput` — and the second of those throws *after* the
|
|
222
257
|
* first has committed. Releasing the reservation there is what turned a rounding change in an
|
|
@@ -232,6 +267,8 @@ export interface WithIdempotencyOptions {
|
|
|
232
267
|
* the key's outcome, and a retry once the bucket refills must still be able to run.
|
|
233
268
|
*/
|
|
234
269
|
readonly beforeRun?: () => Promise<void>;
|
|
270
|
+
/** This call's ceiling, in ms, over `idempotencyCeilingMs()`. Past it the failure is settled. */
|
|
271
|
+
readonly ceilingMs?: number | undefined;
|
|
235
272
|
}
|
|
236
273
|
|
|
237
274
|
export async function withIdempotency<T>(
|
|
@@ -241,6 +278,12 @@ export async function withIdempotency<T>(
|
|
|
241
278
|
run: () => Promise<T>,
|
|
242
279
|
options: WithIdempotencyOptions = {},
|
|
243
280
|
): Promise<IdempotentOutcome<T>> {
|
|
281
|
+
const ceilingMs = finiteCount(
|
|
282
|
+
'withIdempotency',
|
|
283
|
+
'ceilingMs',
|
|
284
|
+
options.ceilingMs ?? idempotencyCeilingMs(),
|
|
285
|
+
1,
|
|
286
|
+
);
|
|
244
287
|
const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
|
|
245
288
|
const { record, created } = await store.reserve(key, requestHash);
|
|
246
289
|
if (record.requestHash !== requestHash) assertSamePayload(key, record.requestHash, input);
|
|
@@ -269,9 +312,15 @@ export async function withIdempotency<T>(
|
|
|
269
312
|
throw error;
|
|
270
313
|
}
|
|
271
314
|
}
|
|
315
|
+
// A run under a key is OWED its end: the orphan finishing is what makes the replay answer success.
|
|
316
|
+
// An aborted `ctx.signal` would fail its vendor call, `settleFailure` would run, and the key would
|
|
317
|
+
// replay a failure. The COMMIT of a transaction around this gate sits outside the scope below, so
|
|
318
|
+
// that root is marked too, and the settle rides it. A reclaimed reservation still fences its own.
|
|
319
|
+
// The escape removes every bound the request had, so the ceiling below is the one it has.
|
|
320
|
+
carrySettleOnCommit();
|
|
272
321
|
let value: T;
|
|
273
322
|
try {
|
|
274
|
-
value = await run
|
|
323
|
+
value = await runWithinCeiling(key, ceilingMs, run);
|
|
275
324
|
} catch (error) {
|
|
276
325
|
await settleFailure(store, key, record.id, error);
|
|
277
326
|
throw error;
|
|
@@ -290,10 +339,66 @@ export async function withIdempotency<T>(
|
|
|
290
339
|
// The CALLER gets `value` whole; the store gets the resting copy, so an answer carrying a
|
|
291
340
|
// credential is never written down in plaintext for the window (#591).
|
|
292
341
|
const resting = restingAnswer(value);
|
|
342
|
+
if (!jsonRepresentable(resting.value)) {
|
|
343
|
+
const refusal = new IdempotencyOutputUnserializableError(key);
|
|
344
|
+
await settleFailure(store, key, record.id, refusal);
|
|
345
|
+
throw refusal;
|
|
346
|
+
}
|
|
293
347
|
await store.settle(key, resting.value, record.id, resting.redacted);
|
|
294
348
|
return { value, replayed: false };
|
|
295
349
|
}
|
|
296
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Whether a store can write this down. Tested HERE so both stores answer alike: memory holds any
|
|
353
|
+
* value, and Postgres throws from its own `JSON.stringify`. A resting copy has no `toJSON` left to
|
|
354
|
+
* run, so this calls no app code. `undefined` passes and rests as `null` (JSON has no `undefined`):
|
|
355
|
+
* the replay goes through the action's `output` schema, and that schema answers for its shape.
|
|
356
|
+
* Text `jsonb` refuses counts too: Postgres alone would raise it, after the handler ran.
|
|
357
|
+
*/
|
|
358
|
+
function jsonRepresentable(value: unknown): boolean {
|
|
359
|
+
try {
|
|
360
|
+
const text = JSON.stringify(value);
|
|
361
|
+
return text === undefined || jsonbRefusal(text) === undefined;
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* `run` in `runToCompletion`'s scope, under a signal this ceiling aborts. ABORTS before it rejects,
|
|
369
|
+
* as a job's step timeout does: the failure about to be settled is what a retry replays, so a handler
|
|
370
|
+
* still going must be told. One that ignores it is let go of: the in-flight count and the drain stop
|
|
371
|
+
* waiting, and a transaction around the gate rolls back on this throw, so its statements then refuse.
|
|
372
|
+
*/
|
|
373
|
+
function runWithinCeiling<T>(key: string, ceilingMs: number, run: () => Promise<T>): Promise<T> {
|
|
374
|
+
const ceiling = new AbortController();
|
|
375
|
+
const startedAt = tryUseContext()?.now().getTime();
|
|
376
|
+
const bound = {
|
|
377
|
+
signal: ceiling.signal,
|
|
378
|
+
...(startedAt === undefined ? {} : { deadlineAt: startedAt + ceilingMs }),
|
|
379
|
+
};
|
|
380
|
+
// Past what a timer holds `setTimeout` arms ~1 ms, and the longest ceiling killed every handler.
|
|
381
|
+
const delayMs = Math.min(ceilingMs, MAX_TIMER_MS);
|
|
382
|
+
return new Promise<T>((resolve, reject) => {
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
const failure = new IdempotencyRunCeilingError(key, ceilingMs);
|
|
385
|
+
ceiling.abort(failure);
|
|
386
|
+
reject(failure);
|
|
387
|
+
}, delayMs);
|
|
388
|
+
// `async`: a `run` that throws synchronously takes the same path, and clears the timer.
|
|
389
|
+
(async () => runToCompletion(run, bound))().then(
|
|
390
|
+
(value) => {
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
resolve(value);
|
|
393
|
+
},
|
|
394
|
+
(cause: unknown) => {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
reject(cause);
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
297
402
|
/**
|
|
298
403
|
* A stored fingerprint that is not this request's exact string. A legacy unkeyed one (a row
|
|
299
404
|
* written before keying) is still checked exactly. One keyed under a secret this process does not
|
package/src/index.ts
CHANGED
|
@@ -31,12 +31,16 @@ export type {
|
|
|
31
31
|
ActionHandlerArgs,
|
|
32
32
|
ActionMcp,
|
|
33
33
|
ActionRateLimit,
|
|
34
|
+
ActionRateLimitFact,
|
|
34
35
|
ActionRowArgs,
|
|
35
36
|
AnyAction,
|
|
36
37
|
InvokeOptions,
|
|
37
38
|
McpDescriptorMeta,
|
|
39
|
+
StreamAction,
|
|
40
|
+
StreamActionDef,
|
|
38
41
|
} from './action';
|
|
39
42
|
export { action, describeAction, isAction } from './action';
|
|
43
|
+
export { chunksRead } from './action-stream';
|
|
40
44
|
/**
|
|
41
45
|
* The app's API as a whole — `defineApi({ http: { pathStyle, mounts }, openapi })` — read back by
|
|
42
46
|
* the boot (bearer mounts) and `x manifest` (the complete and per-mount documents).
|
|
@@ -67,6 +71,12 @@ export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
|
|
|
67
71
|
*/
|
|
68
72
|
export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
|
|
69
73
|
export { postgresAuditSink, SQL_AUDIT_TABLE } from './audit-postgres';
|
|
74
|
+
export type {
|
|
75
|
+
AuditSubjectRef,
|
|
76
|
+
AuditSubjectTarget,
|
|
77
|
+
AuditSubjectTargetOptions,
|
|
78
|
+
} from './audit-subject-target';
|
|
79
|
+
export { AUDIT_SUBJECT_COLUMNS, auditSubjectTarget } from './audit-subject-target';
|
|
70
80
|
export type {
|
|
71
81
|
ActionLike,
|
|
72
82
|
ActionMap,
|
|
@@ -75,6 +85,7 @@ export type {
|
|
|
75
85
|
ClientMethod,
|
|
76
86
|
ClientOptions,
|
|
77
87
|
FetchLike,
|
|
88
|
+
StreamClientMethod,
|
|
78
89
|
} from './client';
|
|
79
90
|
export { rpc } from './client';
|
|
80
91
|
export type { ContractTest, ContractTestOptions } from './contract-test';
|
|
@@ -96,8 +107,10 @@ export {
|
|
|
96
107
|
IdempotencyConflictError,
|
|
97
108
|
IdempotencyKeyInvalidError,
|
|
98
109
|
IdempotencyNotSharedError,
|
|
110
|
+
IdempotencyOutputUnserializableError,
|
|
99
111
|
IdempotencyReplayedFailureError,
|
|
100
112
|
IdempotencyReservationLostError,
|
|
113
|
+
IdempotencyRunCeilingError,
|
|
101
114
|
IdempotencyStatusUnknownError,
|
|
102
115
|
IdempotentReplayRedactedError,
|
|
103
116
|
InputInvalidError,
|
|
@@ -107,21 +120,37 @@ export {
|
|
|
107
120
|
RpcFailedError,
|
|
108
121
|
} from './errors';
|
|
109
122
|
export {
|
|
123
|
+
ActionDeprecationSuccessorInvalidError,
|
|
110
124
|
ActionHttpPathInvalidError,
|
|
111
125
|
ActionPathStyleInvalidError,
|
|
112
126
|
OpenApiConfigInvalidError,
|
|
113
127
|
} from './errors-http';
|
|
128
|
+
export type { MeterExceededInput, MeterField } from './errors-meter';
|
|
129
|
+
export { MeterExceededError, MeterInvalidError } from './errors-meter';
|
|
130
|
+
export { ActionStreamInvalidError } from './errors-stream';
|
|
131
|
+
export type {
|
|
132
|
+
ExerciseActorOptions,
|
|
133
|
+
ExerciseOffOptions,
|
|
134
|
+
ExerciseOptions,
|
|
135
|
+
ExerciseReport,
|
|
136
|
+
} from './exercise';
|
|
137
|
+
export { exerciseAction } from './exercise';
|
|
114
138
|
export type { OpenApiOperation } from './http';
|
|
115
139
|
export {
|
|
116
140
|
operationTagOf,
|
|
117
|
-
REPLAYED_HEADER,
|
|
118
141
|
toOpenApiOperation,
|
|
119
142
|
toPostBinding,
|
|
120
143
|
toRoute,
|
|
121
144
|
} from './http';
|
|
122
145
|
/** `http: { path }` on an action pins its URL; the app's `pathStyle` derives every other one. */
|
|
123
|
-
export type { ActionHttp } from './http-path';
|
|
124
|
-
export {
|
|
146
|
+
export type { ActionHttp, ActionHttpMethod } from './http-path';
|
|
147
|
+
export {
|
|
148
|
+
ACTION_HTTP_METHODS,
|
|
149
|
+
actionHttpPinOf,
|
|
150
|
+
actionPathStyle,
|
|
151
|
+
forgetHandedOutActionPaths,
|
|
152
|
+
} from './http-path';
|
|
153
|
+
export { EVENT_STREAM } from './http-stream';
|
|
125
154
|
/**
|
|
126
155
|
* The idempotency seam. `withIdempotency` and `IDEMPOTENCY_HEADER` are both public, so a plain
|
|
127
156
|
* mutating `route` can reserve-and-replay exactly as an action does — `idempotencyKeyFor` is the
|
|
@@ -144,6 +173,7 @@ export {
|
|
|
144
173
|
DEFAULT_IDEMPOTENCY_CONFIG,
|
|
145
174
|
getIdempotencyStore,
|
|
146
175
|
IDEMPOTENCY_STATUSES,
|
|
176
|
+
idempotencyCeilingMs,
|
|
147
177
|
idempotencyConfig,
|
|
148
178
|
isIdempotencyStatus,
|
|
149
179
|
resetIdempotency,
|
|
@@ -172,6 +202,27 @@ export type { ActionJobHandle } from './job-handle';
|
|
|
172
202
|
export { toJobHandle } from './job-handle';
|
|
173
203
|
export type { JsonSchemaObject } from './json-schema';
|
|
174
204
|
export { jsonSchemaOf } from './json-schema';
|
|
205
|
+
/**
|
|
206
|
+
* The meter seam: `meter: { name, period, limit, warnAt, onWarn }` on an action, drawn inside
|
|
207
|
+
* `invoke` on every surface. The draw itself (`meter-gate.ts`) stays unexported — one caller.
|
|
208
|
+
* `MeterWarning` is the schema an app's warning `job()` declares as its input.
|
|
209
|
+
*/
|
|
210
|
+
export type {
|
|
211
|
+
ActionMeter,
|
|
212
|
+
ActionMeterFact,
|
|
213
|
+
MeterLimitArgs,
|
|
214
|
+
MeterPeriod,
|
|
215
|
+
MeterWarnEnqueueOptions,
|
|
216
|
+
MeterWarnTarget,
|
|
217
|
+
ResolvedLimit,
|
|
218
|
+
} from './meter';
|
|
219
|
+
export { METER_PERIODS, MeterWarning } from './meter';
|
|
220
|
+
export type { MemoryMeterStoreOptions } from './meter-memory';
|
|
221
|
+
export { DEFAULT_MAX_METER_KEYS, memoryMeterStore } from './meter-memory';
|
|
222
|
+
export type { PostgresMeterStoreOptions } from './meter-postgres';
|
|
223
|
+
export { postgresMeterStore, SQL_METER_TABLE } from './meter-postgres';
|
|
224
|
+
export type { MeterDraw, MeterDrawResult, MeterKey, MeterStore } from './meter-store';
|
|
225
|
+
export { getMeterStore, resetMeterStore, setMeterStore } from './meter-store';
|
|
175
226
|
export type {
|
|
176
227
|
LocalTable,
|
|
177
228
|
LocalTableName,
|
|
@@ -216,6 +267,20 @@ export {
|
|
|
216
267
|
resetActions,
|
|
217
268
|
} from './registry';
|
|
218
269
|
export { requestDeadlineMs } from './request-deadline';
|
|
270
|
+
/**
|
|
271
|
+
* What a named tool passes on its way to `invoke`, on either tool surface. `@ultimat3/mcp`'s
|
|
272
|
+
* `mcpConfirmations` registers one and `@ultimat3/ai`'s adapter reads it: one tier down from both.
|
|
273
|
+
*/
|
|
274
|
+
export type { ToolGate, ToolGateCall } from './tool-gate';
|
|
275
|
+
export {
|
|
276
|
+
admitActionCaller,
|
|
277
|
+
asCallerContext,
|
|
278
|
+
gatedToolNames,
|
|
279
|
+
knownToolNames,
|
|
280
|
+
noteToolNames,
|
|
281
|
+
registerToolGate,
|
|
282
|
+
toolGateFor,
|
|
283
|
+
} from './tool-gate';
|
|
219
284
|
/**
|
|
220
285
|
* A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
|
|
221
286
|
* a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
|
|
@@ -229,6 +294,12 @@ export type {
|
|
|
229
294
|
TransitionValues,
|
|
230
295
|
} from './transition';
|
|
231
296
|
export { transition } from './transition';
|
|
297
|
+
/**
|
|
298
|
+
* `invoke`'s input parse and its `X_INPUT_INVALID` refusal, alone. Exported so a caller that must
|
|
299
|
+
* hold something else to an action's input — an agent eval's tool double (`@ultimat3/ai`) — runs
|
|
300
|
+
* this one check instead of a copy that drifts.
|
|
301
|
+
*/
|
|
302
|
+
export { parseActionInput } from './validate';
|
|
232
303
|
/**
|
|
233
304
|
* The one reader of a problem document's `issues` member. Exported because the typed client is not
|
|
234
305
|
* the only caller that meets one: an island that posts with a plain `fetch` — which is what
|
package/src/invoke.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
3
|
+
* policy, draw the meter, 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
|
*
|
|
@@ -15,13 +15,19 @@ import {
|
|
|
15
15
|
actorOf,
|
|
16
16
|
anonymousActor,
|
|
17
17
|
ctxOf,
|
|
18
|
+
failureCaptureActive,
|
|
18
19
|
isUltimateError,
|
|
20
|
+
REDACTED,
|
|
21
|
+
recordFailureInput,
|
|
19
22
|
runWithContext,
|
|
20
23
|
tryUseContext,
|
|
21
24
|
useContext,
|
|
22
25
|
withChildContext,
|
|
26
|
+
withFailureCapture,
|
|
23
27
|
withSpan,
|
|
24
28
|
} from '@ultimat3/core';
|
|
29
|
+
import { statusFor } from '@ultimat3/http';
|
|
30
|
+
import { maskSensitive } from '@ultimat3/schema';
|
|
25
31
|
import type { AnyAction, AnyActionDef, InvokeOptions } from './action';
|
|
26
32
|
import type { AuditRecord } from './audit';
|
|
27
33
|
import {
|
|
@@ -32,12 +38,14 @@ import {
|
|
|
32
38
|
auditThrew,
|
|
33
39
|
} from './audit-gate';
|
|
34
40
|
import { bustAfterCommit } from './cache-gate';
|
|
41
|
+
import { judgedHandler } from './declared-errors';
|
|
35
42
|
import { ActionForeignError, ActionUnregisteredError } from './errors';
|
|
36
43
|
import { getIdempotencyStore, withIdempotency } from './idempotency';
|
|
37
44
|
import { idempotencyKeyFor } from './idempotency-key';
|
|
45
|
+
import { drawMeter } from './meter-gate';
|
|
38
46
|
import { guardAction, guardActionBeforeInput } from './policy-gate';
|
|
39
47
|
import { spendActionLimit, withCallerAddress, withJobFrame } from './rate-limit-gate';
|
|
40
|
-
import {
|
|
48
|
+
import { parseActionInput, parsedOrNothing, validateOutput } from './validate';
|
|
41
49
|
|
|
42
50
|
/**
|
|
43
51
|
* Private on purpose. `@ultimat3/action` exports no way to read this back, which
|
|
@@ -63,6 +71,18 @@ export function defOf(target: AnyAction): AnyActionDef {
|
|
|
63
71
|
return def;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
/**
|
|
75
|
+
* How many times each declaration's `handle` has started, keyed like `DECLARATIONS`. Read only by
|
|
76
|
+
* `exercise.ts` — never re-exported — so "zero runs reached the handler" is countable without a
|
|
77
|
+
* second execution path: a contract that never got past policy or parse is its own red.
|
|
78
|
+
*/
|
|
79
|
+
const HANDLER_REACHED = new WeakMap<AnyActionDef, number>();
|
|
80
|
+
|
|
81
|
+
/** Starts of `target`'s handler so far, across every surface. Off the barrel on purpose. */
|
|
82
|
+
export function handlerReached(target: AnyAction): number {
|
|
83
|
+
return HANDLER_REACHED.get(defOf(target)) ?? 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
/** Projections need a stable name; an unregistered action has none yet. */
|
|
67
87
|
export function actionName(target: AnyAction): string {
|
|
68
88
|
if (target.name.length === 0) throw new ActionUnregisteredError();
|
|
@@ -190,6 +210,20 @@ async function execute(
|
|
|
190
210
|
ctx: Ctx,
|
|
191
211
|
options: InvokeOptions,
|
|
192
212
|
trace: InvokeTrace,
|
|
213
|
+
): Promise<unknown> {
|
|
214
|
+
// A 5xx keeps a replayable bundle when `failureCapture` is on (`x repro`); off, `run` runs bare.
|
|
215
|
+
return withFailureCapture('action', name, statusFor, () =>
|
|
216
|
+
traced(def, name, raw, ctx, options, trace),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function traced(
|
|
221
|
+
def: AnyActionDef,
|
|
222
|
+
name: string,
|
|
223
|
+
raw: unknown,
|
|
224
|
+
ctx: Ctx,
|
|
225
|
+
options: InvokeOptions,
|
|
226
|
+
trace: InvokeTrace,
|
|
193
227
|
): Promise<unknown> {
|
|
194
228
|
return withSpan(`action.${name}`, async (span) => {
|
|
195
229
|
span.setAttributes({
|
|
@@ -237,7 +271,9 @@ async function perform(
|
|
|
237
271
|
} catch (denial) {
|
|
238
272
|
// The audit record still names what was attempted — the denial is already decided, so parsing
|
|
239
273
|
// now tells the CALLER nothing, and an unparseable payload is a record with no input.
|
|
240
|
-
if (def.audit === true)
|
|
274
|
+
if (def.audit === true) {
|
|
275
|
+
trace.input = maskSensitive(def.input, await parsedOrNothing(def.input, raw), REDACTED);
|
|
276
|
+
}
|
|
241
277
|
throw denial;
|
|
242
278
|
}
|
|
243
279
|
// The ONE place a declared `rateLimit:` is spent, so every surface draws on the same bucket.
|
|
@@ -260,15 +296,21 @@ async function perform(
|
|
|
260
296
|
// committed write had not happened. A key the store already holds is a replay (or a duplicate in
|
|
261
297
|
// flight, answered 409); only a key it does not hold spends here. The peek can be stale both
|
|
262
298
|
// ways, so `withIdempotency` meters a reservation it CREATED that this peek did not pay for.
|
|
263
|
-
|
|
299
|
+
// The same peek serves the row rule below, so an action that needs neither makes no read here.
|
|
300
|
+
const peeked =
|
|
264
301
|
trace.idempotencyKey === null ||
|
|
265
302
|
store === undefined ||
|
|
266
|
-
def.rateLimit === undefined
|
|
267
|
-
|
|
303
|
+
(def.rateLimit === undefined && def.row === undefined)
|
|
304
|
+
? undefined
|
|
305
|
+
: await store.get(trace.idempotencyKey);
|
|
306
|
+
const spentEarly = def.rateLimit === undefined || peeked === undefined;
|
|
268
307
|
if (spentEarly) await spend();
|
|
269
308
|
|
|
270
|
-
const input = await
|
|
271
|
-
|
|
309
|
+
const input = await parseActionInput(def.input, raw, name);
|
|
310
|
+
if (failureCaptureActive()) recordFailureInput(maskSensitive(def.input, input, REDACTED));
|
|
311
|
+
// The audit record's copy, `.sensitive()` fields masked by THIS action's own schema — never a
|
|
312
|
+
// key table, so a `cardPan` flagged here changes nothing another action records or stores.
|
|
313
|
+
if (def.audit === true) trace.input = maskSensitive(def.input, input, REDACTED);
|
|
272
314
|
// The one place a row-level rule gets its row. Once per invocation, never per row:
|
|
273
315
|
// that asymmetry is what lets the predicate stay synchronous, so a live query can
|
|
274
316
|
// re-evaluate the same policy per subscriber without a query per change event. An
|
|
@@ -276,39 +318,73 @@ async function perform(
|
|
|
276
318
|
// because a rule that reads `row` has to decide what `null` means.
|
|
277
319
|
// The SAME `input` object reaches `def.row` here and `def.handle` below, by reference:
|
|
278
320
|
// `transition()` keys what its rule read on it (`transition-observe.ts`, #702).
|
|
279
|
-
const
|
|
280
|
-
|
|
321
|
+
const judgeRow = async (): Promise<void> => {
|
|
322
|
+
// `row` and `handle` are the two places `errors:` judges a throw (`declared-errors.ts`).
|
|
323
|
+
const load = def.row;
|
|
324
|
+
const row =
|
|
325
|
+
load === undefined
|
|
326
|
+
? null
|
|
327
|
+
: ((await judgedHandler(def, name, () => load.call(def, { input, ctx }))) ?? null);
|
|
328
|
+
guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
|
|
329
|
+
};
|
|
330
|
+
// A finished record is this key's outcome, and the key is this actor's (`idempotency-key.ts`).
|
|
331
|
+
// The row it decided about may be the row it deleted: a rule asked again judges `null`, and a
|
|
332
|
+
// committed write answers its own retry with a refusal. So the loader and the rule wait, and
|
|
333
|
+
// `withIdempotency` answers: the replay, or the conflict for another payload. Who is asking was
|
|
334
|
+
// already decided, per call, by `guardActionBeforeInput` above: a revoked `can()` is refused
|
|
335
|
+
// here too. In flight or absent, nothing has been decided yet, so the rule decides.
|
|
336
|
+
const answered = def.row !== undefined && peeked !== undefined && peeked.status !== 'in-flight';
|
|
337
|
+
if (!answered) await judgeRow();
|
|
281
338
|
|
|
282
339
|
// Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
|
|
283
340
|
// one shape on the wire, first call and every retry. No span of its own: `execute` above holds
|
|
284
341
|
// the one that covers this invocation, and a second here would only re-time its tail.
|
|
342
|
+
let started = false;
|
|
285
343
|
const run = async (): Promise<unknown> => {
|
|
286
|
-
|
|
344
|
+
started = true;
|
|
345
|
+
HANDLER_REACHED.set(def, (HANDLER_REACHED.get(def) ?? 0) + 1);
|
|
346
|
+
// Read here, not closed over: under a key this runs in `runToCompletion`'s scope, and a handler
|
|
347
|
+
// passing the outer `ctx.signal` onward would fail its vendor call and settle a failure.
|
|
348
|
+
const produced = await judgedHandler(def, name, () => def.handle({ input, ctx: useContext() }));
|
|
287
349
|
return validateOutput(def.output, produced, name);
|
|
288
350
|
};
|
|
351
|
+
// The meter is drawn LAST before the handler, on every surface `surface: 'server'` included: a
|
|
352
|
+
// call refused by the policy, the parse or the rate limit used none of the allowance. Under a key
|
|
353
|
+
// it is drawn only for a reservation this call CREATED (`beforeRun`), so a replay — of a success
|
|
354
|
+
// or of a settled failure — draws nothing, and a refusal releases the key for a retry next period.
|
|
355
|
+
const metered = (): Promise<void> =>
|
|
356
|
+
def.meter === undefined ? Promise.resolve() : drawMeter(name, def.meter, ctx);
|
|
357
|
+
// The peek can be stale: a record that lapsed after it leaves this call a FRESH reservation, and
|
|
358
|
+
// the handler must not run on a rule it skipped. `beforeRun` runs only for a created reservation,
|
|
359
|
+
// and a refusal there releases it. Spend first, in the order a first call takes them.
|
|
360
|
+
const beforeRun = async (): Promise<void> => {
|
|
361
|
+
if (!spentEarly) await spend();
|
|
362
|
+
if (answered) await judgeRow();
|
|
363
|
+
await metered();
|
|
364
|
+
};
|
|
289
365
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
366
|
+
try {
|
|
367
|
+
if (store === undefined || trace.idempotencyKey === null) {
|
|
368
|
+
await metered();
|
|
369
|
+
return await run();
|
|
370
|
+
}
|
|
295
371
|
const outcome = await withIdempotency(store, trace.idempotencyKey, input, run, {
|
|
296
|
-
...(spentEarly ? {} : { beforeRun
|
|
372
|
+
...(spentEarly && !answered && def.meter === undefined ? {} : { beforeRun }),
|
|
297
373
|
});
|
|
298
374
|
if (outcome.replayed) options.onReplay?.();
|
|
299
375
|
trace.replayed = outcome.replayed;
|
|
300
|
-
wrote = !outcome.replayed;
|
|
301
376
|
// A replay is parsed like a first call. The memory store hands back the live value and the
|
|
302
377
|
// postgres store `JSON.parse(JSON.stringify(v))` — a `Date` on the first call and a `string`
|
|
303
378
|
// on every retry served from Postgres — so the output schema is the one answer to its shape.
|
|
304
|
-
|
|
379
|
+
return outcome.replayed ? validateOutput(def.output, outcome.value, name) : outcome.value;
|
|
380
|
+
} finally {
|
|
381
|
+
// Only for a handler that actually started, and only through the gate. A replay ran no handler
|
|
382
|
+
// and changed nothing the first call had not already busted — re-busting per retry re-purges
|
|
383
|
+
// the CDN and re-queues ISR for a write nobody made. A handler that THREW is busted too: it may
|
|
384
|
+
// have committed (`withIdempotency` records the same throw for that reason), and skipping the
|
|
385
|
+
// bust hid that write behind a stale cache for the TTL. The bust is post-COMMIT either way —
|
|
386
|
+
// invoked inside a transaction, it waits for the root commit and is dropped by a rollback — and
|
|
387
|
+
// `bustAfterCommit` never throws, so it cannot replace this call's answer or its failure.
|
|
388
|
+
if (started && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
|
|
305
389
|
}
|
|
306
|
-
// Only for a run that actually happened, and only through the gate. A replay ran no handler
|
|
307
|
-
// and changed nothing the first call had not already busted — re-busting per retry re-purges
|
|
308
|
-
// the CDN and re-queues ISR for a write nobody made. And the bust is post-COMMIT either way —
|
|
309
|
-
// invoked inside a transaction, it waits for the root commit and is dropped by a rollback — so
|
|
310
|
-
// `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
|
|
311
|
-
// durable write into a failed action.
|
|
312
|
-
if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
|
|
313
|
-
return value;
|
|
314
390
|
}
|
package/src/json-schema.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type JsonSchemaObject = Record<string, unknown>;
|
|
|
15
15
|
* `{ type: 'object', additionalProperties: true }` so that "a missing OpenAPI detail must not
|
|
16
16
|
* break a deploy" — which inverts axiom 3: the deploy succeeded and every caller was lied to.
|
|
17
17
|
* `toJsonSchema` throws exactly when the spec must not claim anything, and the same schema
|
|
18
|
-
* still fails `
|
|
18
|
+
* still fails `parseActionInput` on every payload, so the OpenAPI component and the MCP
|
|
19
19
|
* `inputSchema` were advertising "any object accepted" for an endpoint that accepts none.
|
|
20
20
|
* `registerAction` calls this at boot (`assertProjectable`), so a registered action can never
|
|
21
21
|
* reach a projection that throws.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a declared `meter:` is drawn: once per call, inside `invoke`, on EVERY surface — HTTP, MCP,
|
|
3
|
+
* a job, an in-process call — after the policy admitted it and before the handler starts. A
|
|
4
|
+
* refusal is `X_METER_EXCEEDED`; a crossed `warnAt` enqueues the app's `onWarn` once per period.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { isAnonymous, logger } from '@ultimat3/core';
|
|
9
|
+
import { MeterExceededError, MeterInvalidError } from './errors-meter';
|
|
10
|
+
import type { ActionMeter, MeterWarning, ResolvedLimit } from './meter';
|
|
11
|
+
import { isMeterLimit } from './meter';
|
|
12
|
+
import { meterPeriodKey, meterWindow } from './meter-period';
|
|
13
|
+
import type { MeterKey, MeterStore } from './meter-store';
|
|
14
|
+
import { getMeterStore } from './meter-store';
|
|
15
|
+
import { inJobFrame, UNATTRIBUTED_JOB } from './rate-limit-gate';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whose counter: the tenant, else the signed-in actor, else ONE shared counter per population —
|
|
19
|
+
* refused together rather than unlimited, the rate limiter's rule for a caller with nothing to key
|
|
20
|
+
* on. A job run carries its org on the worker's anonymous actor (`jobRunActor`), so it counts
|
|
21
|
+
* there; a tenantless one counts as `job:unattributed`, never on the anonymous visitors' counter.
|
|
22
|
+
*/
|
|
23
|
+
export function meterSubject(ctx: Ctx): string {
|
|
24
|
+
const orgId = ctx.actor.orgId;
|
|
25
|
+
if (typeof orgId === 'string' && orgId !== '') return `org:${orgId}`;
|
|
26
|
+
if (!isAnonymous(ctx.actor)) return `actor:${ctx.actor.id}`;
|
|
27
|
+
return inJobFrame() ? UNATTRIBUTED_JOB : 'anonymous';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A call-time limit, checked: `NaN`, a fraction or a negative is the app's bug, never allow-all. */
|
|
31
|
+
export async function resolveMeterLimit(
|
|
32
|
+
meter: string,
|
|
33
|
+
limit: ResolvedLimit,
|
|
34
|
+
ctx: Ctx,
|
|
35
|
+
): Promise<number> {
|
|
36
|
+
const resolved: unknown = typeof limit === 'function' ? await limit({ ctx }) : limit;
|
|
37
|
+
if (!isMeterLimit(resolved)) {
|
|
38
|
+
throw new MeterInvalidError(meter, 'limit', 'a whole number of calls, 0 or more', resolved);
|
|
39
|
+
}
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Draw one call from `meter` for this caller, or throw `X_METER_EXCEEDED`. */
|
|
44
|
+
export async function drawMeter(
|
|
45
|
+
action: string,
|
|
46
|
+
meter: ActionMeter,
|
|
47
|
+
ctx: Ctx,
|
|
48
|
+
store: MeterStore = getMeterStore(),
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const limit = await resolveMeterLimit(meter.name, meter.limit, ctx);
|
|
51
|
+
const nowMs = ctx.now().getTime();
|
|
52
|
+
const window = meterWindow(meter.period, nowMs);
|
|
53
|
+
const key: MeterKey = {
|
|
54
|
+
subject: meterSubject(ctx),
|
|
55
|
+
meter: meter.name,
|
|
56
|
+
period: meterPeriodKey(meter.period),
|
|
57
|
+
};
|
|
58
|
+
const drawn = await store.draw({ ...key, periodStartMs: window.startMs, limit });
|
|
59
|
+
if (!drawn.allowed) {
|
|
60
|
+
throw new MeterExceededError({
|
|
61
|
+
action,
|
|
62
|
+
meter: meter.name,
|
|
63
|
+
period: meter.period,
|
|
64
|
+
limit,
|
|
65
|
+
resetsAtMs: window.endMs,
|
|
66
|
+
nowMs,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// After the increment landed, never before: a warning for a draw that was refused is a warning
|
|
70
|
+
// about usage that never happened.
|
|
71
|
+
if (meter.warnAt === undefined || meter.onWarn === undefined || drawn.warned) return;
|
|
72
|
+
if (drawn.used < Math.ceil(meter.warnAt * limit)) return;
|
|
73
|
+
if (!(await store.claimWarning(key, window.startMs))) return;
|
|
74
|
+
const warning: MeterWarning = {
|
|
75
|
+
action,
|
|
76
|
+
meter: meter.name,
|
|
77
|
+
orgId: ctx.actor.orgId ?? null,
|
|
78
|
+
actorId: isAnonymous(ctx.actor) ? null : ctx.actor.id,
|
|
79
|
+
used: drawn.used,
|
|
80
|
+
limit,
|
|
81
|
+
periodStart: new Date(window.startMs).toISOString(),
|
|
82
|
+
resetsAt: new Date(window.endMs).toISOString(),
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
// Outside the caller's transaction: the claim above is already durable, and a warning staged
|
|
86
|
+
// in a transaction that rolls back would be a claim nothing ever delivers.
|
|
87
|
+
await meter.onWarn.enqueue(warning, {
|
|
88
|
+
outbox: false,
|
|
89
|
+
...(warning.orgId === null ? {} : { tenantId: warning.orgId }),
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// The call is owed its answer: its draw counted and the handler is about to run, so a warning
|
|
93
|
+
// that could not be queued never fails it. The claim goes back, so the next draw retries.
|
|
94
|
+
logger.error('action.meter.warn-failed', { action, meter: meter.name, error });
|
|
95
|
+
await store.releaseWarning(key, window.startMs).catch((releaseError: unknown) => {
|
|
96
|
+
logger.error('action.meter.warn-release-failed', {
|
|
97
|
+
action,
|
|
98
|
+
meter: meter.name,
|
|
99
|
+
error: releaseError,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|