@ultimat3/action 22.6.0 → 22.6.1

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 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.0",
3
+ "version": "22.6.1",
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.0",
38
- "@ultimat3/core": "22.6.0",
39
- "@ultimat3/entity": "22.6.0",
40
- "@ultimat3/http": "22.6.0",
41
- "@ultimat3/policy": "22.6.0",
42
- "@ultimat3/schema": "22.6.0"
37
+ "@ultimat3/cache": "22.6.1",
38
+ "@ultimat3/core": "22.6.1",
39
+ "@ultimat3/entity": "22.6.1",
40
+ "@ultimat3/http": "22.6.1",
41
+ "@ultimat3/policy": "22.6.1",
42
+ "@ultimat3/schema": "22.6.1"
43
43
  }
44
44
  }
@@ -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 { ActionHttpPathInvalidError, ActionPathStyleInvalidError } from './errors-http';
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
- style = next as ActionPathStyle;
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
- const derived = actionRoute(name, style);
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
- /** The route a registered NAME is served at: its pin when it declares one, else the style's. */
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
- return routeFor(name, pinOf(name));
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, derivePath(actionName(target)).path, (req) => req.bodyRaw());
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 derivePath(name).resource;
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 `derivePath` — the same derivation the route and the client use, never a second one.
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 : derivePath(deprecated.replacedBy).path;
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 { derivePath, inputSchemaName, outputSchemaName, PROBLEM_SCHEMA_NAME } from './naming';
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 } = derivePath(name);
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 { assertPinnedPath, resetActionPathStyle, routeFor, setActionPathStyle } from './http-path';
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
- if (typeof target === 'string') {
74
- const seated = registry.get(target);
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 } = actionHttpPath(target);
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 });