@ultimat3/action 30.6.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 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.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.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.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
  }
@@ -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,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
+ }
@@ -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
@@ -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
- /** The caller each run is invoked as. `null` is the signed-out caller. */
36
- readonly actor: Actor | null;
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: options.actor, surface: 'http' }),
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
- exercise: ExerciseOptions | undefined,
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, true)),
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) throw new ExerciseUndeclaredError(name, false);
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,