@ultimat3/action 22.6.0 → 22.6.2
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/README.md +6 -0
- package/package.json +7 -7
- package/src/errors-http.ts +29 -0
- package/src/errors.ts +1 -0
- package/src/http-path.ts +45 -5
- package/src/http.ts +5 -6
- package/src/openapi.ts +3 -2
- package/src/registry.ts +10 -6
package/README.md
CHANGED
|
@@ -185,6 +185,12 @@ One rule per app, declared once — `defineApi({ http: { pathStyle } })` — plu
|
|
|
185
185
|
`derivePath(name)` (pin-aware) and `actionHttpPath(action | name)`. The browser's `rpc()` derives
|
|
186
186
|
from the name alone, so it is told the style: `rpc({ baseUrl, pathStyle: 'readable' })`; a pinned
|
|
187
187
|
action is called through `action.client()`, which holds the pin.
|
|
188
|
+
- **The style is read when the path is derived, so derive it late.** The scan evaluates
|
|
189
|
+
`apps/web/api/index.ts` before any other module, so a page or admin view already sees the declared
|
|
190
|
+
style. A module the index itself imports runs before its `defineApi` call: a module-level
|
|
191
|
+
`const SIGN_OUT = derivePath('signOut').path` there captures the default style, and the
|
|
192
|
+
declaration refuses the switch with `X_ACTION_PATH_DERIVED_EARLY`, naming it. Derive inside the
|
|
193
|
+
component or function instead.
|
|
188
194
|
|
|
189
195
|
```ts
|
|
190
196
|
import { defineApi } from '@ultimat3/action';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "22.6.
|
|
3
|
+
"version": "22.6.2",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/cache": "22.6.
|
|
38
|
-
"@ultimat3/core": "22.6.
|
|
39
|
-
"@ultimat3/entity": "22.6.
|
|
40
|
-
"@ultimat3/http": "22.6.
|
|
41
|
-
"@ultimat3/policy": "22.6.
|
|
42
|
-
"@ultimat3/schema": "22.6.
|
|
37
|
+
"@ultimat3/cache": "22.6.2",
|
|
38
|
+
"@ultimat3/core": "22.6.2",
|
|
39
|
+
"@ultimat3/entity": "22.6.2",
|
|
40
|
+
"@ultimat3/http": "22.6.2",
|
|
41
|
+
"@ultimat3/policy": "22.6.2",
|
|
42
|
+
"@ultimat3/schema": "22.6.2"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/errors-http.ts
CHANGED
|
@@ -27,6 +27,35 @@ export class ActionPathStyleInvalidError extends UltimateError {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A path was handed out under one style and the app then declared another. The string is already
|
|
32
|
+
* captured — a module-level `const SIGN_OUT = derivePath('signOut').path` — so re-deriving the
|
|
33
|
+
* registry cannot reach it: that form would post to a URL no route serves. Refused at the
|
|
34
|
+
* declaration, naming every stale capture, rather than answered 404 in production.
|
|
35
|
+
*/
|
|
36
|
+
export class ActionPathDerivedEarlyError extends UltimateError {
|
|
37
|
+
constructor(input: {
|
|
38
|
+
readonly style: string;
|
|
39
|
+
readonly stale: readonly {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly was: string;
|
|
42
|
+
readonly now: string;
|
|
43
|
+
}[];
|
|
44
|
+
}) {
|
|
45
|
+
const shown = input.stale
|
|
46
|
+
.slice(0, 5)
|
|
47
|
+
.map((row) => `${row.name} (${row.was}, now ${row.now})`)
|
|
48
|
+
.join(', ');
|
|
49
|
+
const more = input.stale.length > 5 ? ` and ${input.stale.length - 5} more` : '';
|
|
50
|
+
super({
|
|
51
|
+
code: 'X_ACTION_PATH_DERIVED_EARLY',
|
|
52
|
+
cause: `pathStyle '${input.style}' was declared after a path was already derived under the previous style: ${shown}${more} — any module that kept that string holds a URL no route serves`,
|
|
53
|
+
fix: 'derive the path where it is used — inside the component or function, never in a module-level const — or import the module that calls defineApi({ http: { pathStyle } }) before the one that derives it',
|
|
54
|
+
meta: { style: input.style, names: input.stale.map((row) => row.name) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
export class OpenApiConfigInvalidError extends UltimateError {
|
|
31
60
|
constructor(reason: string) {
|
|
32
61
|
super({
|
package/src/errors.ts
CHANGED
|
@@ -58,6 +58,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
58
58
|
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
|
|
59
59
|
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
60
60
|
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
61
|
+
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
package/src/http-path.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ActionPathStyle, ActionRoute } from '@ultimat3/core';
|
|
9
9
|
import { ACTION_PATH_STYLES, actionRoute } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ActionHttpPathInvalidError,
|
|
12
|
+
ActionPathDerivedEarlyError,
|
|
13
|
+
ActionPathStyleInvalidError,
|
|
14
|
+
} from './errors-http';
|
|
11
15
|
import { defOf } from './invoke';
|
|
12
16
|
import { seatedActions } from './registry-store';
|
|
13
17
|
|
|
@@ -35,6 +39,14 @@ const PINNED_PATH = /^(\/[a-z0-9][a-z0-9._~-]*)+$/;
|
|
|
35
39
|
|
|
36
40
|
let style: ActionPathStyle = 'resource';
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Every path handed out BY NAME (`derivePath`, `actionHttpPath('name')`), by name. A caller may
|
|
44
|
+
* keep the string — a page's form target in a module-level const — so a later style that moves
|
|
45
|
+
* one of these is a URL no route serves; `setActionPathStyle` refuses it instead. Bounded by the
|
|
46
|
+
* number of action names, and a path the new style keeps is not a stale one.
|
|
47
|
+
*/
|
|
48
|
+
const handedOut = new Map<string, string>();
|
|
49
|
+
|
|
38
50
|
/** The app's declared style — `'resource'` until `defineApi` says otherwise. */
|
|
39
51
|
export function actionPathStyle(): ActionPathStyle {
|
|
40
52
|
return style;
|
|
@@ -45,13 +57,22 @@ export function setActionPathStyle(next: unknown): ActionPathStyle {
|
|
|
45
57
|
if (!ACTION_PATH_STYLES.some((known) => known === next)) {
|
|
46
58
|
throw new ActionPathStyleInvalidError(next);
|
|
47
59
|
}
|
|
48
|
-
|
|
60
|
+
const declared = next as ActionPathStyle;
|
|
61
|
+
if (declared !== style) {
|
|
62
|
+
const stale = [...handedOut]
|
|
63
|
+
.map(([name, was]) => ({ name, was, now: routeUnder(declared, name, pinOf(name)).path }))
|
|
64
|
+
.filter((row) => row.now !== row.was)
|
|
65
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
66
|
+
if (stale.length > 0) throw new ActionPathDerivedEarlyError({ style: declared, stale });
|
|
67
|
+
}
|
|
68
|
+
style = declared;
|
|
49
69
|
return style;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
/** Test seam, beside `resetRegistry`. */
|
|
53
73
|
export function resetActionPathStyle(): void {
|
|
54
74
|
style = 'resource';
|
|
75
|
+
handedOut.clear();
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
/** Refuses a pin that is not a static lowercase path, or one under the framework's `/_x`. */
|
|
@@ -81,11 +102,30 @@ export function assertPinnedPath(name: string, path: unknown): void {
|
|
|
81
102
|
* verb and resource stay the style's — they label the operation, the path is where it lives.
|
|
82
103
|
*/
|
|
83
104
|
export function routeFor(name: string, pin: string | undefined): ActionRoute {
|
|
84
|
-
|
|
105
|
+
return routeUnder(style, name, pin);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const routeUnder = (under: ActionPathStyle, name: string, pin: string | undefined): ActionRoute => {
|
|
109
|
+
const derived = actionRoute(name, under);
|
|
85
110
|
return pin === undefined ? derived : { ...derived, path: pin };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The route a registered NAME is served at, for the framework's own projections — the route
|
|
115
|
+
* table, the OpenAPI operation, a deprecation's successor. Re-derived on every call and never
|
|
116
|
+
* held by app code, so nothing is recorded: a test that mounted routes must not make a later
|
|
117
|
+
* test's style declaration look like a stranded capture.
|
|
118
|
+
*/
|
|
119
|
+
export function servedActionRoute(name: string): ActionRoute {
|
|
120
|
+
return routeFor(name, pinOf(name));
|
|
86
121
|
}
|
|
87
122
|
|
|
88
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* The same answer, handed to APP code by name (`derivePath`, `actionHttpPath('name')`) — which
|
|
125
|
+
* may keep the string, so it is the one recorded; see `handedOut`.
|
|
126
|
+
*/
|
|
89
127
|
export function resolveActionRoute(name: string): ActionRoute {
|
|
90
|
-
|
|
128
|
+
const route = servedActionRoute(name);
|
|
129
|
+
handedOut.set(name, route.path);
|
|
130
|
+
return route;
|
|
91
131
|
}
|
package/src/http.ts
CHANGED
|
@@ -22,10 +22,9 @@ import type { ActionRateLimit, AnyAction } from './action';
|
|
|
22
22
|
import type { Deprecation } from './deprecation';
|
|
23
23
|
import { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
24
24
|
import { ActionDeprecationInvalidError } from './errors';
|
|
25
|
-
import { actionPathStyle } from './http-path';
|
|
25
|
+
import { actionPathStyle, servedActionRoute } from './http-path';
|
|
26
26
|
import { actionName, defOf, invoke } from './invoke';
|
|
27
27
|
import {
|
|
28
|
-
derivePath,
|
|
29
28
|
inputSchemaName,
|
|
30
29
|
outputSchemaName,
|
|
31
30
|
PROBLEM_SCHEMA_NAME,
|
|
@@ -57,7 +56,7 @@ async function writeOriginOf(req: UltimateRequest): Promise<string | undefined>
|
|
|
57
56
|
* kebab-cased (`updateUserProfile` -> `/api/user-profiles/update`). See `naming.ts`.
|
|
58
57
|
*/
|
|
59
58
|
export function toRoute(target: AnyAction): Route {
|
|
60
|
-
return projectRoute(target,
|
|
59
|
+
return projectRoute(target, servedActionRoute(actionName(target)).path, (req) => req.bodyRaw());
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
/**
|
|
@@ -182,7 +181,7 @@ function projectRoute(
|
|
|
182
181
|
*/
|
|
183
182
|
export function operationTagOf(target: AnyAction): string {
|
|
184
183
|
const name = actionName(target);
|
|
185
|
-
if (actionPathStyle() === 'resource') return
|
|
184
|
+
if (actionPathStyle() === 'resource') return servedActionRoute(name).resource;
|
|
186
185
|
const policy = defOf(target).policy;
|
|
187
186
|
// A grant first; an `allow('x:y')` names its resource in the label alone.
|
|
188
187
|
const named = policyPermissions(policy)[0] ?? policyCapability(policy);
|
|
@@ -266,7 +265,7 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
266
265
|
|
|
267
266
|
/**
|
|
268
267
|
* The headers this action's `deprecated:` block renders to, or nothing. The successor's URL comes
|
|
269
|
-
* from `
|
|
268
|
+
* from `servedActionRoute` — `derivePath`'s derivation, the one the route and the client use.
|
|
270
269
|
*/
|
|
271
270
|
function deprecationHeadersFor(
|
|
272
271
|
name: string,
|
|
@@ -274,7 +273,7 @@ function deprecationHeadersFor(
|
|
|
274
273
|
): Readonly<Record<string, string>> | undefined {
|
|
275
274
|
if (deprecated === undefined) return undefined;
|
|
276
275
|
const successor =
|
|
277
|
-
deprecated.replacedBy === undefined ? undefined :
|
|
276
|
+
deprecated.replacedBy === undefined ? undefined : servedActionRoute(deprecated.replacedBy).path;
|
|
278
277
|
const rendered = renderDeprecation(deprecated, successor);
|
|
279
278
|
if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
|
|
280
279
|
return rendered.headers;
|
package/src/openapi.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import type { AnyAction } from './action';
|
|
11
11
|
import { operationTagOf, toOpenApiOperation } from './http';
|
|
12
|
+
import { servedActionRoute } from './http-path';
|
|
12
13
|
import { actionName } from './invoke';
|
|
13
14
|
import { type JsonSchemaObject, jsonSchemaOf, sortSchema } from './json-schema';
|
|
14
|
-
import {
|
|
15
|
+
import { inputSchemaName, outputSchemaName, PROBLEM_SCHEMA_NAME } from './naming';
|
|
15
16
|
import { listActions } from './registry';
|
|
16
17
|
import { stableStringify } from './stable';
|
|
17
18
|
|
|
@@ -50,7 +51,7 @@ export function buildOpenApi(options: BuildOpenApiOptions = {}): OpenApiDocument
|
|
|
50
51
|
|
|
51
52
|
for (const target of actions) {
|
|
52
53
|
const name = actionName(target);
|
|
53
|
-
const { path } =
|
|
54
|
+
const { path } = servedActionRoute(name);
|
|
54
55
|
paths[path] = { post: toOpenApiOperation(target) };
|
|
55
56
|
schemas[inputSchemaName(name)] = sortSchema(jsonSchemaOf(target.input));
|
|
56
57
|
schemas[outputSchemaName(name)] = sortSchema(jsonSchemaOf(target.output));
|
package/src/registry.ts
CHANGED
|
@@ -10,7 +10,13 @@ import type { ActionDescriptor, AnyAction } from './action';
|
|
|
10
10
|
import { isAction, nameAction } from './action';
|
|
11
11
|
import { resetApiDeclaration } from './api-declaration';
|
|
12
12
|
import { ActionDuplicateError, ActionPathDuplicateError, ActionPolicyMissingError } from './errors';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
assertPinnedPath,
|
|
15
|
+
resetActionPathStyle,
|
|
16
|
+
resolveActionRoute,
|
|
17
|
+
routeFor,
|
|
18
|
+
setActionPathStyle,
|
|
19
|
+
} from './http-path';
|
|
14
20
|
import { assertIdempotencyScope } from './idempotency';
|
|
15
21
|
import { defOf } from './invoke';
|
|
16
22
|
import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
|
|
@@ -70,10 +76,8 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
|
|
|
70
76
|
* name (a deprecation's `replacedBy`, a page minting an island's endpoint).
|
|
71
77
|
*/
|
|
72
78
|
export function actionHttpPath(target: AnyAction | string): ActionPath {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return seated === undefined ? routeFor(target, undefined) : actionHttpPath(seated);
|
|
76
|
-
}
|
|
79
|
+
// By NAME is how a page mints a form target, so it answers through the recorded path.
|
|
80
|
+
if (typeof target === 'string') return resolveActionRoute(target);
|
|
77
81
|
return routeFor(target.name, defOf(target).http?.path);
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -86,7 +90,7 @@ export function configureActionPathStyle(style: ActionPathStyle): void {
|
|
|
86
90
|
setActionPathStyle(style);
|
|
87
91
|
paths.clear();
|
|
88
92
|
for (const [name, target] of [...registry.entries()].sort(byName)) {
|
|
89
|
-
const { path } =
|
|
93
|
+
const { path } = routeFor(name, defOf(target).http?.path);
|
|
90
94
|
const owner = paths.get(path);
|
|
91
95
|
if (owner !== undefined && owner !== name) {
|
|
92
96
|
throw new ActionPathDuplicateError({ name, existing: owner, path });
|