@ultimat3/action 29.4.0 → 30.1.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 +68 -101
- package/README.md +151 -9
- package/package.json +19 -8
- package/src/action-stream.ts +116 -0
- package/src/action.ts +116 -13
- package/src/audit-subject-target.ts +81 -0
- package/src/client.ts +102 -12
- package/src/contract-test.ts +40 -16
- package/src/declared-errors-contract.ts +38 -0
- package/src/declared-errors.ts +13 -0
- package/src/error-titles.ts +18 -2
- package/src/errors-http.ts +19 -2
- package/src/errors-idempotency.ts +35 -0
- package/src/errors-meter.ts +48 -0
- package/src/errors-stream.ts +18 -0
- package/src/errors.ts +20 -12
- package/src/exercise-errors.ts +74 -0
- package/src/exercise.ts +153 -0
- package/src/facade.ts +1 -1
- package/src/http-path.ts +102 -20
- package/src/http-stream.ts +99 -0
- package/src/http.ts +106 -38
- package/src/idempotency.ts +108 -3
- package/src/index.ts +74 -3
- package/src/invoke.ts +103 -27
- package/src/json-schema.ts +1 -1
- package/src/meter-gate.ts +103 -0
- package/src/meter-memory.ts +124 -0
- package/src/meter-period.ts +34 -0
- package/src/meter-postgres.ts +132 -0
- package/src/meter-store.ts +61 -0
- package/src/meter.ts +124 -0
- package/src/mutator.ts +7 -0
- package/src/openapi-complete.ts +40 -11
- package/src/openapi-errors.ts +18 -0
- package/src/openapi.ts +39 -4
- package/src/rate-limit-gate.ts +43 -3
- package/src/registry.ts +39 -18
- package/src/tool-gate.ts +97 -0
- package/src/tx-scope.ts +71 -3
- package/src/validate.ts +1 -1
- package/src/wire-issues.ts +4 -1
- package/src/sample-input.ts +0 -226
package/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.1.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"action",
|
|
10
|
+
"rpc",
|
|
11
|
+
"openapi",
|
|
12
|
+
"mcp",
|
|
13
|
+
"typed-client"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Actions",
|
|
5
16
|
"license": "MIT",
|
|
6
17
|
"type": "module",
|
|
7
18
|
"sideEffects": [
|
|
@@ -35,12 +46,12 @@
|
|
|
35
46
|
"test": "bun test"
|
|
36
47
|
},
|
|
37
48
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/db": "
|
|
41
|
-
"@ultimat3/entity": "
|
|
42
|
-
"@ultimat3/http": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/schema": "
|
|
49
|
+
"@ultimat3/cache": "30.1.0",
|
|
50
|
+
"@ultimat3/core": "30.1.0",
|
|
51
|
+
"@ultimat3/db": "30.1.0",
|
|
52
|
+
"@ultimat3/entity": "30.1.0",
|
|
53
|
+
"@ultimat3/http": "30.1.0",
|
|
54
|
+
"@ultimat3/policy": "30.1.0",
|
|
55
|
+
"@ultimat3/schema": "30.1.0"
|
|
45
56
|
}
|
|
46
57
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stream-declaring action's handler, drained. `handle` is an async generator: every yielded chunk
|
|
3
|
+
* is parsed by `stream`, handed to the sink the HTTP stream projection opened (if any), and the
|
|
4
|
+
* generator's RETURN value is the output — so `invoke`, MCP, `.job()` and `.as()` keep one contract.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { asyncContext } from '@ultimat3/core';
|
|
9
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
|
+
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
11
|
+
import { ActionStreamInvalidError } from './errors-stream';
|
|
12
|
+
|
|
13
|
+
/** Where a draining handler hands its chunks: `http-stream.ts` opens one per streamed request. */
|
|
14
|
+
export interface ChunkSink {
|
|
15
|
+
/** The wrapped declaration this sink is for — a nested action's chunks never reach it. */
|
|
16
|
+
readonly owner: object;
|
|
17
|
+
/** Called once, when the handler starts: the policy has passed by then. */
|
|
18
|
+
start(): void;
|
|
19
|
+
push(chunk: unknown): void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sinks = asyncContext<{ readonly sink: ChunkSink; claimed: boolean }>('an action stream');
|
|
23
|
+
/** Whether the handler being drained right now has a reader for its chunks. */
|
|
24
|
+
const reading = asyncContext<boolean>('an action stream reader');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Inside a stream-declaring `handle`: `true` when a caller is reading the chunks as they are
|
|
28
|
+
* yielded (a `text/event-stream` request), `false` when the call is drained to its output alone
|
|
29
|
+
* (MCP, `.job()`, `.as()`, JSON). A handler whose chunks cost something — `llm()` gives up its
|
|
30
|
+
* repair turn to stream — produces them only when they are read. Per declaration: a nested
|
|
31
|
+
* action's handler reads `false` unless it is the one the stream was opened for.
|
|
32
|
+
*/
|
|
33
|
+
export function chunksRead(): boolean {
|
|
34
|
+
return reading.get() === true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run `fn` with `sink` receiving the chunks of the first draining handler that `owner` names. */
|
|
38
|
+
export function withChunkSink<T>(sink: ChunkSink, fn: () => T): T {
|
|
39
|
+
return sinks.run({ sink, claimed: false }, fn);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StreamingDef {
|
|
43
|
+
readonly stream?: StandardSchemaV1;
|
|
44
|
+
handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The declaration `invoke` runs: `def` unchanged without `stream`, else a copy whose `handle`
|
|
49
|
+
* drains. Built per name, so an error names the action that was called.
|
|
50
|
+
*/
|
|
51
|
+
export function streamingDef<D extends StreamingDef>(def: D, name: () => string): D {
|
|
52
|
+
const stream = def.stream;
|
|
53
|
+
if (stream === undefined) return def;
|
|
54
|
+
const wrapped: D = {
|
|
55
|
+
...def,
|
|
56
|
+
handle: (args: { readonly input: unknown; readonly ctx: Ctx }) =>
|
|
57
|
+
drain(wrapped, stream, name(), def.handle(args)),
|
|
58
|
+
};
|
|
59
|
+
return wrapped;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, undefined> {
|
|
63
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
64
|
+
const candidate = value as { readonly next?: unknown; readonly [Symbol.asyncIterator]?: unknown };
|
|
65
|
+
return (
|
|
66
|
+
typeof candidate.next === 'function' && typeof candidate[Symbol.asyncIterator] === 'function'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function drain(
|
|
71
|
+
owner: object,
|
|
72
|
+
stream: StandardSchemaV1,
|
|
73
|
+
name: string,
|
|
74
|
+
produced: unknown,
|
|
75
|
+
): Promise<unknown> {
|
|
76
|
+
if (!isAsyncGenerator(produced)) {
|
|
77
|
+
throw new ActionStreamInvalidError(
|
|
78
|
+
name,
|
|
79
|
+
'its handle is not an async generator (async *handle)',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const open = sinks.get();
|
|
83
|
+
// Claimed once: the first draining handler of THIS declaration owns the response stream.
|
|
84
|
+
const sink = open !== undefined && !open.claimed && open.sink.owner === owner ? open.sink : null;
|
|
85
|
+
if (open !== undefined && sink !== null) open.claimed = true;
|
|
86
|
+
sink?.start();
|
|
87
|
+
// Every resume of the generator runs inside this scope, so its body reads `chunksRead()`.
|
|
88
|
+
return reading.run(sink !== null, () => pump(produced, stream, name, sink));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function pump(
|
|
92
|
+
produced: AsyncGenerator<unknown, unknown, undefined>,
|
|
93
|
+
stream: StandardSchemaV1,
|
|
94
|
+
name: string,
|
|
95
|
+
sink: ChunkSink | null,
|
|
96
|
+
): Promise<unknown> {
|
|
97
|
+
try {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const step = await produced.next();
|
|
100
|
+
if (step.done === true) return step.value;
|
|
101
|
+
const parsed = await validateAsync(stream, step.value);
|
|
102
|
+
if (parsed.issues !== undefined) {
|
|
103
|
+
const issues = formatIssues(parsed.issues).join('; ');
|
|
104
|
+
throw new ActionStreamInvalidError(
|
|
105
|
+
name,
|
|
106
|
+
`yielded a chunk its stream schema refuses: ${issues}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await sink?.push(parsed.value);
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
// A throw above (a refused chunk, a sink that failed) ends the generator, so its own
|
|
113
|
+
// `finally` runs and nothing it holds is left open.
|
|
114
|
+
await produced.return(undefined);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/action.ts
CHANGED
|
@@ -8,22 +8,27 @@
|
|
|
8
8
|
import type { CacheTag } from '@ultimat3/cache';
|
|
9
9
|
import { tagKeys } from '@ultimat3/cache';
|
|
10
10
|
import type { Actor, Ctx, Deprecation, McpExposureDeclaration } from '@ultimat3/core';
|
|
11
|
-
import { isMcpExposed } from '@ultimat3/core';
|
|
11
|
+
import { checkDeclaredErrors, isMcpExposed } from '@ultimat3/core';
|
|
12
12
|
import type { RateLimitDecision } from '@ultimat3/http';
|
|
13
|
+
import { declaredStatusFor } from '@ultimat3/http';
|
|
13
14
|
import { policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
14
15
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
15
|
-
import
|
|
16
|
+
import { streamingDef } from './action-stream';
|
|
17
|
+
import type { ClientMethod, ClientOptions, StreamClientMethod } from './client';
|
|
16
18
|
import type { ContractTest, ContractTestOptions } from './contract-test';
|
|
17
19
|
import { facadeFor } from './facade';
|
|
18
20
|
import type { OpenApiOperation } from './http';
|
|
19
|
-
import type { ActionHttp } from './http-path';
|
|
20
|
-
import { routeFor } from './http-path';
|
|
21
|
+
import type { ActionHttp, ActionHttpMethod } from './http-path';
|
|
22
|
+
import { methodOfHttp, routeFor } from './http-path';
|
|
21
23
|
import type { IdempotencyStore } from './idempotency';
|
|
22
24
|
import { actionName, defOf, hasDef, invoke, stashDef } from './invoke';
|
|
23
25
|
import type { ActionJobHandle } from './job-handle';
|
|
24
26
|
import type { JsonSchemaObject } from './json-schema';
|
|
25
27
|
import { jsonSchemaOf } from './json-schema';
|
|
28
|
+
import type { ActionMeter, ActionMeterFact, ResolvedLimit } from './meter';
|
|
29
|
+
import { assertMeterDeclaration, meterFact } from './meter';
|
|
26
30
|
import type { ActionPolicy, Surface } from './policy-gate';
|
|
31
|
+
import { fixedRateLimit } from './rate-limit-gate';
|
|
27
32
|
|
|
28
33
|
export interface ActionCache {
|
|
29
34
|
/** Tags dropped from every cache tier after the handler settles. */
|
|
@@ -37,6 +42,13 @@ export interface ActionCache {
|
|
|
37
42
|
export type ActionMcp = Omit<McpExposureDeclaration, 'listParams'>;
|
|
38
43
|
|
|
39
44
|
export interface ActionRateLimit {
|
|
45
|
+
/** The burst, or one resolved per call from `ctx` (`({ ctx }) => planOf(ctx).perMinute`). */
|
|
46
|
+
readonly limit: ResolvedLimit;
|
|
47
|
+
readonly windowMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A rate limit as data: the fixed pair a contract can diff. */
|
|
51
|
+
export interface ActionRateLimitFact {
|
|
40
52
|
readonly limit: number;
|
|
41
53
|
readonly windowMs: number;
|
|
42
54
|
}
|
|
@@ -63,10 +75,18 @@ export interface ActionDef<
|
|
|
63
75
|
readonly cache?: ActionCache;
|
|
64
76
|
readonly mcp?: ActionMcp;
|
|
65
77
|
readonly rateLimit?: ActionRateLimit;
|
|
78
|
+
/**
|
|
79
|
+
* A durable per-period allowance, drawn once per call on EVERY surface — HTTP, MCP, a job, an
|
|
80
|
+
* in-process call — after the policy admits it and before `handle` starts. Spent, the call is
|
|
81
|
+
* `X_METER_EXCEEDED`. A replayed idempotent call draws nothing. Periods are UTC (`meter.ts`).
|
|
82
|
+
*/
|
|
83
|
+
readonly meter?: ActionMeter;
|
|
66
84
|
/**
|
|
67
85
|
* The HTTP projection's one per-action knob: `http: { path }` pins the URL whatever the app's
|
|
68
|
-
* `pathStyle` derives — for a
|
|
69
|
-
* method, the policy, the body) stays the framework's.
|
|
86
|
+
* `pathStyle` derives — for a path a caller holds and cannot be told to change (a signed upload).
|
|
87
|
+
* Everything else about the route (the method, the policy, the body) stays the framework's. An
|
|
88
|
+
* inbound WEBHOOK is not this door: it is `webhookReceiver()` from `@ultimat3/jobs`, whose route
|
|
89
|
+
* verifies the vendor's bytes before anything parses them.
|
|
70
90
|
*/
|
|
71
91
|
readonly http?: ActionHttp;
|
|
72
92
|
/**
|
|
@@ -84,6 +104,14 @@ export interface ActionDef<
|
|
|
84
104
|
readonly deprecated?: Deprecation;
|
|
85
105
|
/** Marks the action safe to retry with an `Idempotency-Key`. */
|
|
86
106
|
readonly idempotent?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The refusals a caller may branch on: each a registered 4xx code (`defineError` first), listed
|
|
109
|
+
* once — else `X_ACTION_ERRORS_INVALID` here. Declared, the list is CLOSED: a 4xx code thrown out
|
|
110
|
+
* of `handle` or `row` that it does not name is `X_ERROR_UNDECLARED` in a test or dev run
|
|
111
|
+
* (production answers it unchanged, warns and counts), and `.contract()` scans the handler's own
|
|
112
|
+
* source for one. The OpenAPI operation and the MCP tool publish it. Absent: today's open contract.
|
|
113
|
+
*/
|
|
114
|
+
readonly errors?: readonly string[];
|
|
87
115
|
/**
|
|
88
116
|
* Record every attempt at this action through the installed `AuditSink` — allowed, denied and
|
|
89
117
|
* failed alike. Opt-in per declaration and never a global switch: a login and a price change
|
|
@@ -111,6 +139,24 @@ export interface ActionDef<
|
|
|
111
139
|
handle(args: ActionHandlerArgs<TInput>): Promise<InferOutput<TOutput>> | InferOutput<TOutput>;
|
|
112
140
|
}
|
|
113
141
|
|
|
142
|
+
/**
|
|
143
|
+
* An action that answers in pieces before it answers: `stream` is the schema every yielded chunk
|
|
144
|
+
* is parsed by, and `handle` an async generator whose RETURN value is the output. An ordinary call
|
|
145
|
+
* (MCP, `.job()`, `.as()`, a JSON request) drains it and answers the output alone; a request with
|
|
146
|
+
* `accept: text/event-stream` is sent each chunk as it is yielded (`http-stream.ts`).
|
|
147
|
+
*/
|
|
148
|
+
export interface StreamActionDef<
|
|
149
|
+
TInput extends StandardSchemaV1,
|
|
150
|
+
TOutput extends StandardSchemaV1,
|
|
151
|
+
TChunk extends StandardSchemaV1,
|
|
152
|
+
TRow = unknown,
|
|
153
|
+
> extends Omit<ActionDef<TInput, TOutput, TRow>, 'handle'> {
|
|
154
|
+
readonly stream: TChunk;
|
|
155
|
+
handle(
|
|
156
|
+
args: ActionHandlerArgs<TInput>,
|
|
157
|
+
): AsyncGenerator<InferInput<TChunk>, InferOutput<TOutput>, undefined>;
|
|
158
|
+
}
|
|
159
|
+
|
|
114
160
|
export interface InvokeOptions {
|
|
115
161
|
/** Explicit context. Omitted means "take the ambient request context". */
|
|
116
162
|
readonly ctx?: Ctx;
|
|
@@ -156,7 +202,8 @@ export interface ActionDescriptor {
|
|
|
156
202
|
readonly name: string;
|
|
157
203
|
readonly verb: string;
|
|
158
204
|
readonly resource: string;
|
|
159
|
-
|
|
205
|
+
/** The verb the route answers: `http.method` on a pin, `POST` otherwise (`methodOfHttp`). */
|
|
206
|
+
readonly method: ActionHttpMethod;
|
|
160
207
|
readonly path: string;
|
|
161
208
|
/** The policy's DISPLAY label. A composite renders as `and(a:b, c:d)` — never a permission. */
|
|
162
209
|
readonly capability: string;
|
|
@@ -177,9 +224,15 @@ export interface ActionDescriptor {
|
|
|
177
224
|
*/
|
|
178
225
|
readonly audited: boolean;
|
|
179
226
|
readonly mcp: McpDescriptorMeta;
|
|
180
|
-
|
|
227
|
+
/** The FIXED pair; `null` when none is declared or the limit is resolved per call. */
|
|
228
|
+
readonly rateLimit: ActionRateLimitFact | null;
|
|
229
|
+
/** The window of a rate limit resolved per call, which has no static number to publish. */
|
|
230
|
+
readonly rateLimitPerCall: { readonly windowMs: number } | null;
|
|
231
|
+
readonly meter: ActionMeterFact | null;
|
|
181
232
|
/** Published so `x actions list --json` can answer "what is retiring, and when". */
|
|
182
233
|
readonly deprecated: Deprecation | null;
|
|
234
|
+
/** The declared refusals, sorted; `null` for an action that declares none (an open contract). */
|
|
235
|
+
readonly errors: readonly string[] | null;
|
|
183
236
|
}
|
|
184
237
|
|
|
185
238
|
/**
|
|
@@ -194,10 +247,13 @@ export interface AnyActionDef {
|
|
|
194
247
|
readonly cache?: ActionCache;
|
|
195
248
|
readonly mcp?: ActionMcp;
|
|
196
249
|
readonly rateLimit?: ActionRateLimit;
|
|
250
|
+
readonly meter?: ActionMeter;
|
|
197
251
|
readonly http?: ActionHttp;
|
|
198
252
|
readonly deprecated?: Deprecation;
|
|
199
253
|
readonly idempotent?: boolean;
|
|
254
|
+
readonly errors?: readonly string[];
|
|
200
255
|
readonly audit?: boolean;
|
|
256
|
+
readonly stream?: StandardSchemaV1;
|
|
201
257
|
row?(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
202
258
|
handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
203
259
|
}
|
|
@@ -210,6 +266,8 @@ export interface AnyAction {
|
|
|
210
266
|
readonly output: StandardSchemaV1;
|
|
211
267
|
readonly policy: ActionPolicy;
|
|
212
268
|
readonly mcp?: ActionMcp;
|
|
269
|
+
/** The declared refusals, checked and sorted; absent for an open contract. */
|
|
270
|
+
readonly errors?: readonly string[];
|
|
213
271
|
describe(): ActionDescriptor;
|
|
214
272
|
/** A twin under another name. Registration uses `nameAction`, which names in place. */
|
|
215
273
|
named(name: string): AnyAction;
|
|
@@ -255,18 +313,47 @@ export interface Action<
|
|
|
255
313
|
job(): ActionJobHandle<TInput, TOutput>;
|
|
256
314
|
}
|
|
257
315
|
|
|
316
|
+
/**
|
|
317
|
+
* An action that declared `stream`: the same action, whose typed client also carries
|
|
318
|
+
* `.stream(input)` yielding `TChunk`. The chunk schema is not lifted onto the action — `llm()`'s
|
|
319
|
+
* server-side `.stream()` method owns that member name — so `TChunk` lives in `client()`'s type.
|
|
320
|
+
*/
|
|
321
|
+
export interface StreamAction<
|
|
322
|
+
TInput extends StandardSchemaV1 = StandardSchemaV1,
|
|
323
|
+
TOutput extends StandardSchemaV1 = StandardSchemaV1,
|
|
324
|
+
TChunk extends StandardSchemaV1 = StandardSchemaV1,
|
|
325
|
+
> extends Action<TInput, TOutput> {
|
|
326
|
+
named(name: string): StreamAction<TInput, TOutput, TChunk>;
|
|
327
|
+
client(options: ClientOptions): StreamClientMethod<TInput, TOutput, TChunk>;
|
|
328
|
+
}
|
|
329
|
+
|
|
258
330
|
/** The fluent half of an action: lifted declaration plus one method per projection. */
|
|
259
331
|
export type ActionFacade<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> = Pick<
|
|
260
332
|
Action<TInput, TOutput>,
|
|
261
333
|
'input' | 'output' | 'policy' | 'mcp' | 'as' | 'openapi' | 'client' | 'job' | 'contract'
|
|
262
334
|
>;
|
|
263
335
|
|
|
336
|
+
export function action<
|
|
337
|
+
TInput extends StandardSchemaV1,
|
|
338
|
+
TOutput extends StandardSchemaV1,
|
|
339
|
+
TChunk extends StandardSchemaV1 = StandardSchemaV1,
|
|
340
|
+
TRow = unknown,
|
|
341
|
+
>(def: StreamActionDef<TInput, TOutput, TChunk, TRow>): StreamAction<TInput, TOutput, TChunk>;
|
|
342
|
+
export function action<
|
|
343
|
+
TInput extends StandardSchemaV1,
|
|
344
|
+
TOutput extends StandardSchemaV1,
|
|
345
|
+
TRow = unknown,
|
|
346
|
+
>(def: ActionDef<TInput, TOutput, TRow>): Action<TInput, TOutput>;
|
|
264
347
|
export function action<
|
|
265
348
|
TInput extends StandardSchemaV1,
|
|
266
349
|
TOutput extends StandardSchemaV1,
|
|
267
350
|
TRow = unknown,
|
|
268
351
|
>(def: ActionDef<TInput, TOutput, TRow>): Action<TInput, TOutput> {
|
|
269
|
-
|
|
352
|
+
// Refused where it is written, and frozen before `build` stores it: the list a caller still
|
|
353
|
+
// holds is not the one the descriptor, the document and the wrap read.
|
|
354
|
+
const errors = checkDeclaredErrors(def.errors, declaredStatusFor, 'this action');
|
|
355
|
+
if (def.meter !== undefined) assertMeterDeclaration(def.meter);
|
|
356
|
+
return build(errors === undefined ? def : { ...def, errors }, '');
|
|
270
357
|
}
|
|
271
358
|
|
|
272
359
|
/**
|
|
@@ -293,6 +380,11 @@ export function nameAction<A extends AnyAction>(target: A, name: string): A {
|
|
|
293
380
|
return target;
|
|
294
381
|
}
|
|
295
382
|
|
|
383
|
+
/** The one member a stream-declaring definition adds, read through the erased `ActionDef`. */
|
|
384
|
+
interface StreamFields {
|
|
385
|
+
readonly stream?: StandardSchemaV1;
|
|
386
|
+
}
|
|
387
|
+
|
|
296
388
|
function build<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1, TRow>(
|
|
297
389
|
def: ActionDef<TInput, TOutput, TRow>,
|
|
298
390
|
name: string,
|
|
@@ -309,11 +401,16 @@ function build<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1
|
|
|
309
401
|
describe: (): ActionDescriptor => describeAction(self),
|
|
310
402
|
named: (next: string): Action<TInput, TOutput> => build(def, next),
|
|
311
403
|
...facadeFor(def, () => self),
|
|
404
|
+
...(def.errors === undefined ? {} : { errors: def.errors }),
|
|
312
405
|
});
|
|
313
406
|
// `name` on a function is non-writable, so Object.assign cannot set it.
|
|
314
407
|
Object.defineProperty(self, 'name', { value: name, configurable: true });
|
|
315
|
-
// The declaration goes to `invoke.ts` and stays there: `handle` has no other reader.
|
|
316
|
-
|
|
408
|
+
// The declaration goes to `invoke.ts` and stays there: `handle` has no other reader. A
|
|
409
|
+
// stream-declaring one is stored draining (`action-stream.ts`), so `invoke` stays one path.
|
|
410
|
+
stashDef(
|
|
411
|
+
self,
|
|
412
|
+
streamingDef(def as ActionDef<TInput, TOutput, TRow> & StreamFields, () => name),
|
|
413
|
+
);
|
|
317
414
|
return self;
|
|
318
415
|
}
|
|
319
416
|
|
|
@@ -331,7 +428,7 @@ export function describeAction(target: AnyAction): ActionDescriptor {
|
|
|
331
428
|
name,
|
|
332
429
|
verb: path.verb,
|
|
333
430
|
resource: path.resource,
|
|
334
|
-
method:
|
|
431
|
+
method: methodOfHttp(def.http),
|
|
335
432
|
path: path.path,
|
|
336
433
|
capability: policyCapability(def.policy),
|
|
337
434
|
// The flattened list, beside the label and never instead of it: one is read, one is matched.
|
|
@@ -354,7 +451,13 @@ export function describeAction(target: AnyAction): ActionDescriptor {
|
|
|
354
451
|
tool: name,
|
|
355
452
|
description: mcp?.description ?? null,
|
|
356
453
|
},
|
|
357
|
-
rateLimit: def.rateLimit
|
|
454
|
+
rateLimit: fixedRateLimit(def.rateLimit),
|
|
455
|
+
rateLimitPerCall:
|
|
456
|
+
def.rateLimit !== undefined && typeof def.rateLimit.limit === 'function'
|
|
457
|
+
? { windowMs: def.rateLimit.windowMs }
|
|
458
|
+
: null,
|
|
459
|
+
meter: meterFact(def.meter),
|
|
358
460
|
deprecated: def.deprecated ?? null,
|
|
461
|
+
errors: def.errors ?? null,
|
|
359
462
|
};
|
|
360
463
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `auditSubjectTarget()`: `x_audit` as a subject target a `defineSubject({ targets })` lists —
|
|
2
|
+
// structural, so `@ultimat3/jobs` (this tier) imports nothing from here. The erase never deletes a
|
|
3
|
+
// trail row: it overwrites who acted (`actor_id`, `on_behalf_of_id`) and the input they sent, so
|
|
4
|
+
// the record of WHAT happened stays. An app that must keep the person too lists it `{ retain }`.
|
|
5
|
+
|
|
6
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** The subject as a target is handed it: the key asked for, and the subject's row read fresh. */
|
|
9
|
+
export interface AuditSubjectRef {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuditSubjectTargetOptions {
|
|
15
|
+
readonly executor: PgExecutor;
|
|
16
|
+
/** The `actor_id` the subject acted as. Default: the subject's key. `undefined` touches nothing. */
|
|
17
|
+
readonly actor?: (subject: AuditSubjectRef) => string | undefined;
|
|
18
|
+
/** What replaces the actor. Default `'erased'`: a fixed word, never derived from the person. */
|
|
19
|
+
readonly placeholder?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Cell = string | number | boolean | null;
|
|
23
|
+
|
|
24
|
+
export interface AuditSubjectTarget {
|
|
25
|
+
readonly name: 'x_audit';
|
|
26
|
+
readonly columns: readonly string[];
|
|
27
|
+
exportPage(
|
|
28
|
+
subject: AuditSubjectRef,
|
|
29
|
+
cursor: string | null,
|
|
30
|
+
size: number,
|
|
31
|
+
): Promise<{
|
|
32
|
+
readonly rows: readonly Readonly<Record<string, Cell>>[];
|
|
33
|
+
readonly cursor: string | null;
|
|
34
|
+
}>;
|
|
35
|
+
erase(subject: AuditSubjectRef, size: number): Promise<number>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const AUDIT_SUBJECT_COLUMNS = [
|
|
39
|
+
'id',
|
|
40
|
+
'at',
|
|
41
|
+
'action',
|
|
42
|
+
'primitive',
|
|
43
|
+
'surface',
|
|
44
|
+
'outcome',
|
|
45
|
+
'org_id',
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
// Keyset on the uuid v7 id, which is time-ordered: a page resumes where the last one ended.
|
|
49
|
+
const SQL_PAGE = `select id::text as id, at::text as at, action, primitive, surface, outcome, org_id
|
|
50
|
+
from x_audit where (actor_id = $1 or on_behalf_of_id = $1)
|
|
51
|
+
and ($2::uuid is null or id > $2::uuid) order by id limit $3`;
|
|
52
|
+
|
|
53
|
+
// Bounded: one statement rewrites at most `size` rows, and a replay finds none left to rewrite.
|
|
54
|
+
const SQL_ERASE = `update x_audit set
|
|
55
|
+
actor_id = case when actor_id = $1 then $2 else actor_id end,
|
|
56
|
+
on_behalf_of_id = case when on_behalf_of_id = $1 then $2 else on_behalf_of_id end,
|
|
57
|
+
input = null
|
|
58
|
+
where id in (select id from x_audit where actor_id = $1 or on_behalf_of_id = $1 limit $3)
|
|
59
|
+
returning id`;
|
|
60
|
+
|
|
61
|
+
export function auditSubjectTarget(options: AuditSubjectTargetOptions): AuditSubjectTarget {
|
|
62
|
+
const { executor } = options;
|
|
63
|
+
const actorOf = options.actor ?? ((subject: AuditSubjectRef) => subject.key);
|
|
64
|
+
const placeholder = options.placeholder ?? 'erased';
|
|
65
|
+
return {
|
|
66
|
+
name: 'x_audit',
|
|
67
|
+
columns: AUDIT_SUBJECT_COLUMNS,
|
|
68
|
+
async exportPage(subject, cursor, size) {
|
|
69
|
+
const actor = actorOf(subject);
|
|
70
|
+
if (actor === undefined) return { rows: [], cursor: null };
|
|
71
|
+
const rows = await executor.query<Record<string, Cell>>(SQL_PAGE, [actor, cursor, size]);
|
|
72
|
+
const last = rows.at(-1)?.['id'];
|
|
73
|
+
return { rows, cursor: rows.length < size || typeof last !== 'string' ? null : last };
|
|
74
|
+
},
|
|
75
|
+
async erase(subject, size) {
|
|
76
|
+
const actor = actorOf(subject);
|
|
77
|
+
if (actor === undefined) return 0;
|
|
78
|
+
return (await executor.query(SQL_ERASE, [actor, placeholder, size])).length;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -25,15 +25,20 @@ import type {
|
|
|
25
25
|
import {
|
|
26
26
|
actionPath,
|
|
27
27
|
BUILD_ID_HEADER,
|
|
28
|
+
clientStream,
|
|
28
29
|
clientTransport,
|
|
29
30
|
FRAMEWORK_CODE,
|
|
31
|
+
fillWire,
|
|
30
32
|
isJsonObject,
|
|
31
33
|
problemOf,
|
|
32
34
|
remoteTitleOf,
|
|
35
|
+
renderedActionPin,
|
|
33
36
|
} from '@ultimat3/core';
|
|
34
37
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
35
|
-
import type { Action } from './action';
|
|
38
|
+
import type { Action, StreamAction } from './action';
|
|
36
39
|
import { ContractDriftError, RemoteActionError } from './errors';
|
|
40
|
+
import { ActionHttpPathInvalidError } from './errors-http';
|
|
41
|
+
import type { ActionHttp } from './http-path';
|
|
37
42
|
import { issuesFromWire } from './wire-issues';
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -58,11 +63,20 @@ export interface CallOptions {
|
|
|
58
63
|
readonly retry?: ClientRetry;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* `api.publishPost({ postId })` with both sides of the schema inferred; a stream-declaring action's
|
|
68
|
+
* method also carries `.stream(input)`, and only that one — the type is the gate.
|
|
69
|
+
*/
|
|
62
70
|
export type Client<TActions extends ActionMap> = {
|
|
63
|
-
readonly [K in keyof TActions]: TActions[K] extends
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
readonly [K in keyof TActions]: TActions[K] extends StreamAction<
|
|
72
|
+
infer TIn,
|
|
73
|
+
infer TOut,
|
|
74
|
+
infer TChunk
|
|
75
|
+
>
|
|
76
|
+
? StreamClientMethod<TIn, TOut, TChunk>
|
|
77
|
+
: TActions[K] extends Action<infer TIn, infer TOut>
|
|
78
|
+
? ClientMethod<TIn, TOut>
|
|
79
|
+
: never;
|
|
66
80
|
};
|
|
67
81
|
|
|
68
82
|
export type ClientMethod<TIn extends StandardSchemaV1, TOut extends StandardSchemaV1> = (
|
|
@@ -70,6 +84,22 @@ export type ClientMethod<TIn extends StandardSchemaV1, TOut extends StandardSche
|
|
|
70
84
|
options?: CallOptions,
|
|
71
85
|
) => Promise<InferOutput<TOut>>;
|
|
72
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A stream-declaring action's method: the ordinary call, plus `.stream(input)` — each chunk as the
|
|
89
|
+
* server yields it, and the generator's RETURN value is the output. `POST` with
|
|
90
|
+
* `accept: text/event-stream` through core's `clientStream`, never `EventSource`.
|
|
91
|
+
*/
|
|
92
|
+
export type StreamClientMethod<
|
|
93
|
+
TIn extends StandardSchemaV1,
|
|
94
|
+
TOut extends StandardSchemaV1,
|
|
95
|
+
TChunk extends StandardSchemaV1,
|
|
96
|
+
> = ClientMethod<TIn, TOut> & {
|
|
97
|
+
stream(
|
|
98
|
+
input: InferInput<TIn>,
|
|
99
|
+
options?: Omit<CallOptions, 'retry'>,
|
|
100
|
+
): AsyncGenerator<InferOutput<TChunk>, InferOutput<TOut>, undefined>;
|
|
101
|
+
};
|
|
102
|
+
|
|
73
103
|
/** Core's one declaration, re-exported by name so `import type { FetchLike }` keeps resolving. */
|
|
74
104
|
export type { FetchLike } from '@ultimat3/core';
|
|
75
105
|
|
|
@@ -125,12 +155,17 @@ export function rpc<TActions extends ActionMap>(options: ClientOptions): Client<
|
|
|
125
155
|
export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
126
156
|
name: string,
|
|
127
157
|
options: ClientOptions,
|
|
128
|
-
|
|
158
|
+
pinned?: ActionHttp,
|
|
129
159
|
): ClientMethod<TInput, TOutput> {
|
|
130
160
|
// No default here: for a caller naming no style, `actionPath` asks the document the server
|
|
131
161
|
// rendered, and that answer must be the same one `useMutation` and the outbox replay get.
|
|
132
|
-
|
|
133
|
-
|
|
162
|
+
// A pin by name (`rpc`) is the one the server stamped into this document (`renderedActionPin`);
|
|
163
|
+
// a declaration's own (`.client()`) wins, and with neither the style derives the path. A
|
|
164
|
+
// declared method with no path still names the verb when no stamp does (a server has none).
|
|
165
|
+
const pin = pinned?.path === undefined ? renderedActionPin(name) : pinned;
|
|
166
|
+
const path = pin?.path ?? actionPath(name, options.pathStyle);
|
|
167
|
+
const method = pin?.method ?? pinned?.method ?? 'POST';
|
|
168
|
+
const base = options.baseUrl.replace(/\/+$/, '');
|
|
134
169
|
const onResponse = (response: Response): void =>
|
|
135
170
|
assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
|
|
136
171
|
const decodeError = (
|
|
@@ -139,11 +174,19 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
|
|
|
139
174
|
retryAfterSeconds?: number,
|
|
140
175
|
): UltimateError | undefined => toUltimateError(text, status, name, retryAfterSeconds);
|
|
141
176
|
// Erased at the wire seam; the response type is this action's by construction.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
177
|
+
const call: ClientMethod<TInput, TOutput> = (input, callOptions = {}) => {
|
|
178
|
+
let wire: { readonly url: string; readonly body: unknown };
|
|
179
|
+
try {
|
|
180
|
+
wire = wireOf(name, base, path, input);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// A rejection like every other refusal of a call, never a throw out of a Promise-returning one.
|
|
183
|
+
return Promise.reject(error);
|
|
184
|
+
}
|
|
185
|
+
const { url, body } = wire;
|
|
186
|
+
return clientTransport({
|
|
187
|
+
method,
|
|
145
188
|
url,
|
|
146
|
-
body
|
|
189
|
+
body,
|
|
147
190
|
headers: headersFor(options),
|
|
148
191
|
signal: callOptions.signal,
|
|
149
192
|
idempotencyKey: callOptions.idempotencyKey,
|
|
@@ -156,6 +199,52 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
|
|
|
156
199
|
decodeError,
|
|
157
200
|
fetchImpl: options.fetch,
|
|
158
201
|
}) as Promise<InferOutput<TOutput>>;
|
|
202
|
+
};
|
|
203
|
+
// On every method, because the name-only `rpc` proxy cannot see a declaration: the TYPE offers
|
|
204
|
+
// it only where the action streams, and a server that does not stream answers JSON, which
|
|
205
|
+
// `clientStream` returns as the output. Never retried: a stream is not an idempotent replay.
|
|
206
|
+
async function* stream(
|
|
207
|
+
input: InferInput<TInput>,
|
|
208
|
+
callOptions: Omit<CallOptions, 'retry'> = {},
|
|
209
|
+
): AsyncGenerator<unknown, InferOutput<TOutput>, undefined> {
|
|
210
|
+
// Inside the generator, so a refusal arrives on the first pull like every other one.
|
|
211
|
+
const { url, body } = wireOf(name, base, path, input);
|
|
212
|
+
return (yield* clientStream<unknown, InferOutput<TOutput>>({
|
|
213
|
+
url,
|
|
214
|
+
body,
|
|
215
|
+
headers: headersFor(options),
|
|
216
|
+
signal: callOptions.signal,
|
|
217
|
+
idempotencyKey: callOptions.idempotencyKey,
|
|
218
|
+
onResponse,
|
|
219
|
+
decodeError,
|
|
220
|
+
fetchImpl: options.fetch,
|
|
221
|
+
})) as InferOutput<TOutput>;
|
|
222
|
+
}
|
|
223
|
+
return Object.assign(call, { stream });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A `:param` pin's URL, filled from the input the way the server's router reads it back (each value
|
|
228
|
+
* one encoded segment), and the body without those keys — the URL carries them. A static path is
|
|
229
|
+
* the whole input as the body, as every unpinned call always sent. A missing value is refused
|
|
230
|
+
* before any request exists.
|
|
231
|
+
*/
|
|
232
|
+
function wireOf(
|
|
233
|
+
name: string,
|
|
234
|
+
base: string,
|
|
235
|
+
path: string,
|
|
236
|
+
input: unknown,
|
|
237
|
+
): { readonly url: string; readonly body: unknown } {
|
|
238
|
+
const filled = fillWire(path, input);
|
|
239
|
+
if (!filled.ok) {
|
|
240
|
+
throw new ActionHttpPathInvalidError({
|
|
241
|
+
name,
|
|
242
|
+
path,
|
|
243
|
+
reason: `:${filled.missing} ${filled.reason}`,
|
|
244
|
+
fix: `${name}.client(options)({ ${filled.missing}: '<value>', ... }) — a non-empty string, the URL's :${filled.missing}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return { url: `${base}${filled.url}`, body: filled.rest ?? {} };
|
|
159
248
|
}
|
|
160
249
|
|
|
161
250
|
/** One attempt, and no retry at all. What a mutation carrying no idempotency key is allowed. */
|
|
@@ -223,6 +312,7 @@ function toUltimateError(
|
|
|
223
312
|
// is dropped rather than repaired, and `cause` below still carries every rejection in it.
|
|
224
313
|
issues: issuesFromWire(body['issues']),
|
|
225
314
|
meta: metaFromWire(body['meta']),
|
|
315
|
+
requestId: body['requestId'],
|
|
226
316
|
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`),
|
|
227
317
|
fix: stringOr(body['fix'], `x actions show ${name} --json`),
|
|
228
318
|
// RFC-9457's `type` IS a documentation URI, so a server that sends no `docs` extension has
|