@cfast/actions 0.1.2 → 0.2.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/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as ClientDescriptor, S as Serializable } from './types-ogCcbQm3.js';
1
+ import { C as ClientDescriptor, S as Serializable } from './types-C1PGA5l3.js';
2
2
  import * as react_jsx_runtime from 'react/jsx-runtime';
3
3
  import { Form } from 'react-router';
4
4
  import { ComponentProps, ReactNode } from 'react';
@@ -9,8 +9,16 @@ import '@cfast/permissions';
9
9
  * Creates a ClientDescriptor for use in client code without importing
10
10
  * server modules. The action names must match the keys passed to
11
11
  * `composeActions` or the single action name from `createAction`.
12
+ *
13
+ * Pass a `readonly` tuple (`as const`) to get compile-time type-checking
14
+ * of action names throughout the client code:
15
+ *
16
+ * ```ts
17
+ * const client = clientDescriptor(["create", "delete"] as const);
18
+ * // client is ClientDescriptor<readonly ["create", "delete"]>
19
+ * ```
12
20
  */
13
- declare function clientDescriptor(actionNames: readonly string[]): ClientDescriptor;
21
+ declare function clientDescriptor<const TNames extends readonly string[]>(actionNames: TNames): ClientDescriptor<TNames>;
14
22
  type ActionHookResult = {
15
23
  permitted: boolean;
16
24
  invisible: boolean;
@@ -20,7 +28,9 @@ type ActionHookResult = {
20
28
  data: unknown | undefined;
21
29
  error: unknown | undefined;
22
30
  };
23
- declare function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
31
+ declare function useActions<const TNames extends readonly string[]>(descriptor: ClientDescriptor<TNames>): {
32
+ [K in TNames[number]]: (input?: Serializable) => ActionHookResult;
33
+ };
24
34
 
25
35
  type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
26
36
  /** Object with `_action` key and input fields to inject as hidden inputs. */
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Grant, PermissionDescriptor } from '@cfast/permissions';
2
- import { A as ActionPermissionStatus, a as ActionServices, b as ActionsConfig, O as OperationsFn, c as ActionDefinition, d as ComposedActions } from './types-ogCcbQm3.js';
3
- export { e as ActionContext, f as ActionPermissionsMap, C as ClientDescriptor, R as RequestArgs, S as Serializable } from './types-ogCcbQm3.js';
2
+ import { A as ActionPermissionStatus, a as ActionServices, b as ActionsConfig, O as OperationsFn, c as ActionDefinition, d as ComposedActions } from './types-C1PGA5l3.js';
3
+ export { e as ActionContext, f as ActionPermissionsMap, C as ClientDescriptor, D as DispatchArgs, R as RequestArgs, S as Serializable } from './types-C1PGA5l3.js';
4
4
  import '@cfast/db';
5
5
 
6
6
  /**
package/dist/index.js CHANGED
@@ -104,7 +104,12 @@ function createActions(config) {
104
104
  const buildOperation = (db, input, ctx) => {
105
105
  return handler(db, input, ctx);
106
106
  };
107
- return { action, loader, client, buildOperation };
107
+ const dispatch = async (args) => {
108
+ const { ctx, input } = args;
109
+ const operation = handler(ctx.db, input, ctx);
110
+ return operation.run();
111
+ };
112
+ return { action, dispatch, loader, client, buildOperation };
108
113
  }
109
114
  function composeActions(actions) {
110
115
  const actionNames = Object.keys(actions);
@@ -80,6 +80,37 @@ type RequestArgs = {
80
80
  /** Optional context object (e.g., Cloudflare Workers env via `context.cloudflare.env`). */
81
81
  context?: unknown;
82
82
  };
83
+ /**
84
+ * Arguments for {@link ActionDefinition.dispatch}, which lets a parent action
85
+ * invoke a sub-action **without** constructing a new `Request`.
86
+ *
87
+ * The parent passes its already-resolved {@link ActionContext} and the typed
88
+ * input directly. This avoids re-running `getContext()` (and thus the
89
+ * cookie-based session lookup) for the sub-action, which is both faster and
90
+ * eliminates the class of bugs described in issue #185 where a manually-built
91
+ * `Request` forgets to forward the `Cookie` header.
92
+ *
93
+ * @typeParam TInput - The expected input shape for the target action.
94
+ * @typeParam TUser - The shape of the authenticated user object.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const parent = createAction((db, input, ctx) => ({
99
+ * permissions: [],
100
+ * async run() {
101
+ * // No Request needed — ctx is reused directly.
102
+ * const result = await child.dispatch({ ctx, input: { title: "Hello" } });
103
+ * return result;
104
+ * },
105
+ * }));
106
+ * ```
107
+ */
108
+ type DispatchArgs<TInput, TUser> = {
109
+ /** The parent action's already-resolved context, reused as-is. */
110
+ ctx: ActionContext<TUser>;
111
+ /** Typed input for the sub-action. */
112
+ input: TInput;
113
+ };
83
114
  /**
84
115
  * Configuration for the {@link createActions} factory.
85
116
  *
@@ -192,11 +223,11 @@ type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
192
223
  * Created by {@link ActionDefinition.client} or {@link ComposedActions.client}.
193
224
  * Contains the action names and the key used to read permission data from loader results.
194
225
  */
195
- type ClientDescriptor = {
226
+ type ClientDescriptor<TNames extends readonly string[] = readonly string[]> = {
196
227
  /** Brand field to distinguish this type at the type level. */
197
228
  _brand: "ActionClientDescriptor";
198
229
  /** The list of action names this descriptor covers. */
199
- actionNames: readonly string[];
230
+ actionNames: TNames;
200
231
  /** The loader-data key where {@link ActionPermissionsMap} is stored. */
201
232
  permissionsKey: string;
202
233
  };
@@ -230,6 +261,30 @@ type ClientDescriptor = {
230
261
  type ActionDefinition<TInput, TResult, TUser> = {
231
262
  /** React Router action handler. Parses input, resolves context, and runs the operation. */
232
263
  action: (args: RequestArgs) => Promise<TResult>;
264
+ /**
265
+ * Dispatches the action using an already-resolved {@link ActionContext},
266
+ * bypassing `getContext()` entirely.
267
+ *
268
+ * Use this when a parent action handler needs to invoke a sub-action.
269
+ * Because the parent's `ctx` is reused directly, cookies, user session,
270
+ * and grants are inherited without constructing a new `Request` — which
271
+ * eliminates the cookie-forwarding bug described in issue #185.
272
+ *
273
+ * @param args - The {@link DispatchArgs} containing the parent's `ctx`
274
+ * and the typed input for this action.
275
+ * @returns The action's result, same as calling `.action()`.
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * const parent = createAction((db, input, ctx) => ({
280
+ * permissions: [],
281
+ * async run() {
282
+ * return child.dispatch({ ctx, input: { title: "Hello" } });
283
+ * },
284
+ * }));
285
+ * ```
286
+ */
287
+ dispatch: (args: DispatchArgs<TInput, TUser>) => Promise<TResult>;
233
288
  /**
234
289
  * Wraps a loader function to inject {@link ActionPermissionsMap} into its return value.
235
290
  *
@@ -285,4 +340,4 @@ type ComposedActions<TActions extends Record<string, ActionDefinition<any, any,
285
340
  actions: TActions;
286
341
  };
287
342
 
288
- export type { ActionPermissionStatus as A, ClientDescriptor as C, OperationsFn as O, RequestArgs as R, Serializable as S, ActionServices as a, ActionsConfig as b, ActionDefinition as c, ComposedActions as d, ActionContext as e, ActionPermissionsMap as f };
343
+ export type { ActionPermissionStatus as A, ClientDescriptor as C, DispatchArgs as D, OperationsFn as O, RequestArgs as R, Serializable as S, ActionServices as a, ActionsConfig as b, ActionDefinition as c, ComposedActions as d, ActionContext as e, ActionPermissionsMap as f };
package/llms.txt CHANGED
@@ -68,9 +68,31 @@ type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
68
68
 
69
69
  ### Client (`@cfast/actions/client`)
70
70
 
71
+ #### Type-safe `clientDescriptor()` and `useActions()`
72
+
73
+ `clientDescriptor()` now uses `const` type parameters to infer the literal tuple
74
+ type from the action names array. `useActions()` returns a mapped type keyed by
75
+ those literal names instead of `Record<string, ...>`, giving compile-time
76
+ autocomplete and error checking on action names.
77
+
78
+ ```typescript
79
+ // Type-safe: actions.create and actions.delete are typed, typos are caught
80
+ const client = clientDescriptor(["create", "delete"]);
81
+ // client is ClientDescriptor<readonly ["create", "delete"]>
82
+
83
+ const actions = useActions(client);
84
+ // actions is { create: (input?) => ActionHookResult; delete: (input?) => ActionHookResult }
85
+ // actions.creat → TypeScript error (typo caught at compile time)
86
+ ```
87
+
71
88
  ```typescript
72
- function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
73
- function clientDescriptor(actionNames: readonly string[]): ClientDescriptor;
89
+ function useActions<const TNames extends readonly string[]>(
90
+ descriptor: ClientDescriptor<TNames>,
91
+ ): { [K in TNames[number]]: (input?: Serializable) => ActionHookResult };
92
+
93
+ function clientDescriptor<const TNames extends readonly string[]>(
94
+ actionNames: TNames,
95
+ ): ClientDescriptor<TNames>;
74
96
 
75
97
  function ActionForm(props: ActionFormProps): JSX.Element;
76
98
 
@@ -187,7 +209,37 @@ new body is `FormData` or `URLSearchParams`, so the platform can attach the
187
209
  correct multipart boundary. Pass an explicit `headers: { "content-type": ... }`
188
210
  to override.
189
211
 
190
- ### 5. Use on the client
212
+ ### 5. Dispatch sub-actions with `action.dispatch()` (preferred)
213
+
214
+ `action.dispatch({ ctx, input })` calls an action's operation directly,
215
+ bypassing Request construction entirely. No cookie forwarding, no FormData
216
+ serialisation, no `forwardRequest()` boilerplate -- just pass the context
217
+ and input you already have:
218
+
219
+ ```typescript
220
+ import { createRow } from "~/actions/rows";
221
+
222
+ export const importCsv = createAction(async (db, input, ctx) => ({
223
+ permissions: createRow.buildOperation(db, {} as never, ctx).permissions,
224
+ async run() {
225
+ for (const row of input.rows) {
226
+ await createRow.dispatch({
227
+ ctx: { db, user: ctx.user, grants: ctx.grants, services: {} },
228
+ input: { title: row.title },
229
+ });
230
+ }
231
+ return { ok: true };
232
+ },
233
+ }));
234
+ ```
235
+
236
+ `dispatch()` reuses the caller's `db`, `user`, and `grants` directly, so
237
+ permission checks still run on the sub-action's operation. Use `dispatch()`
238
+ for all server-to-server sub-action calls. `forwardRequest()` still works
239
+ but is only needed when you must go through the full HTTP action handler
240
+ (e.g., calling an action in a different Worker).
241
+
242
+ ### 6. Use on the client
191
243
  ```typescript
192
244
  import { useActions } from "@cfast/actions/client";
193
245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/actions",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Multi-action routes and permission-aware action definitions for React Router",
5
5
  "keywords": [
6
6
  "cfast",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@cfast/db": ">=0.3.0 <0.5.0",
42
- "@cfast/permissions": ">=0.3.0 <0.5.0",
42
+ "@cfast/permissions": ">=0.3.0 <0.6.0",
43
43
  "react": "^19.0.0",
44
44
  "react-router": "^7.0.0"
45
45
  },
@@ -59,8 +59,8 @@
59
59
  "tsup": "^8",
60
60
  "typescript": "^5.7",
61
61
  "vitest": "^4.1.0",
62
- "@cfast/db": "0.4.0",
63
- "@cfast/permissions": "0.4.0"
62
+ "@cfast/db": "0.5.0",
63
+ "@cfast/permissions": "0.5.1"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsup src/index.ts src/client.ts --format esm --dts",