@ultimat3/action 1.0.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/LICENSE +21 -0
- package/README.md +210 -0
- package/package.json +39 -0
- package/src/action.ts +295 -0
- package/src/client.ts +150 -0
- package/src/contract-test.ts +116 -0
- package/src/define-api.ts +138 -0
- package/src/errors.ts +195 -0
- package/src/facade.ts +42 -0
- package/src/http.ts +149 -0
- package/src/idempotency.ts +122 -0
- package/src/index.ts +110 -0
- package/src/invoke.ts +120 -0
- package/src/job-handle.ts +38 -0
- package/src/json-schema.ts +39 -0
- package/src/mcp-tool.ts +73 -0
- package/src/mutator.ts +193 -0
- package/src/naming.ts +99 -0
- package/src/openapi.ts +85 -0
- package/src/policy-gate.ts +81 -0
- package/src/registry.ts +70 -0
- package/src/stable.ts +68 -0
- package/src/tags.ts +17 -0
- package/src/validate.ts +38 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection 3: the typed RPC client. Types come from the action map, paths from
|
|
3
|
+
* the same pure derivation the server uses, so a renamed or mistyped action is a
|
|
4
|
+
* compile error in a Solid component — not a 404 at runtime.
|
|
5
|
+
*/
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { Action } from './action';
|
|
9
|
+
import { ContractDriftError, RpcFailedError } from './errors';
|
|
10
|
+
import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './http';
|
|
11
|
+
import { derivePath } from './naming';
|
|
12
|
+
import { isJsonObject } from './stable';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loose constraint on purpose: a map of concrete `Action<In, Out>` values must be
|
|
16
|
+
* assignable to it, while `Client<T>` still recovers each action's exact schemas.
|
|
17
|
+
*/
|
|
18
|
+
export interface ActionLike {
|
|
19
|
+
readonly kind: 'action';
|
|
20
|
+
readonly name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ActionMap = Record<string, ActionLike>;
|
|
24
|
+
|
|
25
|
+
export interface CallOptions {
|
|
26
|
+
readonly idempotencyKey?: string;
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `api.publishPost({ postId })` with both sides of the schema inferred. */
|
|
31
|
+
export type Client<TActions extends ActionMap> = {
|
|
32
|
+
readonly [K in keyof TActions]: TActions[K] extends Action<infer TIn, infer TOut>
|
|
33
|
+
? ClientMethod<TIn, TOut>
|
|
34
|
+
: never;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ClientMethod<TIn extends StandardSchemaV1, TOut extends StandardSchemaV1> = (
|
|
38
|
+
input: InferInput<TIn>,
|
|
39
|
+
options?: CallOptions,
|
|
40
|
+
) => Promise<InferOutput<TOut>>;
|
|
41
|
+
|
|
42
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
43
|
+
|
|
44
|
+
export interface ClientOptions {
|
|
45
|
+
readonly baseUrl: string;
|
|
46
|
+
readonly fetch?: FetchLike;
|
|
47
|
+
/** Sent on every call; a differing server build id raises X_CONTRACT_DRIFT. */
|
|
48
|
+
readonly buildId?: string;
|
|
49
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The typed client for a whole action map: `rpc<Api['actions']>({ baseUrl })`. One blessed
|
|
54
|
+
* name — there is no `createClient` twin to choose between.
|
|
55
|
+
*/
|
|
56
|
+
export function rpc<TActions extends ActionMap>(options: ClientOptions): Client<TActions> {
|
|
57
|
+
const proxy = new Proxy(
|
|
58
|
+
{},
|
|
59
|
+
{
|
|
60
|
+
get(_target, property: string | symbol) {
|
|
61
|
+
if (typeof property !== 'string') return undefined;
|
|
62
|
+
return clientMethodFor(property, options);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
// The proxy realizes the mapped type structurally; TS cannot check a Proxy.
|
|
67
|
+
return proxy as Client<TActions>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One action's method — what `rpc` proxies to and what `action.client()` returns.
|
|
72
|
+
* Both spellings are the same call, so a per-action client can never drift from the
|
|
73
|
+
* map-wide one.
|
|
74
|
+
*/
|
|
75
|
+
export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
76
|
+
name: string,
|
|
77
|
+
options: ClientOptions,
|
|
78
|
+
): ClientMethod<TInput, TOutput> {
|
|
79
|
+
const doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
|
|
80
|
+
const base = options.baseUrl.replace(/\/+$/, '');
|
|
81
|
+
// Erased at the wire seam; the response type is this action's by construction.
|
|
82
|
+
return (input, callOptions = {}) =>
|
|
83
|
+
call(doFetch, base, options, name, input, callOptions) as Promise<InferOutput<TOutput>>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function call(
|
|
87
|
+
doFetch: FetchLike,
|
|
88
|
+
base: string,
|
|
89
|
+
options: ClientOptions,
|
|
90
|
+
name: string,
|
|
91
|
+
input: unknown,
|
|
92
|
+
callOptions: CallOptions,
|
|
93
|
+
): Promise<unknown> {
|
|
94
|
+
const headers: Record<string, string> = {
|
|
95
|
+
'content-type': 'application/json',
|
|
96
|
+
...options.headers,
|
|
97
|
+
};
|
|
98
|
+
if (options.buildId !== undefined) headers[BUILD_ID_HEADER] = options.buildId;
|
|
99
|
+
if (callOptions.idempotencyKey !== undefined) {
|
|
100
|
+
headers[IDEMPOTENCY_HEADER] = callOptions.idempotencyKey;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const init: RequestInit = {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers,
|
|
106
|
+
body: JSON.stringify(input ?? {}),
|
|
107
|
+
...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
|
|
108
|
+
};
|
|
109
|
+
const response = await doFetch(`${base}${derivePath(name).path}`, init);
|
|
110
|
+
assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
|
|
111
|
+
if (!response.ok) throw await toUltimateError(response, name);
|
|
112
|
+
if (response.status === 204) return undefined;
|
|
113
|
+
const body: unknown = await response.json();
|
|
114
|
+
return body;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Version skew is a contract problem, not a network problem: the client holds
|
|
119
|
+
* types from build A while build B answers. Fail loudly so the shell reloads.
|
|
120
|
+
*/
|
|
121
|
+
function assertSameBuild(
|
|
122
|
+
clientBuild: string | undefined,
|
|
123
|
+
serverBuild: string | null,
|
|
124
|
+
name: string,
|
|
125
|
+
): void {
|
|
126
|
+
if (clientBuild === undefined || serverBuild === null) return;
|
|
127
|
+
if (clientBuild === serverBuild) return;
|
|
128
|
+
throw new ContractDriftError(
|
|
129
|
+
`client build ${clientBuild} called ${name} on server build ${serverBuild}`,
|
|
130
|
+
'reload the page to pick up the new client bundle',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `application/problem+json` back into the same error the server threw. */
|
|
135
|
+
async function toUltimateError(response: Response, name: string): Promise<UltimateError> {
|
|
136
|
+
const body: unknown = await response.json().catch(() => null);
|
|
137
|
+
if (isJsonObject(body) && typeof body['code'] === 'string') {
|
|
138
|
+
return new UltimateError({
|
|
139
|
+
code: body['code'],
|
|
140
|
+
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${response.status}`),
|
|
141
|
+
fix: stringOr(body['fix'], `x actions describe ${name} --json`),
|
|
142
|
+
docs: stringOr(body['docs'], `https://ultimate.dev/errors/${body['code']}`),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return new RpcFailedError(name, response.status);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stringOr(value: unknown, fallback: string): string {
|
|
149
|
+
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
150
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection 6: the tests. `x g action` emits these three assertions for every
|
|
3
|
+
* new action, so an action that skips validation, skips authz, or never reaches
|
|
4
|
+
* the spec fails CI on the day it is written.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { createContext, isUltimateError } from '@ultimat3/core';
|
|
9
|
+
import type { AnyAction } from './action';
|
|
10
|
+
import { ContractDriftError } from './errors';
|
|
11
|
+
import { actionName, invoke } from './invoke';
|
|
12
|
+
import { derivePath } from './naming';
|
|
13
|
+
import { buildOpenApi } from './openapi';
|
|
14
|
+
|
|
15
|
+
export interface ContractTest {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
run(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ContractTestOptions {
|
|
21
|
+
/** Value the input schema must reject. `null` fails every object schema. */
|
|
22
|
+
readonly garbage?: unknown;
|
|
23
|
+
readonly ctx?: Ctx;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A context whose actor is core's anonymous actor — what a signed-out caller has. */
|
|
27
|
+
export function anonymousCtx(): Ctx {
|
|
28
|
+
return createContext({});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function contractTestsFor(
|
|
32
|
+
target: AnyAction,
|
|
33
|
+
options: ContractTestOptions = {},
|
|
34
|
+
): readonly ContractTest[] {
|
|
35
|
+
const name = actionName(target);
|
|
36
|
+
const garbage = 'garbage' in options ? options.garbage : null;
|
|
37
|
+
const ctx = options.ctx ?? anonymousCtx();
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
name: `${name}: input schema rejects garbage`,
|
|
42
|
+
run: async () => {
|
|
43
|
+
await expectThrow(
|
|
44
|
+
() => invoke(target, garbage, { ctx, surface: 'http' }),
|
|
45
|
+
'X_INPUT_INVALID',
|
|
46
|
+
`${name} accepted ${JSON.stringify(garbage) ?? 'undefined'} as input`,
|
|
47
|
+
`tighten \`input:\` in the ${name} definition`,
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: `${name}: policy denies an anonymous actor`,
|
|
53
|
+
run: async () => {
|
|
54
|
+
await expectThrow(
|
|
55
|
+
() => invoke(target, emptyInput(), { ctx, surface: 'http' }),
|
|
56
|
+
null,
|
|
57
|
+
`${name} ran for an actor of null`,
|
|
58
|
+
`make the ${name} policy require an authenticated actor`,
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: `${name}: OpenAPI document contains its operation`,
|
|
64
|
+
run: async () => {
|
|
65
|
+
const document = buildOpenApi({ actions: [target] });
|
|
66
|
+
const path = derivePath(name).path;
|
|
67
|
+
if (document.paths[path] === undefined) {
|
|
68
|
+
throw new ContractDriftError(
|
|
69
|
+
`OpenAPI document has no entry for ${path}`,
|
|
70
|
+
'x verify --contract',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The generated policy test. Emitted as source (not executed here) because the
|
|
80
|
+
* app owns which actors it considers privileged.
|
|
81
|
+
*/
|
|
82
|
+
export function policyTestStubFor(target: AnyAction): string {
|
|
83
|
+
const name = actionName(target);
|
|
84
|
+
return `import { contractTestsFor } from '@ultimat3/action';
|
|
85
|
+
import { ${name} } from './actions';
|
|
86
|
+
|
|
87
|
+
// Fill in: arrange a foreign actor, expect the policy to deny.
|
|
88
|
+
// The contract tests below are framework-generated and always included.
|
|
89
|
+
for (const contract of contractTestsFor(${name})) {
|
|
90
|
+
test(contract.name, async () => {
|
|
91
|
+
await contract.run();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An input the schema may still reject — only the policy assertion depends on it. */
|
|
98
|
+
function emptyInput(): unknown {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function expectThrow(
|
|
103
|
+
run: () => Promise<unknown>,
|
|
104
|
+
code: string | null,
|
|
105
|
+
cause: string,
|
|
106
|
+
fix: string,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
try {
|
|
109
|
+
await run();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!isUltimateError(error)) throw error;
|
|
112
|
+
if (code === null || error.code === code) return;
|
|
113
|
+
throw new ContractDriftError(`${cause} (got ${error.code}, expected ${code})`, fix);
|
|
114
|
+
}
|
|
115
|
+
throw new ContractDriftError(cause, fix);
|
|
116
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API registry: one call that turns an app's primitive modules into the registered,
|
|
3
|
+
* projectable API surface. `apps/web/api/index.ts` calls it and nothing else, so importing
|
|
4
|
+
* that module IS the boot — and the value it returns is also the type the RPC client is
|
|
5
|
+
* shaped from, which is why there is no second list of names to keep in step.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type PrimitiveKind, primitiveRegistrar, type RegisteredPrimitive } from '@ultimat3/core';
|
|
9
|
+
import { registerActions } from './registry';
|
|
10
|
+
|
|
11
|
+
/** A module namespace: `import * as postActions from './actions'`. */
|
|
12
|
+
export type ApiModule = Readonly<Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
/** One module, or several — a feature per entry, never a list of name strings. */
|
|
15
|
+
export type ApiModules = ApiModule | readonly ApiModule[];
|
|
16
|
+
|
|
17
|
+
export interface ApiDef {
|
|
18
|
+
readonly actions?: ApiModules;
|
|
19
|
+
/** A mutator IS an action; it registers as one, on the same authz path. */
|
|
20
|
+
readonly mutators?: ApiModules;
|
|
21
|
+
readonly queries?: ApiModules;
|
|
22
|
+
/** `llm()` returns an action, so a model call registers exactly like every other one. */
|
|
23
|
+
readonly llm?: ApiModules;
|
|
24
|
+
/** The export name becomes the durable queue key — a job row names the handle, not a counter. */
|
|
25
|
+
readonly jobs?: ApiModules;
|
|
26
|
+
/** A task only enqueues jobs; handing it over here is what names its cron after its export. */
|
|
27
|
+
readonly tasks?: ApiModules;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Get<TDef, TKey extends string> = TKey extends keyof TDef ? TDef[TKey] : undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The exports a registrar would take, and only those. A feature module legitimately exports its
|
|
34
|
+
* own helpers next to its primitives; carrying one into `Api['actions']` would offer the client a
|
|
35
|
+
* method the server never registered and no surface can project.
|
|
36
|
+
*/
|
|
37
|
+
type Registered<TModule, TKind extends string> = {
|
|
38
|
+
readonly [K in keyof TModule as TModule[K] extends { readonly kind: TKind }
|
|
39
|
+
? K
|
|
40
|
+
: never]: TModule[K];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Intersect a tuple of module namespaces. `{ createPost } & { inviteMember }` is the map the
|
|
45
|
+
* typed client indexes, so `rpc<Api['actions']>()` knows every action without a codegen step.
|
|
46
|
+
*/
|
|
47
|
+
type Merge<TModules, TKind extends string> = [TModules] extends [undefined]
|
|
48
|
+
? EmptyModule
|
|
49
|
+
: TModules extends readonly [infer THead, ...infer TRest]
|
|
50
|
+
? Registered<THead, TKind> & Merge<TRest, TKind>
|
|
51
|
+
: TModules extends readonly []
|
|
52
|
+
? EmptyModule
|
|
53
|
+
: Registered<TModules, TKind>;
|
|
54
|
+
|
|
55
|
+
type EmptyModule = Readonly<Record<never, never>>;
|
|
56
|
+
|
|
57
|
+
/** What `defineApi` returns: the registered primitives, merged and keyed by export name. */
|
|
58
|
+
export interface Api<TDef extends ApiDef> {
|
|
59
|
+
readonly actions: Merge<Get<TDef, 'actions'>, 'action'> &
|
|
60
|
+
Merge<Get<TDef, 'mutators'>, 'action'> &
|
|
61
|
+
Merge<Get<TDef, 'llm'>, 'action'>;
|
|
62
|
+
readonly queries: Merge<Get<TDef, 'queries'>, 'query'>;
|
|
63
|
+
readonly jobs: Merge<Get<TDef, 'jobs'>, 'job'>;
|
|
64
|
+
readonly tasks: Merge<Get<TDef, 'tasks'>, 'task'>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Register every primitive the app exposes, in one call.
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* export const api = defineApi({
|
|
72
|
+
* actions: [postActions, orgActions],
|
|
73
|
+
* mutators: [postMutators],
|
|
74
|
+
* queries: [postQueries],
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* Names come from export names, so two features exporting one name collide here with
|
|
79
|
+
* `X_ACTION_DUPLICATE` instead of merging silently. Actions, mutators and `llm()` calls all
|
|
80
|
+
* land in the action registry — they are the same primitive. Queries, jobs and tasks reach
|
|
81
|
+
* their own registries through core's registrar table, because `@ultimat3/query` and
|
|
82
|
+
* `@ultimat3/jobs` are on this tier and importing either sideways is a build error.
|
|
83
|
+
*/
|
|
84
|
+
export function defineApi<const TDef extends ApiDef>(def: TDef): Api<TDef> {
|
|
85
|
+
const actionModules = [
|
|
86
|
+
...moduleList(def.actions),
|
|
87
|
+
...moduleList(def.mutators),
|
|
88
|
+
...moduleList(def.llm),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const actions: RegisteredPrimitive[] = [];
|
|
92
|
+
for (const module of actionModules) actions.push(...registerActions(module));
|
|
93
|
+
|
|
94
|
+
const queries = registerThrough('query', moduleList(def.queries));
|
|
95
|
+
// Jobs before tasks: a task's descriptor lists the jobs it enqueues by name, so registering
|
|
96
|
+
// the other way round would read the queue keys one boot step before they were assigned.
|
|
97
|
+
const jobs = registerThrough('job', moduleList(def.jobs));
|
|
98
|
+
const tasks = registerThrough('task', moduleList(def.tasks));
|
|
99
|
+
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
actions: byRegisteredName(actions),
|
|
102
|
+
queries: byRegisteredName(queries),
|
|
103
|
+
jobs: byRegisteredName(jobs),
|
|
104
|
+
tasks: byRegisteredName(tasks),
|
|
105
|
+
}) as Api<TDef>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hand `modules` to the package that owns `kind`, through core's registrar table. Resolved only
|
|
110
|
+
* when there is something to register: a missing registrar with modules in hand must be an
|
|
111
|
+
* error, never a silent skip that drops every primitive of that kind.
|
|
112
|
+
*/
|
|
113
|
+
function registerThrough(
|
|
114
|
+
kind: PrimitiveKind,
|
|
115
|
+
modules: readonly ApiModule[],
|
|
116
|
+
): readonly RegisteredPrimitive[] {
|
|
117
|
+
if (modules.length === 0) return [];
|
|
118
|
+
const register = primitiveRegistrar(kind);
|
|
119
|
+
const registered: RegisteredPrimitive[] = [];
|
|
120
|
+
for (const module of modules) registered.push(...register(module));
|
|
121
|
+
return registered;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function moduleList(modules: ApiModules | undefined): readonly ApiModule[] {
|
|
125
|
+
if (modules === undefined) return [];
|
|
126
|
+
return Array.isArray(modules) ? modules : [modules as ApiModule];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Keyed by the name registration stamped, read back off the registrar's own results — never off
|
|
131
|
+
* the module's exports. Copying every export would seat a feature's helper in `api.actions` under
|
|
132
|
+
* a name no surface serves, and the last module exporting that name would win in silence.
|
|
133
|
+
*/
|
|
134
|
+
function byRegisteredName(primitives: readonly RegisteredPrimitive[]): ApiModule {
|
|
135
|
+
const map: Record<string, RegisteredPrimitive> = {};
|
|
136
|
+
for (const primitive of primitives) map[primitive.name] = primitive;
|
|
137
|
+
return Object.freeze(map);
|
|
138
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure @ultimat3/action can produce, one subclass per stable code so
|
|
3
|
+
* callers `instanceof` a specific failure instead of string-matching a message.
|
|
4
|
+
*/
|
|
5
|
+
import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
7
|
+
|
|
8
|
+
const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
12
|
+
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
|
|
13
|
+
* enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
|
|
14
|
+
* Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
|
|
15
|
+
*/
|
|
16
|
+
const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
17
|
+
X_ACTION_DUPLICATE: 'two actions are registered under one name',
|
|
18
|
+
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
|
|
19
|
+
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
|
|
20
|
+
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
|
|
21
|
+
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
|
|
22
|
+
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
|
|
23
|
+
X_INPUT_INVALID: 'input failed schema validation',
|
|
24
|
+
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
25
|
+
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
29
|
+
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
30
|
+
registerErrorCodes(
|
|
31
|
+
Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Thrown when a projection needs a name the action does not have yet. */
|
|
35
|
+
export class ActionUnregisteredError extends UltimateError {
|
|
36
|
+
constructor() {
|
|
37
|
+
super({
|
|
38
|
+
code: 'X_ACTION_UNREGISTERED',
|
|
39
|
+
cause: 'an action was projected before it was registered, so it has no name',
|
|
40
|
+
fix: "call registerActions(await import('./actions')) at boot, before mounting routes",
|
|
41
|
+
docs: docs('X_ACTION_UNREGISTERED'),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown when a projection is handed something that never came out of `action()`.
|
|
48
|
+
* The declaration is private to `invoke.ts`, so an object that merely looks like
|
|
49
|
+
* an action has no handler to run and no policy to evaluate — refusing it here is
|
|
50
|
+
* how "there is one execution path" stays true at runtime, not just in the types.
|
|
51
|
+
*/
|
|
52
|
+
export class ActionForeignError extends UltimateError {
|
|
53
|
+
constructor(name: string) {
|
|
54
|
+
super({
|
|
55
|
+
code: 'X_ACTION_FOREIGN',
|
|
56
|
+
cause: `"${name === '' ? 'anonymous' : name}" is not an action built by action()`,
|
|
57
|
+
fix: "declare it as `export const name = action({ input, output, policy, handle })` from '@ultimat3/action'",
|
|
58
|
+
docs: docs('X_ACTION_FOREIGN'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function denialCode(denial: SurfaceDenial): string {
|
|
64
|
+
switch (denial.surface) {
|
|
65
|
+
case 'http':
|
|
66
|
+
return denial.problem.code;
|
|
67
|
+
case 'live':
|
|
68
|
+
case 'job':
|
|
69
|
+
return denial.code;
|
|
70
|
+
case 'mcp':
|
|
71
|
+
return denial.content[0]?.text.split(':')[0] ?? 'X_FORBIDDEN';
|
|
72
|
+
default:
|
|
73
|
+
return assertNever(denial);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function denialReason(denial: SurfaceDenial): string {
|
|
78
|
+
switch (denial.surface) {
|
|
79
|
+
case 'http':
|
|
80
|
+
return denial.problem.detail;
|
|
81
|
+
case 'live':
|
|
82
|
+
case 'job':
|
|
83
|
+
return denial.reason;
|
|
84
|
+
case 'mcp':
|
|
85
|
+
return denial.content[0]?.text ?? 'denied';
|
|
86
|
+
default:
|
|
87
|
+
return assertNever(denial);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An authz denial, thrown by `guard()`. The code and reason come from the policy
|
|
93
|
+
* decision — this package never invents an authz code — and the surface-shaped
|
|
94
|
+
* denial rides along for projections that render it themselves.
|
|
95
|
+
*/
|
|
96
|
+
export class ActionDeniedError extends UltimateError {
|
|
97
|
+
readonly denial: SurfaceDenial;
|
|
98
|
+
|
|
99
|
+
constructor(action: string, denial: SurfaceDenial) {
|
|
100
|
+
const code = denialCode(denial);
|
|
101
|
+
super({
|
|
102
|
+
code,
|
|
103
|
+
cause: `${action} denied: ${denialReason(denial)}`,
|
|
104
|
+
fix: `x policy explain ${action} --json # shows which clause decided and why`,
|
|
105
|
+
docs: docs(code),
|
|
106
|
+
});
|
|
107
|
+
this.denial = denial;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class ActionDuplicateError extends UltimateError {
|
|
112
|
+
constructor(name: string) {
|
|
113
|
+
super({
|
|
114
|
+
code: 'X_ACTION_DUPLICATE',
|
|
115
|
+
cause: `two actions are registered under the name "${name}"`,
|
|
116
|
+
fix: `rename one export — action names are globally unique: x actions list --json`,
|
|
117
|
+
docs: docs('X_ACTION_DUPLICATE'),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class ActionPolicyMissingError extends UltimateError {
|
|
123
|
+
constructor(name: string) {
|
|
124
|
+
super({
|
|
125
|
+
code: 'X_ACTION_POLICY_MISSING',
|
|
126
|
+
cause: `action "${name}" was registered without a policy`,
|
|
127
|
+
fix: `add \`policy: can('${name}')\` to the action definition in the file that exports it`,
|
|
128
|
+
docs: docs('X_ACTION_POLICY_MISSING'),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class InputInvalidError extends UltimateError {
|
|
134
|
+
constructor(name: string, detail: string) {
|
|
135
|
+
super({
|
|
136
|
+
code: 'X_INPUT_INVALID',
|
|
137
|
+
cause: `input for action "${name}" failed validation: ${detail}`,
|
|
138
|
+
fix: `x actions describe ${name} --json # prints the expected input schema`,
|
|
139
|
+
docs: docs('X_INPUT_INVALID'),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The mirror of `InputInvalidError`, and a server bug rather than a caller's:
|
|
146
|
+
* the handler produced something its own `output` schema rejects, so the client,
|
|
147
|
+
* the OpenAPI response and the MCP `outputSchema` would all have been lied to.
|
|
148
|
+
*/
|
|
149
|
+
export class OutputInvalidError extends UltimateError {
|
|
150
|
+
constructor(name: string, detail: string) {
|
|
151
|
+
super({
|
|
152
|
+
code: 'X_OUTPUT_INVALID',
|
|
153
|
+
cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
|
|
154
|
+
fix: `x actions describe ${name} --json # compare the handler's return against \`output:\``,
|
|
155
|
+
docs: docs('X_OUTPUT_INVALID'),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type IdempotencyConflictReason = 'payload-mismatch' | 'in-flight';
|
|
161
|
+
|
|
162
|
+
export class IdempotencyConflictError extends UltimateError {
|
|
163
|
+
constructor(key: string, reason: IdempotencyConflictReason) {
|
|
164
|
+
super({
|
|
165
|
+
code: 'X_IDEMPOTENCY_CONFLICT',
|
|
166
|
+
cause:
|
|
167
|
+
reason === 'payload-mismatch'
|
|
168
|
+
? `idempotency key "${key}" was already used with a different payload`
|
|
169
|
+
: `idempotency key "${key}" is still in flight from an earlier request`,
|
|
170
|
+
fix:
|
|
171
|
+
reason === 'payload-mismatch'
|
|
172
|
+
? 'send a fresh Idempotency-Key header for a different payload'
|
|
173
|
+
: 'retry the same Idempotency-Key after the first request settles',
|
|
174
|
+
docs: docs('X_IDEMPOTENCY_CONFLICT'),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The client got a non-`problem+json` failure — a proxy, not our server, answered. */
|
|
180
|
+
export class RpcFailedError extends UltimateError {
|
|
181
|
+
constructor(name: string, status: number) {
|
|
182
|
+
super({
|
|
183
|
+
code: 'X_RPC_FAILED',
|
|
184
|
+
cause: `${name} returned HTTP ${status} without a problem+json body`,
|
|
185
|
+
fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
|
|
186
|
+
docs: docs('X_RPC_FAILED'),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class ContractDriftError extends UltimateError {
|
|
192
|
+
constructor(cause: string, fix: string) {
|
|
193
|
+
super({ code: 'X_CONTRACT_DRIFT', cause, fix, docs: docs('X_CONTRACT_DRIFT') });
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/facade.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fluent surface: every projection reachable as a method on the action itself,
|
|
3
|
+
* `publishPost.tool()` rather than `toMcpTool(publishPost)`, and every declared
|
|
4
|
+
* field lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
+
* functions stay exported for the framework's own call sites — this file only
|
|
6
|
+
* binds them to the action, it never re-implements one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
|
+
import type { Action, ActionDef, ActionFacade } from './action';
|
|
11
|
+
import { clientMethodFor } from './client';
|
|
12
|
+
import { contractTestsFor } from './contract-test';
|
|
13
|
+
import { toOpenApiOperation } from './http';
|
|
14
|
+
import { actionName, invoke } from './invoke';
|
|
15
|
+
import { toJobHandle } from './job-handle';
|
|
16
|
+
import { toMcpTool } from './mcp-tool';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `self` is a thunk on purpose: the façade is attached while the action is still
|
|
20
|
+
* being assembled, so every method resolves the action when it is called, not now.
|
|
21
|
+
*/
|
|
22
|
+
export function facadeFor<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
23
|
+
def: ActionDef<TInput, TOutput>,
|
|
24
|
+
self: () => Action<TInput, TOutput>,
|
|
25
|
+
): ActionFacade<TInput, TOutput> {
|
|
26
|
+
return {
|
|
27
|
+
input: def.input,
|
|
28
|
+
output: def.output,
|
|
29
|
+
policy: def.policy,
|
|
30
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
31
|
+
// `.as()` is impersonation on the one execution path: `invoke` keeps the
|
|
32
|
+
// surrounding context whole and swaps only the actor. Erased at the seam;
|
|
33
|
+
// the output type is this action's by construction.
|
|
34
|
+
as: (actor, input, options) =>
|
|
35
|
+
invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
|
|
36
|
+
tool: () => toMcpTool(self()),
|
|
37
|
+
openapi: () => toOpenApiOperation(self()),
|
|
38
|
+
client: (options) => clientMethodFor(actionName(self()), options),
|
|
39
|
+
job: () => toJobHandle(self()),
|
|
40
|
+
contract: (options) => contractTestsFor(self(), options),
|
|
41
|
+
};
|
|
42
|
+
}
|