@ultimat3/action 30.5.0 → 30.7.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 +33 -0
- package/src/exercise-errors.ts +54 -13
- package/src/exercise.ts +26 -7
- 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.7.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.7.0",
|
|
50
|
+
"@ultimat3/core": "30.7.0",
|
|
51
|
+
"@ultimat3/db": "30.7.0",
|
|
52
|
+
"@ultimat3/entity": "30.7.0",
|
|
53
|
+
"@ultimat3/http": "30.7.0",
|
|
54
|
+
"@ultimat3/policy": "30.7.0",
|
|
55
|
+
"@ultimat3/schema": "30.7.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,33 @@
|
|
|
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
|
+
/** The entry `name` reads: the declarations' own, or the inline one. `undefined` when none. */
|
|
27
|
+
export function resolveExercise(
|
|
28
|
+
name: string,
|
|
29
|
+
exercise: ContractExercise | undefined,
|
|
30
|
+
): ExerciseOptions | undefined {
|
|
31
|
+
if (exercise === undefined || !isExerciseDeclarations(exercise)) return exercise;
|
|
32
|
+
return exercise.entries.get(name);
|
|
33
|
+
}
|
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
|
@@ -18,6 +18,8 @@ import { admitsAnonymous } from '@ultimat3/policy';
|
|
|
18
18
|
import { arbitrary } from '@ultimat3/schema/sample';
|
|
19
19
|
import type { AnyAction } from './action';
|
|
20
20
|
import type { ContractTest } from './contract-test';
|
|
21
|
+
import type { ContractExercise } from './exercise-declarations';
|
|
22
|
+
import { isExerciseDeclarations, resolveExercise } from './exercise-declarations';
|
|
21
23
|
import {
|
|
22
24
|
ContractDefectError,
|
|
23
25
|
ExerciseUndeclaredError,
|
|
@@ -32,8 +34,12 @@ export type { ExerciseReport };
|
|
|
32
34
|
|
|
33
35
|
/** Who the handler runs as, and what binds the parts a generated value cannot (a real row's id). */
|
|
34
36
|
export interface ExerciseActorOptions {
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The caller each run is invoked as. `null` is the signed-out caller. Omitted, `x exercise`
|
|
39
|
+
* picks the first actor the policy allows (as it does with no entry); a contract test runs as
|
|
40
|
+
* the signed-out caller when the policy admits one, else `X_CONTRACT_EXERCISE_UNDECLARED`.
|
|
41
|
+
*/
|
|
42
|
+
readonly actor?: Actor | null | undefined;
|
|
37
43
|
/**
|
|
38
44
|
* `(drawn) => ({ ...drawn, postId })`: the generated value, with what must resolve bound in.
|
|
39
45
|
* May be `async`: `x exercise` awaits it inside the call's own request, so `useContext().actor`
|
|
@@ -75,6 +81,7 @@ export async function exerciseAction(
|
|
|
75
81
|
ctx?: Ctx,
|
|
76
82
|
): Promise<ExerciseReport> {
|
|
77
83
|
const name = actionName(target);
|
|
84
|
+
const actor = exerciseActor(target, options, name);
|
|
78
85
|
const base = ctx ?? tryUseContext() ?? ctxOf({});
|
|
79
86
|
// Counted in a scratch store: a spent bucket would make the file's next real call a 429.
|
|
80
87
|
const verdict = await withScratchRateLimitStore(() =>
|
|
@@ -89,7 +96,7 @@ export async function exerciseAction(
|
|
|
89
96
|
attempt: async (input): Promise<ExerciseAttempt> => {
|
|
90
97
|
const before = handlerReached(target);
|
|
91
98
|
const settled = await inRolledBackScope(() =>
|
|
92
|
-
invoke(target, input, { ctx: base, actor
|
|
99
|
+
invoke(target, input, { ctx: base, actor, surface: 'http' }),
|
|
93
100
|
);
|
|
94
101
|
const reached = handlerReached(target) > before;
|
|
95
102
|
return settled.ok ? { ok: true, reached } : { ok: false, reached, error: settled.error };
|
|
@@ -101,7 +108,7 @@ export async function exerciseAction(
|
|
|
101
108
|
case 'pass':
|
|
102
109
|
return verdict.report;
|
|
103
110
|
case 'drift':
|
|
104
|
-
throw everyDrawRefused(name);
|
|
111
|
+
throw everyDrawRefused(name, target.input);
|
|
105
112
|
case 'unreached':
|
|
106
113
|
throw new ExerciseUnreachedError(name, verdict.outcomes);
|
|
107
114
|
case 'defect':
|
|
@@ -116,6 +123,13 @@ export async function exerciseAction(
|
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
/** An entry's actor; none declared is the signed-out caller where the policy admits one. */
|
|
127
|
+
function exerciseActor(target: AnyAction, options: ExerciseActorOptions, name: string) {
|
|
128
|
+
if (options.actor !== undefined) return options.actor;
|
|
129
|
+
if (admitsAnonymous(target.policy)) return null;
|
|
130
|
+
throw new ExerciseUndeclaredError(name, 'no-actor');
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
async function acceptedBy(target: AnyAction, input: unknown, name: string): Promise<boolean> {
|
|
120
134
|
try {
|
|
121
135
|
await parseActionInput(target.input, input, name);
|
|
@@ -133,16 +147,17 @@ async function acceptedBy(target: AnyAction, input: unknown, name: string): Prom
|
|
|
133
147
|
*/
|
|
134
148
|
export function exerciseContract(
|
|
135
149
|
target: AnyAction,
|
|
136
|
-
|
|
150
|
+
declared: ContractExercise | undefined,
|
|
137
151
|
ctx: Ctx | undefined,
|
|
138
152
|
): ContractTest {
|
|
139
153
|
const name = actionName(target);
|
|
154
|
+
const exercise = resolveExercise(name, declared);
|
|
140
155
|
if (exercise !== undefined && 'off' in exercise) {
|
|
141
156
|
const reason = exercise.off.trim();
|
|
142
157
|
return reason.length === 0
|
|
143
158
|
? {
|
|
144
159
|
name: exerciseTestName(name),
|
|
145
|
-
run: () => Promise.reject(new ExerciseUndeclaredError(name,
|
|
160
|
+
run: () => Promise.reject(new ExerciseUndeclaredError(name, 'blank')),
|
|
146
161
|
}
|
|
147
162
|
: { name: `${name}: handler exercise off: ${reason}`, run: () => Promise.resolve() };
|
|
148
163
|
}
|
|
@@ -150,7 +165,11 @@ export function exerciseContract(
|
|
|
150
165
|
return {
|
|
151
166
|
name: exerciseTestName(name),
|
|
152
167
|
run: async () => {
|
|
153
|
-
if (options === undefined)
|
|
168
|
+
if (options === undefined) {
|
|
169
|
+
const why =
|
|
170
|
+
declared !== undefined && isExerciseDeclarations(declared) ? 'no-entry' : 'undeclared';
|
|
171
|
+
throw new ExerciseUndeclaredError(name, why);
|
|
172
|
+
}
|
|
154
173
|
await exerciseAction(target, options, ctx);
|
|
155
174
|
},
|
|
156
175
|
};
|
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,
|