@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 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.6.0",
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.6.0",
50
- "@ultimat3/core": "30.6.0",
51
- "@ultimat3/db": "30.6.0",
52
- "@ultimat3/entity": "30.6.0",
53
- "@ultimat3/http": "30.6.0",
54
- "@ultimat3/policy": "30.6.0",
55
- "@ultimat3/schema": "30.6.0"
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
  }
@@ -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. `{ actor }` says who acts (required unless the policy admits an
48
- * anonymous caller); `{ off: '<reason>' }` skips it out loud.
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?: ExerciseOptions;
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 \`{ exercise: { actor } }\` — who its handler runs as for the
180
- // generated-input runs — or \`{ exercise: { off: '<reason>' } }\`.
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
+ }
@@ -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 { isUltimateError, renderFixShellArg, UltimateError } from '@ultimat3/core';
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: `${call}.contract({ exercise: { actor } }) with an actor the policy permits — or bind a real row: { exercise: { actor, input: (drawn) => ({ ...drawn, id }) } }`,
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
- /** A guarded handler with no `exercise` said, or an opt-out with no reason. */
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, blankReason: boolean) {
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: 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>' } })`,
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 — a provider refinement the IR does not carry.
67
- * `X_CONTRACT_DRIFT`, the code the policy assertion already raises for the same gap.
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
- `${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>' }) } })`,
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 { statusFor, withScratchRateLimitStore } from '@ultimat3/http';
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
- /** The caller each run is invoked as. `null` is the signed-out caller. */
36
- readonly actor: Actor | null;
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
- invoke(target, input, { ctx: base, actor: options.actor, surface: 'http' }),
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
- exercise: ExerciseOptions | undefined,
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, true)),
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) throw new ExerciseUndeclaredError(name, false);
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,