@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/contract-test.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projection 6: the tests. `x g action` emits these
|
|
3
|
-
* new action, so an action that skips validation, skips authz,
|
|
4
|
-
* the spec fails CI on the day it is written.
|
|
2
|
+
* Projection 6: the tests. `x g action` emits these four assertions for every
|
|
3
|
+
* new action, so an action that skips validation, skips authz, never reaches
|
|
4
|
+
* the spec, or crashes on a legal input fails CI on the day it is written.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
ctxOf,
|
|
10
|
+
isJsonObject,
|
|
11
|
+
isUltimateError,
|
|
12
|
+
logger,
|
|
13
|
+
renderFixShellArg,
|
|
14
|
+
toOpenApiPath,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import { describeSampleGap, sampleGaps, sampleInput } from '@ultimat3/schema/sample';
|
|
9
17
|
import type { AnyAction } from './action';
|
|
18
|
+
import { declaredErrorsContract } from './declared-errors-contract';
|
|
10
19
|
import { ActionDeniedError, ContractDriftError } from './errors';
|
|
20
|
+
import type { ExerciseOptions } from './exercise';
|
|
21
|
+
import { exerciseContract } from './exercise';
|
|
11
22
|
import { toOpenApiOperation } from './http';
|
|
12
|
-
import { servedActionRoute } from './http-path';
|
|
13
|
-
import { actionName, invoke } from './invoke';
|
|
23
|
+
import { methodOfHttp, servedActionRoute } from './http-path';
|
|
24
|
+
import { actionName, defOf, invoke } from './invoke';
|
|
14
25
|
import { buildOpenApi } from './openapi';
|
|
15
26
|
import { listActions } from './registry';
|
|
16
|
-
import {
|
|
17
|
-
import { validateInput } from './validate';
|
|
27
|
+
import { parseActionInput } from './validate';
|
|
18
28
|
|
|
19
29
|
export interface ContractTest {
|
|
20
30
|
readonly name: string;
|
|
@@ -32,6 +42,12 @@ export interface ContractTestOptions {
|
|
|
32
42
|
*/
|
|
33
43
|
readonly input?: unknown;
|
|
34
44
|
readonly ctx?: Ctx;
|
|
45
|
+
/**
|
|
46
|
+
* The fourth assertion: the handler, called with generated input its schema accepts, inside a
|
|
47
|
+
* rolled-back transaction. `{ actor }` says who acts (required unless the policy admits an
|
|
48
|
+
* anonymous caller); `{ off: '<reason>' }` skips it out loud.
|
|
49
|
+
*/
|
|
50
|
+
readonly exercise?: ExerciseOptions;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
/** A context whose actor is core's anonymous actor — what a signed-out caller has. */
|
|
@@ -55,7 +71,7 @@ export function contractTestsFor(
|
|
|
55
71
|
// refused 401 without the schema ever seeing the garbage. The claim is about `input:`.
|
|
56
72
|
run: async () => {
|
|
57
73
|
await expectThrow(
|
|
58
|
-
() =>
|
|
74
|
+
() => parseActionInput(target.input, garbage, name),
|
|
59
75
|
'X_INPUT_INVALID',
|
|
60
76
|
`${name} accepted the value this assertion sent as garbage`,
|
|
61
77
|
`tighten \`input:\` in the ${name} definition`,
|
|
@@ -78,6 +94,9 @@ export function contractTestsFor(
|
|
|
78
94
|
name: `${name}: OpenAPI document contains its operation`,
|
|
79
95
|
run: async () => assertDocumented(target, name),
|
|
80
96
|
},
|
|
97
|
+
exerciseContract(target, options.exercise, options.ctx),
|
|
98
|
+
// Only for an action declaring `errors:`: its handler's own source names nothing else.
|
|
99
|
+
...declaredErrorsContract(target, name),
|
|
81
100
|
];
|
|
82
101
|
}
|
|
83
102
|
|
|
@@ -90,9 +109,10 @@ export function contractTestsFor(
|
|
|
90
109
|
function assertDocumented(target: AnyAction, name: string): void {
|
|
91
110
|
const seated = listActions();
|
|
92
111
|
const actions = seated.some((other) => other.name === name) ? seated : [...seated, target];
|
|
93
|
-
|
|
112
|
+
// The document's `{param}` spelling, under the verb the action declares (`http.method`).
|
|
113
|
+
const path = toOpenApiPath(servedActionRoute(name).path);
|
|
94
114
|
const published = buildOpenApi({ actions }).paths[path];
|
|
95
|
-
const owner = operationIdAt(published);
|
|
115
|
+
const owner = operationIdAt(published, methodOfHttp(defOf(target).http).toLowerCase());
|
|
96
116
|
if (owner === toOpenApiOperation(target).operationId) return;
|
|
97
117
|
throw documentedDrift(path, owner, name);
|
|
98
118
|
}
|
|
@@ -117,10 +137,12 @@ export function documentedDrift(
|
|
|
117
137
|
);
|
|
118
138
|
}
|
|
119
139
|
|
|
120
|
-
/** `paths[path].
|
|
121
|
-
function operationIdAt(entry: unknown): string | undefined {
|
|
122
|
-
if (!isJsonObject(entry)
|
|
123
|
-
const
|
|
140
|
+
/** `paths[path][verb].operationId`, read off a value typed `unknown` — narrowed, never cast. */
|
|
141
|
+
function operationIdAt(entry: unknown, verb: string): string | undefined {
|
|
142
|
+
if (!isJsonObject(entry)) return undefined;
|
|
143
|
+
const operation = entry[verb];
|
|
144
|
+
if (!isJsonObject(operation)) return undefined;
|
|
145
|
+
const id = operation['operationId'];
|
|
124
146
|
return typeof id === 'string' ? id : undefined;
|
|
125
147
|
}
|
|
126
148
|
|
|
@@ -154,6 +176,8 @@ import { ${name} } from './actions';
|
|
|
154
176
|
// Fill in: arrange a foreign actor, expect the policy to deny.
|
|
155
177
|
// The contract tests below are framework-generated and always included. Pass
|
|
156
178
|
// \`{ input }\` if the synthesized one cannot satisfy this action's schema or row loader.
|
|
179
|
+
// A guarded action also needs \`{ exercise: { actor } }\` — who its handler runs as for the
|
|
180
|
+
// generated-input runs — or \`{ exercise: { off: '<reason>' } }\`.
|
|
157
181
|
for (const contract of contractTestsFor(${name})) {
|
|
158
182
|
test(contract.name, async () => {
|
|
159
183
|
await contract.run();
|
|
@@ -188,7 +212,7 @@ async function expectDenied(
|
|
|
188
212
|
if (error instanceof ActionDeniedError) return;
|
|
189
213
|
// `invoke` runs the policy's actor half → parse input → row → policy → handle → parse
|
|
190
214
|
// output, and every stage lands here identically. Only `X_INPUT_INVALID` is attributable: it
|
|
191
|
-
// is what `
|
|
215
|
+
// is what `parseActionInput` raises before `guardAction()` is reached (for a policy whose actor half
|
|
192
216
|
// could not decide), and `input:` is the knob that answers it. Any other
|
|
193
217
|
// code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
|
|
194
218
|
// `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Single responsibility: `.contract()`'s assertion for an action declaring `errors:` — the codes its
|
|
2
|
+
// `handle` and `row` name in their own source, judged by core's rule. First hop only: what a
|
|
3
|
+
// helper throws is the dynamic half's (`declared-errors.ts`), reached by any test of the path.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
definedErrorClassCodes,
|
|
7
|
+
ErrorUndeclaredError,
|
|
8
|
+
judgeThrown,
|
|
9
|
+
scanHandlerSource,
|
|
10
|
+
} from '@ultimat3/core';
|
|
11
|
+
import { declaredStatusFor } from '@ultimat3/http';
|
|
12
|
+
import type { AnyAction } from './action';
|
|
13
|
+
import type { ContractTest } from './contract-test';
|
|
14
|
+
import { defOf } from './invoke';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The static half: one assertion, only for an action that declares `errors:`. A code literal or a
|
|
18
|
+
* `defineError` class in `handle`'s or `row`'s own source, 4xx and outside the list, is red here
|
|
19
|
+
* whether or not a test reaches the line. First hop only — a helper is the dynamic half's.
|
|
20
|
+
*/
|
|
21
|
+
export function declaredErrorsContract(target: AnyAction, name: string): readonly ContractTest[] {
|
|
22
|
+
const def = defOf(target);
|
|
23
|
+
if (def.errors === undefined) return [];
|
|
24
|
+
const declared = def.errors;
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
name: `${name}: its handler names only the errors it declares`,
|
|
28
|
+
run: async () => {
|
|
29
|
+
const source = `${def.handle.toString()}\n${def.row?.toString() ?? ''}`;
|
|
30
|
+
const stray = scanHandlerSource(source, await definedErrorClassCodes()).filter(
|
|
31
|
+
(code) => judgeThrown(declared, code, declaredStatusFor(code)) === 'undeclared',
|
|
32
|
+
);
|
|
33
|
+
if (stray.length === 0) return;
|
|
34
|
+
throw new ErrorUndeclaredError({ primitive: 'action', name }, stray.join("', '"));
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Single responsibility: the wrap `invoke` puts around `handle` and `row` for an action declaring
|
|
2
|
+
// `errors:`. Core owns the rule (`declared-errors.ts`, shared with `@ultimat3/query`) and
|
|
3
|
+
// `@ultimat3/http` the status; the static half is `declared-errors-contract.ts`.
|
|
4
|
+
|
|
5
|
+
import { withDeclaredErrors } from '@ultimat3/core';
|
|
6
|
+
import { declaredStatusFor } from '@ultimat3/http';
|
|
7
|
+
import type { AnyActionDef } from './action';
|
|
8
|
+
|
|
9
|
+
/** A throw out of `run` judged against `def.errors`. Undeclared, the call is `run` itself. */
|
|
10
|
+
export function judgedHandler<T>(def: AnyActionDef, name: string, run: () => T): Promise<T> | T {
|
|
11
|
+
if (def.errors === undefined) return run();
|
|
12
|
+
return withDeclaredErrors(def.errors, declaredStatusFor, { primitive: 'action', name }, run);
|
|
13
|
+
}
|
package/src/error-titles.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `index.ts` imports this bare, because `problemError` rebuilds a server's refusal by code and reads
|
|
5
5
|
// its title from the registry; `errors.ts` and its two siblings import it bare as well.
|
|
6
6
|
|
|
7
|
-
import { registerErrorCodes } from '@ultimat3/core';
|
|
7
|
+
import { registerErrorCodes, registerErrorRetry } from '@ultimat3/core';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
@@ -34,7 +34,7 @@ export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
|
|
|
34
34
|
"a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
|
|
35
35
|
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
36
36
|
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
37
|
-
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a
|
|
37
|
+
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a valid path or :param template",
|
|
38
38
|
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
39
39
|
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
40
40
|
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
@@ -42,6 +42,15 @@ export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
|
|
|
42
42
|
X_IDEMPOTENCY_RESERVATION_LOST:
|
|
43
43
|
"an idempotent action's reservation was taken over before its transaction could settle it",
|
|
44
44
|
X_IDEMPOTENT_REPLAY_REDACTED: 'an idempotent replay would return a field redacted at rest',
|
|
45
|
+
X_IDEMPOTENCY_OUTPUT_UNSERIALIZABLE:
|
|
46
|
+
"an idempotent action's answer cannot be stored as JSON, so no retry could replay it",
|
|
47
|
+
X_IDEMPOTENCY_RUN_CEILING: 'a keyed run passed its hard ceiling',
|
|
48
|
+
X_CONTRACT_DEFECT: 'a generated legal input made a handler fail',
|
|
49
|
+
X_CONTRACT_EXERCISE_UNREACHED: 'no generated run reached the handler',
|
|
50
|
+
X_CONTRACT_EXERCISE_UNDECLARED: '.contract() on a non-public action names no exercise',
|
|
51
|
+
X_ACTION_STREAM_INVALID: 'a streaming action broke its stream contract',
|
|
52
|
+
X_METER_EXCEEDED: "an action's usage meter is spent for this period",
|
|
53
|
+
X_METER_INVALID: "an action's meter cannot be counted against",
|
|
45
54
|
};
|
|
46
55
|
|
|
47
56
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
@@ -49,3 +58,10 @@ export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
|
|
|
49
58
|
registerErrorCodes(
|
|
50
59
|
Object.fromEntries(Object.entries(ACTION_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
51
60
|
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The one code this package throws that a caller is SUPPOSED to come back from, and it says when:
|
|
64
|
+
* `meta.retryAfterSeconds` is the period's end, rendered onto `Retry-After`, and a job's retry
|
|
65
|
+
* waits for it instead of spending its attempts on an allowance that cannot refill sooner.
|
|
66
|
+
*/
|
|
67
|
+
registerErrorRetry({ X_METER_EXCEEDED: 'retry-after' });
|
package/src/errors-http.ts
CHANGED
|
@@ -9,16 +9,33 @@ import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
|
9
9
|
import './error-titles';
|
|
10
10
|
|
|
11
11
|
export class ActionHttpPathInvalidError extends UltimateError {
|
|
12
|
-
constructor(input: { name: string; path: unknown; reason: string }) {
|
|
12
|
+
constructor(input: { name: string; path: unknown; reason: string; fix?: string }) {
|
|
13
13
|
super({
|
|
14
14
|
code: 'X_ACTION_HTTP_PATH_INVALID',
|
|
15
15
|
cause: `action "${input.name}" pins http.path ${renderCauseValue(input.path)}, which ${input.reason}`,
|
|
16
|
-
fix:
|
|
16
|
+
fix:
|
|
17
|
+
input.fix ??
|
|
18
|
+
"http: { path: '/orders/:id/refund', method: 'POST' } with input: t.object({ id: t.string }) — every :param a required t.string key of input:; segments of a-z 0-9 . _ ~ -, no *wildcard, no trailing slash, never under /_x; method POST, PUT, PATCH or DELETE",
|
|
17
19
|
meta: { action: input.name },
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `deprecated.replacedBy` names an action served at a `:param` template. The successor travels as a
|
|
26
|
+
* `Link` header, and a header cannot carry a template a client has no values for.
|
|
27
|
+
*/
|
|
28
|
+
export class ActionDeprecationSuccessorInvalidError extends UltimateError {
|
|
29
|
+
constructor(input: { name: string; successor: string; path: string }) {
|
|
30
|
+
super({
|
|
31
|
+
code: 'X_ACTION_DEPRECATION_INVALID',
|
|
32
|
+
cause: `action "${input.name}" declares deprecated.replacedBy "${input.successor}", which is served at the template ${input.path} — a Link header cannot carry a template`,
|
|
33
|
+
fix: `deprecated: { since, sunset } — on ${input.name}, drop replacedBy, or name an action whose http.path has no :param`,
|
|
34
|
+
meta: { action: input.name, field: 'replacedBy', value: input.successor },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
export class ActionPathStyleInvalidError extends UltimateError {
|
|
23
40
|
constructor(value: unknown) {
|
|
24
41
|
super({
|
|
@@ -174,6 +174,41 @@ export class IdempotencyReservationLostError extends UltimateError {
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* A keyed handler's answer that JSON has no form for: in practice a `bigint`, since
|
|
179
|
+
* `restingAnswer` has already cut a cycle and everything else `JSON.stringify` could run app code
|
|
180
|
+
* on. Refused by `withIdempotency`, before either store is asked: memory would keep the live value
|
|
181
|
+
* and replay it while Postgres threw an uncoded `TypeError` from its settle and left the record in
|
|
182
|
+
* flight, so the same handler answered differently per store. Settled as a failure and replayed:
|
|
183
|
+
* the handler ran, and running it again is the double charge.
|
|
184
|
+
*/
|
|
185
|
+
export class IdempotencyOutputUnserializableError extends UltimateError {
|
|
186
|
+
constructor(key: string) {
|
|
187
|
+
super({
|
|
188
|
+
code: 'X_IDEMPOTENCY_OUTPUT_UNSERIALIZABLE',
|
|
189
|
+
cause: `the answer of the handler under idempotency key "${key}" holds a value JSON has no form for, so no retry could replay it. The handler has run`,
|
|
190
|
+
fix: 'handle: ({ input }) => ({ id: String(input.id) }) # return JSON: String(id) for a bigint, text with no NUL and no lone surrogate (a NaN is not this refusal: it rests as null), then send a fresh Idempotency-Key once the handler is fixed',
|
|
191
|
+
meta: { key },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A keyed run still going at its ceiling. It outlives the request deadline on purpose, and nothing
|
|
198
|
+
* else would end it: the request's in-flight count, the key's 409 and the drain would all wait. The
|
|
199
|
+
* handler may have written, so the failure is SETTLED and replayed, never released.
|
|
200
|
+
*/
|
|
201
|
+
export class IdempotencyRunCeilingError extends UltimateError {
|
|
202
|
+
constructor(key: string, ceilingMs: number) {
|
|
203
|
+
super({
|
|
204
|
+
code: 'X_IDEMPOTENCY_RUN_CEILING',
|
|
205
|
+
cause: `the handler under idempotency key "${key}" was still running ${ceilingMs}ms after it started: it was aborted and the key settled as this failure. It may have written before that, so read its effect before sending a fresh key`,
|
|
206
|
+
fix: 'configureIdempotency({ scope, ceilingMs: 300_000 }) # at boot, to raise the ceiling; or make the call that hung with outbound() from @ultimat3/core/outbound, which the ceiling aborts',
|
|
207
|
+
meta: { key, ceilingMs },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
177
212
|
/**
|
|
178
213
|
* A replay of a settled answer that had a field redacted at rest. Refused, never served: the
|
|
179
214
|
* stored copy holds `[redacted]` where the first caller got a credential, and handing that back
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The meter's two refusals: a period's allowance spent (`X_METER_EXCEEDED`, the caller's), and a
|
|
3
|
+
* declaration or a call-time limit that cannot be counted against (`X_METER_INVALID`, the app's).
|
|
4
|
+
* Codes and titles stay in `error-titles.ts`'s one registration; imported bare for that reason.
|
|
5
|
+
*/
|
|
6
|
+
import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import './error-titles';
|
|
8
|
+
|
|
9
|
+
export interface MeterExceededInput {
|
|
10
|
+
readonly action: string;
|
|
11
|
+
readonly meter: string;
|
|
12
|
+
readonly period: string;
|
|
13
|
+
readonly limit: number;
|
|
14
|
+
readonly resetsAtMs: number;
|
|
15
|
+
readonly nowMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `meta.retryAfterSeconds` is the framework's one spelling of a stated delay: `@ultimat3/http`
|
|
20
|
+
* renders it onto `Retry-After`, and a job's retry waits the same number.
|
|
21
|
+
*/
|
|
22
|
+
export class MeterExceededError extends UltimateError {
|
|
23
|
+
constructor(input: MeterExceededInput) {
|
|
24
|
+
const resetsAt = new Date(input.resetsAtMs).toISOString();
|
|
25
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((input.resetsAtMs - input.nowMs) / 1000));
|
|
26
|
+
super({
|
|
27
|
+
code: 'X_METER_EXCEEDED',
|
|
28
|
+
cause: `the "${input.meter}" meter on action "${input.action}" allows ${input.limit} calls per ${input.period} (UTC), and this caller's allowance for the period is spent; it resets at ${resetsAt}`,
|
|
29
|
+
fix: `x actions show ${renderFixShellArg(input.action, '<action>')} --json # names the meter and its period; raise what meter.limit resolves to for this tenant in the action file, or retry after meta.resetsAt`,
|
|
30
|
+
callerFix: `retry after ${resetsAt} (the Retry-After header): the "${input.meter}" allowance resets then, and a larger one is the application's to grant`,
|
|
31
|
+
meta: { meter: input.meter, limit: input.limit, resetsAt, retryAfterSeconds },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type MeterField = 'name' | 'period' | 'limit' | 'warnAt';
|
|
37
|
+
|
|
38
|
+
/** A meter declared — or a limit resolved — that no counter could enforce. */
|
|
39
|
+
export class MeterInvalidError extends UltimateError {
|
|
40
|
+
constructor(meter: string, field: MeterField, expected: string, value?: unknown) {
|
|
41
|
+
super({
|
|
42
|
+
code: 'X_METER_INVALID',
|
|
43
|
+
cause: `meter "${meter}" declares ${field}${value === undefined ? '' : ` = ${renderCauseValue(value)}`}, which is not ${expected}`,
|
|
44
|
+
fix: `meter: { name: 'calls', period: 'day', limit: ({ ctx }) => 500, warnAt: 0.8, onWarn: quotaWarning } # edit the action declaring "${renderFixShellArg(meter, '<meter>')}" — ${field} must be ${expected}`,
|
|
45
|
+
meta: { meter, field },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The streaming action's one refusal, `X_ACTION_STREAM_INVALID`: a declaration that says it streams
|
|
2
|
+
// and a handler that does not, or a chunk its own `stream` schema refuses.
|
|
3
|
+
|
|
4
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
const FIX =
|
|
7
|
+
'async *handle({ input, ctx }) { yield chunk; return output; } — yield only what stream: declares';
|
|
8
|
+
|
|
9
|
+
export class ActionStreamInvalidError extends UltimateError {
|
|
10
|
+
constructor(action: string, reason: string) {
|
|
11
|
+
super({
|
|
12
|
+
code: 'X_ACTION_STREAM_INVALID',
|
|
13
|
+
cause: `action "${action}" declares stream: and ${reason}`,
|
|
14
|
+
fix: FIX,
|
|
15
|
+
meta: { action },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
renderFixShellArg,
|
|
13
13
|
retryForStatus,
|
|
14
14
|
UltimateError,
|
|
15
|
+
withRequestId,
|
|
15
16
|
withStatedDelay,
|
|
16
17
|
} from '@ultimat3/core';
|
|
17
18
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
@@ -29,8 +30,10 @@ export {
|
|
|
29
30
|
IdempotencyConflictError,
|
|
30
31
|
IdempotencyKeyInvalidError,
|
|
31
32
|
IdempotencyNotSharedError,
|
|
33
|
+
IdempotencyOutputUnserializableError,
|
|
32
34
|
IdempotencyReplayedFailureError,
|
|
33
35
|
IdempotencyReservationLostError,
|
|
36
|
+
IdempotencyRunCeilingError,
|
|
34
37
|
IdempotencyStatusUnknownError,
|
|
35
38
|
IdempotentReplayRedactedError,
|
|
36
39
|
MutatorNotIdempotentError,
|
|
@@ -264,6 +267,8 @@ export interface RemoteFailure {
|
|
|
264
267
|
readonly title?: string | undefined;
|
|
265
268
|
/** The delay the answer's `Retry-After` named, in seconds (core's `retryAfterSecondsOf`). */
|
|
266
269
|
readonly retryAfterSeconds?: number | undefined;
|
|
270
|
+
/** The document's root `requestId`, as the wire had it: core's `withRequestId` judges it. */
|
|
271
|
+
readonly requestId?: unknown;
|
|
267
272
|
}
|
|
268
273
|
|
|
269
274
|
/** A link, not a string the server happened to put in a field the overlay renders as an href. */
|
|
@@ -323,18 +328,21 @@ export class RemoteActionError extends UltimateError {
|
|
|
323
328
|
// Core's `withStatedDelay`, the rule `problemError` applies too: a `retryAfterSeconds` off the
|
|
324
329
|
// body is dropped and only the header's is carried — the server never writes one there.
|
|
325
330
|
meta: withStatedDelay(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
331
|
+
withRequestId(
|
|
332
|
+
{
|
|
333
|
+
// The server's declared keys FIRST, so the four this class owns win a collision: a
|
|
334
|
+
// server meta naming `status` would otherwise overwrite the one a report reads the HTTP
|
|
335
|
+
// status from, and `origin: 'remote'` marks the code as one this bundle never declared.
|
|
336
|
+
...failure.meta,
|
|
337
|
+
origin: 'remote',
|
|
338
|
+
action: failure.action,
|
|
339
|
+
status: failure.status,
|
|
340
|
+
// Absent rather than `undefined`: `meta` is rendered into `--json` and the error
|
|
341
|
+
// reporter, and a null member reads as "the server sent an empty list".
|
|
342
|
+
...(failure.issues === undefined ? {} : { issues: failure.issues }),
|
|
343
|
+
},
|
|
344
|
+
failure.requestId,
|
|
345
|
+
),
|
|
338
346
|
failure.retryAfterSeconds,
|
|
339
347
|
),
|
|
340
348
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exercise assertion's refusals. Off the socket: each is raised inside `bun test` by the
|
|
3
|
+
* contract step and never served, so none has an HTTP status. `@ultimat3/query` raises the same
|
|
4
|
+
* three codes from its own twin (it cannot import this tier), borrowing the titles registered here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isUltimateError, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
8
|
+
import './error-titles';
|
|
9
|
+
import { ContractDriftError } from './errors';
|
|
10
|
+
|
|
11
|
+
/** A generated legal input made the handler fail: an uncoded throw, a bad output, a coded 5xx. */
|
|
12
|
+
export class ContractDefectError extends UltimateError {
|
|
13
|
+
constructor(input: {
|
|
14
|
+
name: string;
|
|
15
|
+
run: number;
|
|
16
|
+
input: unknown;
|
|
17
|
+
defect: string;
|
|
18
|
+
replay: string;
|
|
19
|
+
/** What the handler threw: kept whole as `sourceError`, its code and cause in `meta.failure`. */
|
|
20
|
+
error: unknown;
|
|
21
|
+
}) {
|
|
22
|
+
super({
|
|
23
|
+
code: 'X_CONTRACT_DEFECT',
|
|
24
|
+
cause: `${input.name} ${input.defect} on generated run ${input.run}, for input its own schema accepts (meta.input, meta.failure)`,
|
|
25
|
+
fix: `${input.replay} # replays the run; fix the ${input.name} handler or tighten its input: first`,
|
|
26
|
+
meta: {
|
|
27
|
+
run: input.run,
|
|
28
|
+
input: input.input,
|
|
29
|
+
...(isUltimateError(input.error)
|
|
30
|
+
? { failure: { code: input.error.code, cause: input.error.cause } }
|
|
31
|
+
: {}),
|
|
32
|
+
},
|
|
33
|
+
sourceError: input.error,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Not one run got as far as the handler: the assertion would have proven nothing. */
|
|
39
|
+
export class ExerciseUnreachedError extends UltimateError {
|
|
40
|
+
constructor(name: string, outcomes: readonly string[]) {
|
|
41
|
+
const call = renderFixShellArg(name, '<name>');
|
|
42
|
+
super({
|
|
43
|
+
code: 'X_CONTRACT_EXERCISE_UNREACHED',
|
|
44
|
+
cause: `none of ${outcomes.length} generated runs of ${name} reached its handler (meta.outcomes lists what each one hit)`,
|
|
45
|
+
fix: `${call}.contract({ exercise: { actor } }) with an actor the policy permits — or bind a real row: { exercise: { actor, input: (drawn) => ({ ...drawn, id }) } }`,
|
|
46
|
+
meta: { outcomes },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A guarded handler with no `exercise` said, or an opt-out with no reason. */
|
|
52
|
+
export class ExerciseUndeclaredError extends UltimateError {
|
|
53
|
+
constructor(name: string, blankReason: boolean) {
|
|
54
|
+
const call = renderFixShellArg(name, '<name>');
|
|
55
|
+
super({
|
|
56
|
+
code: 'X_CONTRACT_EXERCISE_UNDECLARED',
|
|
57
|
+
cause: blankReason
|
|
58
|
+
? `${name}.contract({ exercise: { off } }) gives a blank reason, so the skip is silent`
|
|
59
|
+
: `${name} refuses an anonymous caller and its .contract() names no actor to exercise its handler as`,
|
|
60
|
+
fix: `${call}.contract({ exercise: { actor } }) — or ${call}.contract({ exercise: { off: '<why this handler cannot run in a test>' } })`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every draw failed the schema it was drawn from — a provider refinement the IR does not carry.
|
|
67
|
+
* `X_CONTRACT_DRIFT`, the code the policy assertion already raises for the same gap.
|
|
68
|
+
*/
|
|
69
|
+
export function everyDrawRefused(name: string): ContractDriftError {
|
|
70
|
+
return new ContractDriftError(
|
|
71
|
+
`${name}: its input schema refused every generated value, so the handler was never exercised`,
|
|
72
|
+
`${renderFixShellArg(name, '<name>')}.contract({ exercise: { actor, input: (drawn) => ({ ...drawn, field: '<a value the refinement accepts>' }) } })`,
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/exercise.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract step's fourth assertion, as an action needs it: `@ultimat3/core`'s exercise loop
|
|
3
|
+
* (`runExercise`) over `invoke`, each run inside a rolled-back transaction, "reached" read off the
|
|
4
|
+
* handler counter. The verdict, the seed and the replay are the loop's, shared with `query`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor, Ctx, DrawnInput, ExerciseAttempt, ExerciseReport } from '@ultimat3/core';
|
|
8
|
+
import {
|
|
9
|
+
bindDrawn,
|
|
10
|
+
ctxOf,
|
|
11
|
+
exerciseReplay,
|
|
12
|
+
isUltimateError,
|
|
13
|
+
runExercise,
|
|
14
|
+
tryUseContext,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import { statusFor } from '@ultimat3/http';
|
|
17
|
+
import { admitsAnonymous } from '@ultimat3/policy';
|
|
18
|
+
import { arbitrary } from '@ultimat3/schema/sample';
|
|
19
|
+
import type { AnyAction } from './action';
|
|
20
|
+
import type { ContractTest } from './contract-test';
|
|
21
|
+
import {
|
|
22
|
+
ContractDefectError,
|
|
23
|
+
ExerciseUndeclaredError,
|
|
24
|
+
ExerciseUnreachedError,
|
|
25
|
+
everyDrawRefused,
|
|
26
|
+
} from './exercise-errors';
|
|
27
|
+
import { actionName, handlerReached, invoke } from './invoke';
|
|
28
|
+
import { inRolledBackScope, rollbackAvailable } from './tx-scope';
|
|
29
|
+
import { parseActionInput } from './validate';
|
|
30
|
+
|
|
31
|
+
export type { ExerciseReport };
|
|
32
|
+
|
|
33
|
+
/** Who the handler runs as, and what binds the parts a generated value cannot (a real row's id). */
|
|
34
|
+
export interface ExerciseActorOptions {
|
|
35
|
+
/** The caller each run is invoked as. `null` is the signed-out caller. */
|
|
36
|
+
readonly actor: Actor | null;
|
|
37
|
+
/**
|
|
38
|
+
* `(drawn) => ({ ...drawn, postId })`: the generated value, with what must resolve bound in.
|
|
39
|
+
* May be `async`: `x exercise` awaits it inside the call's own request, so `useContext().actor`
|
|
40
|
+
* is the actor the call runs as (a token issued to that actor, a passkey it registered).
|
|
41
|
+
*/
|
|
42
|
+
readonly input?: (drawn: DrawnInput) => unknown;
|
|
43
|
+
/** 1 to `EXERCISE_RUNS_MAX`; `EXERCISE_RUNS` when omitted. */
|
|
44
|
+
readonly runs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** No exercise, said out loud: the reason is listed in the assertion's name. */
|
|
48
|
+
export interface ExerciseOffOptions {
|
|
49
|
+
readonly off: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ExerciseOptions = ExerciseActorOptions | ExerciseOffOptions;
|
|
53
|
+
|
|
54
|
+
/** The name the assertion runs under — also the `-t` that replays it. */
|
|
55
|
+
export const exerciseTestName = (name: string): string =>
|
|
56
|
+
`${name}: handler survives generated input`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Exercise `target`: call its handler with generated input its own schema accepts. Throws
|
|
60
|
+
* `X_CONTRACT_DEFECT` on the first defect, `X_CONTRACT_EXERCISE_UNREACHED` when no run got to the
|
|
61
|
+
* handler, `X_CONTRACT_DRIFT` when the schema refused every draw.
|
|
62
|
+
*
|
|
63
|
+
* A test-time call: refused in `staging`/`production` (`X_CONTRACT_EXERCISE_ENVIRONMENT`). Each
|
|
64
|
+
* run's transaction rolls back the handler's SQL writes, its memory-store writes (`test` with no
|
|
65
|
+
* client installed, `tx` passed or not), the post-commit cache bust (either store) and, when a SQL
|
|
66
|
+
* transaction is open, the outbox stage. NOT rolled back: a write on a second client or
|
|
67
|
+
* `outsideTransaction`, a job enqueued with `outbox: false`, every job enqueue when no SQL
|
|
68
|
+
* transaction is open (the memory store: the jobs facade enqueues straight to the job driver),
|
|
69
|
+
* storage puts, mail, outbound fetch (sealed under the test preload) and rate-limit counters.
|
|
70
|
+
*/
|
|
71
|
+
export async function exerciseAction(
|
|
72
|
+
target: AnyAction,
|
|
73
|
+
options: ExerciseActorOptions,
|
|
74
|
+
ctx?: Ctx,
|
|
75
|
+
): Promise<ExerciseReport> {
|
|
76
|
+
const name = actionName(target);
|
|
77
|
+
const base = ctx ?? tryUseContext() ?? ctxOf({});
|
|
78
|
+
const verdict = await runExercise({
|
|
79
|
+
name,
|
|
80
|
+
runs: options.runs,
|
|
81
|
+
stoppedLabel: 'stopped before the handler',
|
|
82
|
+
scoped: rollbackAvailable(),
|
|
83
|
+
draw: (seed, run) => arbitrary(target.input, { seed, run }),
|
|
84
|
+
bind: bindDrawn(options.input),
|
|
85
|
+
accepts: (input) => acceptedBy(target, input, name),
|
|
86
|
+
attempt: async (input): Promise<ExerciseAttempt> => {
|
|
87
|
+
const before = handlerReached(target);
|
|
88
|
+
const settled = await inRolledBackScope(() =>
|
|
89
|
+
invoke(target, input, { ctx: base, actor: options.actor, surface: 'http' }),
|
|
90
|
+
);
|
|
91
|
+
const reached = handlerReached(target) > before;
|
|
92
|
+
return settled.ok ? { ok: true, reached } : { ok: false, reached, error: settled.error };
|
|
93
|
+
},
|
|
94
|
+
statusOf: statusFor,
|
|
95
|
+
});
|
|
96
|
+
switch (verdict.kind) {
|
|
97
|
+
case 'pass':
|
|
98
|
+
return verdict.report;
|
|
99
|
+
case 'drift':
|
|
100
|
+
throw everyDrawRefused(name);
|
|
101
|
+
case 'unreached':
|
|
102
|
+
throw new ExerciseUnreachedError(name, verdict.outcomes);
|
|
103
|
+
case 'defect':
|
|
104
|
+
throw new ContractDefectError({
|
|
105
|
+
name,
|
|
106
|
+
run: verdict.run,
|
|
107
|
+
input: verdict.input,
|
|
108
|
+
defect: verdict.defect,
|
|
109
|
+
replay: exerciseReplay(name, exerciseTestName(name)),
|
|
110
|
+
error: verdict.error,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function acceptedBy(target: AnyAction, input: unknown, name: string): Promise<boolean> {
|
|
116
|
+
try {
|
|
117
|
+
await parseActionInput(target.input, input, name);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isUltimateError(error) && error.code === 'X_INPUT_INVALID') return false;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The assertion `.contract()` appends. A public action exercises as the signed-out caller unless
|
|
127
|
+
* told otherwise; a guarded one must say who acts or why not — there is no bypass and none is
|
|
128
|
+
* added, so a policy that denies the named actor is an unreached run, not a skipped one.
|
|
129
|
+
*/
|
|
130
|
+
export function exerciseContract(
|
|
131
|
+
target: AnyAction,
|
|
132
|
+
exercise: ExerciseOptions | undefined,
|
|
133
|
+
ctx: Ctx | undefined,
|
|
134
|
+
): ContractTest {
|
|
135
|
+
const name = actionName(target);
|
|
136
|
+
if (exercise !== undefined && 'off' in exercise) {
|
|
137
|
+
const reason = exercise.off.trim();
|
|
138
|
+
return reason.length === 0
|
|
139
|
+
? {
|
|
140
|
+
name: exerciseTestName(name),
|
|
141
|
+
run: () => Promise.reject(new ExerciseUndeclaredError(name, true)),
|
|
142
|
+
}
|
|
143
|
+
: { name: `${name}: handler exercise off: ${reason}`, run: () => Promise.resolve() };
|
|
144
|
+
}
|
|
145
|
+
const options = exercise ?? (admitsAnonymous(target.policy) ? { actor: null } : undefined);
|
|
146
|
+
return {
|
|
147
|
+
name: exerciseTestName(name),
|
|
148
|
+
run: async () => {
|
|
149
|
+
if (options === undefined) throw new ExerciseUndeclaredError(name, false);
|
|
150
|
+
await exerciseAction(target, options, ctx);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|