@ultimat3/action 30.6.0 → 30.8.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 +1 -0
- package/package.json +8 -8
- package/src/contract-test.ts +8 -6
- package/src/exercise-declarations.ts +37 -0
- package/src/exercise-errors.ts +54 -13
- package/src/exercise.ts +40 -8
- package/src/index.ts +1 -0
package/CLAUDE.md
CHANGED
|
@@ -42,6 +42,7 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
42
42
|
| `contract-test.ts` | assertions `x g action` emits |
|
|
43
43
|
| `exercise.ts` | the fourth contract assertion: run loop, verdict, `exerciseContract` |
|
|
44
44
|
| `exercise-errors.ts` | the three `X_CONTRACT_*` classes, off-socket |
|
|
45
|
+
| `exercise-declarations.ts` | `.contract({ exercise })` takes the app's `defineExercise()` value (structural `ExerciseDeclarations`) and reads the entry by the action's name: one declaration in `apps/web/exercise.ts`, read by the contract step and `x exercise` |
|
|
45
46
|
| `declared-errors.ts` | the wrap `invoke` puts around `handle` and `row`, the ONLY judged throws. `errors:` is checked, sorted and frozen at `action()` |
|
|
46
47
|
| `declared-errors-contract.ts` | `.contract()`'s source scan |
|
|
47
48
|
| `openapi-errors.ts` | core's builder bound to http's status and `problemTypeFor` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.8.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"test": "bun test"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ultimat3/cache": "30.
|
|
50
|
-
"@ultimat3/core": "30.
|
|
51
|
-
"@ultimat3/db": "30.
|
|
52
|
-
"@ultimat3/entity": "30.
|
|
53
|
-
"@ultimat3/http": "30.
|
|
54
|
-
"@ultimat3/policy": "30.
|
|
55
|
-
"@ultimat3/schema": "30.
|
|
49
|
+
"@ultimat3/cache": "30.8.0",
|
|
50
|
+
"@ultimat3/core": "30.8.0",
|
|
51
|
+
"@ultimat3/db": "30.8.0",
|
|
52
|
+
"@ultimat3/entity": "30.8.0",
|
|
53
|
+
"@ultimat3/http": "30.8.0",
|
|
54
|
+
"@ultimat3/policy": "30.8.0",
|
|
55
|
+
"@ultimat3/schema": "30.8.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/contract-test.ts
CHANGED
|
@@ -17,8 +17,8 @@ import { describeSampleGap, sampleGaps, sampleInput } from '@ultimat3/schema/sam
|
|
|
17
17
|
import type { AnyAction } from './action';
|
|
18
18
|
import { declaredErrorsContract } from './declared-errors-contract';
|
|
19
19
|
import { ActionDeniedError, ContractDriftError } from './errors';
|
|
20
|
-
import type { ExerciseOptions } from './exercise';
|
|
21
20
|
import { exerciseContract } from './exercise';
|
|
21
|
+
import type { ContractExercise } from './exercise-declarations';
|
|
22
22
|
import { toOpenApiOperation } from './http';
|
|
23
23
|
import { methodOfHttp, servedActionRoute } from './http-path';
|
|
24
24
|
import { actionName, defOf, invoke } from './invoke';
|
|
@@ -44,10 +44,12 @@ export interface ContractTestOptions {
|
|
|
44
44
|
readonly ctx?: Ctx;
|
|
45
45
|
/**
|
|
46
46
|
* The fourth assertion: the handler, called with generated input its schema accepts, inside a
|
|
47
|
-
* rolled-back transaction.
|
|
48
|
-
*
|
|
47
|
+
* rolled-back transaction. In an app, the `exercise` export of `apps/web/exercise.ts` — the
|
|
48
|
+
* `defineExercise()` value `x exercise` reads — and this action's entry in it is the one used:
|
|
49
|
+
* one declaration, both runners. One entry inline (`{ actor }`, `{ off: '<reason>' }`) is for a
|
|
50
|
+
* test binding rows of its own world. Required unless the policy admits an anonymous caller.
|
|
49
51
|
*/
|
|
50
|
-
readonly exercise?:
|
|
52
|
+
readonly exercise?: ContractExercise;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** A context whose actor is core's anonymous actor — what a signed-out caller has. */
|
|
@@ -176,8 +178,8 @@ import { ${name} } from './actions';
|
|
|
176
178
|
// Fill in: arrange a foreign actor, expect the policy to deny.
|
|
177
179
|
// The contract tests below are framework-generated and always included. Pass
|
|
178
180
|
// \`{ input }\` if the synthesized one cannot satisfy this action's schema or row loader.
|
|
179
|
-
// A guarded action also needs
|
|
180
|
-
//
|
|
181
|
+
// A guarded action also needs who its handler runs as for the generated-input runs: its entry in
|
|
182
|
+
// apps/web/exercise.ts (\`{ actor }\` or \`{ off: '<reason>' }\`), handed in as \`{ exercise }\`.
|
|
181
183
|
for (const contract of contractTestsFor(${name})) {
|
|
182
184
|
test(contract.name, async () => {
|
|
183
185
|
await contract.run();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One declaration of a primitive's exercise: the app's `defineExercise()` value (apps/web/exercise.ts,
|
|
3
|
+
* the file `x exercise` reads), handed whole to `.contract({ exercise })`, resolved here by the
|
|
4
|
+
* primitive's own name. Structural, so `@ultimat3/testing`'s value satisfies it without an import up.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExerciseOptions } from './exercise';
|
|
8
|
+
|
|
9
|
+
export type { ExerciseOptions };
|
|
10
|
+
|
|
11
|
+
/** The one file an app declares its exercise in — every refusal's fix names it. */
|
|
12
|
+
export const EXERCISE_DECLARATIONS_FILE = 'apps/web/exercise.ts';
|
|
13
|
+
|
|
14
|
+
/** `defineExercise()`'s value, as far as a contract reads it: the entries, by primitive name. */
|
|
15
|
+
export interface ExerciseDeclarations {
|
|
16
|
+
readonly kind: 'exercise-overrides';
|
|
17
|
+
readonly entries: ReadonlyMap<string, ExerciseOptions>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What `.contract({ exercise })` takes: the app's declarations, or one entry inline. */
|
|
21
|
+
export type ContractExercise = ExerciseOptions | ExerciseDeclarations;
|
|
22
|
+
|
|
23
|
+
export const isExerciseDeclarations = (value: ContractExercise): value is ExerciseDeclarations =>
|
|
24
|
+
'kind' in value && value.kind === 'exercise-overrides';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The entry `name` reads: the declarations' own, or the inline one. `undefined` when none. An
|
|
28
|
+
* `'action:<name>'` key wins over the bare name — the one `x exercise` reads for an action whose
|
|
29
|
+
* name a job shares (`@ultimat3/testing`'s `overrideFor`; query's twin keeps the same rule).
|
|
30
|
+
*/
|
|
31
|
+
export function resolveExercise(
|
|
32
|
+
name: string,
|
|
33
|
+
exercise: ContractExercise | undefined,
|
|
34
|
+
): ExerciseOptions | undefined {
|
|
35
|
+
if (exercise === undefined || !isExerciseDeclarations(exercise)) return exercise;
|
|
36
|
+
return exercise.entries.get(`action:${name}`) ?? exercise.entries.get(name);
|
|
37
|
+
}
|
package/src/exercise-errors.ts
CHANGED
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
* three codes from its own twin (it cannot import this tier), borrowing the titles registered here.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isUltimateError,
|
|
9
|
+
renderDrawnInput,
|
|
10
|
+
renderExerciseKey,
|
|
11
|
+
renderFixShellArg,
|
|
12
|
+
sampleGapBinding,
|
|
13
|
+
UltimateError,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
16
|
+
import { describeSampleGap, sampleGaps } from '@ultimat3/schema/sample';
|
|
8
17
|
import './error-titles';
|
|
9
18
|
import { ContractDriftError } from './errors';
|
|
19
|
+
import { EXERCISE_DECLARATIONS_FILE } from './exercise-declarations';
|
|
10
20
|
|
|
11
21
|
/** A generated legal input made the handler fail: an uncoded throw, a bad output, a coded 5xx. */
|
|
12
22
|
export class ContractDefectError extends UltimateError {
|
|
@@ -42,33 +52,64 @@ export class ExerciseUnreachedError extends UltimateError {
|
|
|
42
52
|
super({
|
|
43
53
|
code: 'X_CONTRACT_EXERCISE_UNREACHED',
|
|
44
54
|
cause: `none of ${outcomes.length} generated runs of ${name} reached its handler (meta.outcomes lists what each one hit)`,
|
|
45
|
-
fix:
|
|
55
|
+
fix: `defineExercise({ ${call}: { actor } }) # in ${EXERCISE_DECLARATIONS_FILE}, an actor the policy permits — or bind a real row: { actor, input: (drawn) => ({ ...drawn, id }) }`,
|
|
46
56
|
meta: { outcomes },
|
|
47
57
|
});
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Why no exercise could run: an `off` with no reason, nothing declared, no entry by this name, or
|
|
63
|
+
* an entry with no `actor` on a guarded handler — `x exercise` picks one its policy allows, a
|
|
64
|
+
* contract test has no role set to pick from.
|
|
65
|
+
*/
|
|
66
|
+
export type ExerciseUndeclaredWhy = 'blank' | 'undeclared' | 'no-entry' | 'no-actor';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A guarded handler with no exercise. The fix is the paste in the ONE home, `apps/web/exercise.ts`
|
|
70
|
+
* — the file `x exercise` reads and `.contract({ exercise })` is handed — never an inline option:
|
|
71
|
+
* an entry declared only in a test is one `x exercise` cannot see. `@ultimat3/query` twins it.
|
|
72
|
+
*/
|
|
52
73
|
export class ExerciseUndeclaredError extends UltimateError {
|
|
53
|
-
constructor(name: string,
|
|
74
|
+
constructor(name: string, why: ExerciseUndeclaredWhy) {
|
|
54
75
|
const call = renderFixShellArg(name, '<name>');
|
|
76
|
+
const refuses = `${name} refuses an anonymous caller`;
|
|
55
77
|
super({
|
|
56
78
|
code: 'X_CONTRACT_EXERCISE_UNDECLARED',
|
|
57
|
-
cause:
|
|
58
|
-
|
|
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>' } })`,
|
|
79
|
+
cause: undeclaredCause(name, why, refuses),
|
|
80
|
+
fix: `defineExercise({ ${call}: { actor } }) # in ${EXERCISE_DECLARATIONS_FILE}, then ${call}.contract({ exercise }) with that file's export — or { off: '<why this handler cannot run in a test>' } there`,
|
|
61
81
|
});
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
/** What was missing, said in the file it is missing from. A `switch`, never a key lookup on a table. */
|
|
86
|
+
function undeclaredCause(name: string, why: ExerciseUndeclaredWhy, refuses: string): string {
|
|
87
|
+
switch (why) {
|
|
88
|
+
case 'blank':
|
|
89
|
+
return `${name}.contract({ exercise: { off } }) gives a blank reason, so the skip is silent`;
|
|
90
|
+
case 'no-entry':
|
|
91
|
+
return `${refuses} and ${EXERCISE_DECLARATIONS_FILE} declares no entry for it, so no actor to exercise its handler as is known`;
|
|
92
|
+
}
|
|
93
|
+
return `${refuses} and its .contract() names no actor to exercise its handler as`;
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
/**
|
|
66
|
-
* Every draw failed the schema it was drawn from
|
|
67
|
-
*
|
|
97
|
+
* Every draw failed the schema it was drawn from. `X_CONTRACT_DRIFT`, the code the policy
|
|
98
|
+
* assertion already raises for the same gap. A field whose `pattern` (or item count) the sampler
|
|
99
|
+
* cannot meet is named in the cause and bound in the fix's `input:`; a provider refinement the IR
|
|
100
|
+
* does not carry leaves only the placeholder field, honestly.
|
|
68
101
|
*/
|
|
69
|
-
export function everyDrawRefused(name: string): ContractDriftError {
|
|
102
|
+
export function everyDrawRefused(name: string, schema: StandardSchemaV1): ContractDriftError {
|
|
103
|
+
const gaps = sampleGaps(schema);
|
|
104
|
+
const described = gaps.map((path) => describeSampleGap(schema, path));
|
|
105
|
+
const input =
|
|
106
|
+
gaps.length === 0
|
|
107
|
+
? "(drawn) => ({ ...drawn, field: '<a value the refinement accepts>' })"
|
|
108
|
+
: renderDrawnInput(gaps.map((path, i) => sampleGapBinding(path, described[i] ?? path)));
|
|
70
109
|
return new ContractDriftError(
|
|
71
|
-
|
|
72
|
-
|
|
110
|
+
gaps.length === 0
|
|
111
|
+
? `${name}: its input schema refused every generated value, so the handler was never exercised`
|
|
112
|
+
: `${name}: its input schema refused every generated value — no value can be generated for ${described.join(', ')} — so the handler was never exercised`,
|
|
113
|
+
`defineExercise({ ${renderExerciseKey(name)}: { input: ${input} } }) # in ${EXERCISE_DECLARATIONS_FILE}`,
|
|
73
114
|
);
|
|
74
115
|
}
|
package/src/exercise.ts
CHANGED
|
@@ -13,11 +13,14 @@ import {
|
|
|
13
13
|
runExercise,
|
|
14
14
|
tryUseContext,
|
|
15
15
|
} from '@ultimat3/core';
|
|
16
|
-
import {
|
|
16
|
+
import type { ExerciseRequestOption } from '@ultimat3/http';
|
|
17
|
+
import { statusFor, withRequestAs, withScratchRateLimitStore } from '@ultimat3/http';
|
|
17
18
|
import { admitsAnonymous } from '@ultimat3/policy';
|
|
18
19
|
import { arbitrary } from '@ultimat3/schema/sample';
|
|
19
20
|
import type { AnyAction } from './action';
|
|
20
21
|
import type { ContractTest } from './contract-test';
|
|
22
|
+
import type { ContractExercise } from './exercise-declarations';
|
|
23
|
+
import { isExerciseDeclarations, resolveExercise } from './exercise-declarations';
|
|
21
24
|
import {
|
|
22
25
|
ContractDefectError,
|
|
23
26
|
ExerciseUndeclaredError,
|
|
@@ -32,8 +35,12 @@ export type { ExerciseReport };
|
|
|
32
35
|
|
|
33
36
|
/** Who the handler runs as, and what binds the parts a generated value cannot (a real row's id). */
|
|
34
37
|
export interface ExerciseActorOptions {
|
|
35
|
-
/**
|
|
36
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The caller each run is invoked as. `null` is the signed-out caller. Omitted, `x exercise`
|
|
40
|
+
* picks the first actor the policy allows (as it does with no entry); a contract test runs as
|
|
41
|
+
* the signed-out caller when the policy admits one, else `X_CONTRACT_EXERCISE_UNDECLARED`.
|
|
42
|
+
*/
|
|
43
|
+
readonly actor?: Actor | null | undefined;
|
|
37
44
|
/**
|
|
38
45
|
* `(drawn) => ({ ...drawn, postId })`: the generated value, with what must resolve bound in.
|
|
39
46
|
* May be `async`: `x exercise` awaits it inside the call's own request, so `useContext().actor`
|
|
@@ -42,6 +49,13 @@ export interface ExerciseActorOptions {
|
|
|
42
49
|
readonly input?: (drawn: DrawnInput) => unknown;
|
|
43
50
|
/** 1 to `EXERCISE_RUNS_MAX`; `EXERCISE_RUNS` when omitted. */
|
|
44
51
|
readonly runs?: number;
|
|
52
|
+
/**
|
|
53
|
+
* What the call's request carries in: the cookie a first step set (a pending sign-in, an OAuth
|
|
54
|
+
* state), a header. A function is awaited before EACH call, as that call's actor, so it can mint
|
|
55
|
+
* a session for whoever acts. Read by `x exercise` and the contract step alike: each run is then
|
|
56
|
+
* invoked inside that request (`@ultimat3/http`'s `withRequestAs`) instead of the bare context.
|
|
57
|
+
*/
|
|
58
|
+
readonly request?: ExerciseRequestOption;
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
/** No exercise, said out loud: the reason is listed in the assertion's name. */
|
|
@@ -75,6 +89,7 @@ export async function exerciseAction(
|
|
|
75
89
|
ctx?: Ctx,
|
|
76
90
|
): Promise<ExerciseReport> {
|
|
77
91
|
const name = actionName(target);
|
|
92
|
+
const actor = exerciseActor(target, options, name);
|
|
78
93
|
const base = ctx ?? tryUseContext() ?? ctxOf({});
|
|
79
94
|
// Counted in a scratch store: a spent bucket would make the file's next real call a 429.
|
|
80
95
|
const verdict = await withScratchRateLimitStore(() =>
|
|
@@ -88,8 +103,13 @@ export async function exerciseAction(
|
|
|
88
103
|
accepts: (input) => acceptedBy(target, input, name),
|
|
89
104
|
attempt: async (input): Promise<ExerciseAttempt> => {
|
|
90
105
|
const before = handlerReached(target);
|
|
106
|
+
// The actor is ON the declared request, so `invoke` takes none: its impersonation path
|
|
107
|
+
// would rebuild a plain child context and drop the request's headers.
|
|
108
|
+
const { request } = options;
|
|
91
109
|
const settled = await inRolledBackScope(() =>
|
|
92
|
-
|
|
110
|
+
request === undefined
|
|
111
|
+
? invoke(target, input, { ctx: base, actor, surface: 'http' })
|
|
112
|
+
: withRequestAs(name, actor, request, () => invoke(target, input, { surface: 'http' })),
|
|
93
113
|
);
|
|
94
114
|
const reached = handlerReached(target) > before;
|
|
95
115
|
return settled.ok ? { ok: true, reached } : { ok: false, reached, error: settled.error };
|
|
@@ -101,7 +121,7 @@ export async function exerciseAction(
|
|
|
101
121
|
case 'pass':
|
|
102
122
|
return verdict.report;
|
|
103
123
|
case 'drift':
|
|
104
|
-
throw everyDrawRefused(name);
|
|
124
|
+
throw everyDrawRefused(name, target.input);
|
|
105
125
|
case 'unreached':
|
|
106
126
|
throw new ExerciseUnreachedError(name, verdict.outcomes);
|
|
107
127
|
case 'defect':
|
|
@@ -116,6 +136,13 @@ export async function exerciseAction(
|
|
|
116
136
|
}
|
|
117
137
|
}
|
|
118
138
|
|
|
139
|
+
/** An entry's actor; none declared is the signed-out caller where the policy admits one. */
|
|
140
|
+
function exerciseActor(target: AnyAction, options: ExerciseActorOptions, name: string) {
|
|
141
|
+
if (options.actor !== undefined) return options.actor;
|
|
142
|
+
if (admitsAnonymous(target.policy)) return null;
|
|
143
|
+
throw new ExerciseUndeclaredError(name, 'no-actor');
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
async function acceptedBy(target: AnyAction, input: unknown, name: string): Promise<boolean> {
|
|
120
147
|
try {
|
|
121
148
|
await parseActionInput(target.input, input, name);
|
|
@@ -133,16 +160,17 @@ async function acceptedBy(target: AnyAction, input: unknown, name: string): Prom
|
|
|
133
160
|
*/
|
|
134
161
|
export function exerciseContract(
|
|
135
162
|
target: AnyAction,
|
|
136
|
-
|
|
163
|
+
declared: ContractExercise | undefined,
|
|
137
164
|
ctx: Ctx | undefined,
|
|
138
165
|
): ContractTest {
|
|
139
166
|
const name = actionName(target);
|
|
167
|
+
const exercise = resolveExercise(name, declared);
|
|
140
168
|
if (exercise !== undefined && 'off' in exercise) {
|
|
141
169
|
const reason = exercise.off.trim();
|
|
142
170
|
return reason.length === 0
|
|
143
171
|
? {
|
|
144
172
|
name: exerciseTestName(name),
|
|
145
|
-
run: () => Promise.reject(new ExerciseUndeclaredError(name,
|
|
173
|
+
run: () => Promise.reject(new ExerciseUndeclaredError(name, 'blank')),
|
|
146
174
|
}
|
|
147
175
|
: { name: `${name}: handler exercise off: ${reason}`, run: () => Promise.resolve() };
|
|
148
176
|
}
|
|
@@ -150,7 +178,11 @@ export function exerciseContract(
|
|
|
150
178
|
return {
|
|
151
179
|
name: exerciseTestName(name),
|
|
152
180
|
run: async () => {
|
|
153
|
-
if (options === undefined)
|
|
181
|
+
if (options === undefined) {
|
|
182
|
+
const why =
|
|
183
|
+
declared !== undefined && isExerciseDeclarations(declared) ? 'no-entry' : 'undeclared';
|
|
184
|
+
throw new ExerciseUndeclaredError(name, why);
|
|
185
|
+
}
|
|
154
186
|
await exerciseAction(target, options, ctx);
|
|
155
187
|
},
|
|
156
188
|
};
|
package/src/index.ts
CHANGED
|
@@ -135,6 +135,7 @@ export type {
|
|
|
135
135
|
ExerciseReport,
|
|
136
136
|
} from './exercise';
|
|
137
137
|
export { exerciseAction } from './exercise';
|
|
138
|
+
export type { ContractExercise, ExerciseDeclarations } from './exercise-declarations';
|
|
138
139
|
export type { OpenApiOperation } from './http';
|
|
139
140
|
export {
|
|
140
141
|
operationTagOf,
|