@cfast/actions 0.0.1 → 0.1.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/dist/client.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { C as ClientDescriptor, S as Serializable } from './types-Dolh-eut.js';
2
+ import * as react_jsx_runtime from 'react/jsx-runtime';
3
+ import { Form } from 'react-router';
4
+ import { ComponentProps, ReactNode } from 'react';
2
5
  import '@cfast/db';
3
6
  import '@cfast/permissions';
4
7
 
@@ -19,4 +22,29 @@ type ActionHookResult = {
19
22
  };
20
23
  declare function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
21
24
 
22
- export { type ActionHookResult, ClientDescriptor, clientDescriptor, useActions };
25
+ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
26
+ /** Object with `_action` key and input fields to inject as hidden inputs. */
27
+ action: Record<string, string | number | boolean | null | undefined>;
28
+ children: ReactNode;
29
+ };
30
+ /**
31
+ * A Form wrapper that auto-injects hidden fields from an action descriptor.
32
+ *
33
+ * Replaces the manual pattern of:
34
+ * ```tsx
35
+ * <Form method="post">
36
+ * <input type="hidden" name="_action" value="addComment" />
37
+ * <input type="hidden" name="postId" value={post.id} />
38
+ * </Form>
39
+ * ```
40
+ *
41
+ * With:
42
+ * ```tsx
43
+ * <ActionForm action={{ _action: "addComment", postId: post.id }} method="post">
44
+ * ...
45
+ * </ActionForm>
46
+ * ```
47
+ */
48
+ declare function ActionForm({ action, children, ...formProps }: ActionFormProps): react_jsx_runtime.JSX.Element;
49
+
50
+ export { ActionForm, type ActionHookResult, ClientDescriptor, clientDescriptor, useActions };
package/dist/client.js CHANGED
@@ -66,7 +66,20 @@ function useActions(descriptor) {
66
66
  }
67
67
  return result;
68
68
  }
69
+
70
+ // src/client/action-form.tsx
71
+ import { Form } from "react-router";
72
+ import { jsx, jsxs } from "react/jsx-runtime";
73
+ function ActionForm({ action, children, ...formProps }) {
74
+ return /* @__PURE__ */ jsxs(Form, { ...formProps, children: [
75
+ Object.entries(action).map(
76
+ ([key, value]) => value != null ? /* @__PURE__ */ jsx("input", { type: "hidden", name: key, value: String(value) }, key) : null
77
+ ),
78
+ children
79
+ ] });
80
+ }
69
81
  export {
82
+ ActionForm,
70
83
  clientDescriptor,
71
84
  useActions
72
85
  };
package/llms.txt ADDED
@@ -0,0 +1,181 @@
1
+ # @cfast/actions
2
+
3
+ > Reusable, type-safe, permission-aware actions for React Router. Define operations once -- permissions and execution come from the same place.
4
+
5
+ ## When to use
6
+ Use this package when you need React Router route actions that automatically check user permissions before execution and expose permission status to the client for UI gating (disable/hide buttons).
7
+
8
+ ## Key concepts
9
+ - **Action factory**: `createActions()` binds a `getContext` callback that resolves `{ db, user, grants }` per request. All actions share the same context provider.
10
+ - **Operations**: Each action is defined as an `OperationsFn` that receives `(db, input, ctx)` and returns an `Operation<TResult>` from `@cfast/db`. The operation carries both the mutation logic and permission descriptors.
11
+ - **Four facets**: Every action definition exposes `.action` (route handler), `.loader()` (permission-injecting loader wrapper), `.client` (descriptor for the hook), and `.buildOperation()` (for cross-action composition).
12
+ - **Composition**: `composeActions()` merges multiple actions into one route handler, dispatching by the `_action` discriminator field.
13
+ - **Permission flow**: Loader wraps extract permission descriptors from operations, check them against user grants, and inject `_actionPermissions` into loader data. The `useActions` hook reads this on the client.
14
+
15
+ ## API Reference
16
+
17
+ ### Server (`@cfast/actions`)
18
+
19
+ ```typescript
20
+ function createActions<TUser>(config: ActionsConfig<TUser>): {
21
+ createAction: <TInput, TResult>(
22
+ operationsFn: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>
23
+ ) => ActionDefinition<TInput, TResult, TUser>;
24
+
25
+ composeActions: <TActions extends Record<string, ActionDefinition<any, any, any>>>(
26
+ actions: TActions
27
+ ) => ComposedActions<TActions>;
28
+ };
29
+
30
+ function checkPermissionStatus(grants: Grant[], descriptors: PermissionDescriptor[]): ActionPermissionStatus;
31
+
32
+ type ActionsConfig<TUser> = {
33
+ getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
34
+ };
35
+
36
+ type ActionContext<TUser> = { db: Db; user: TUser; grants: Grant[] };
37
+ type RequestArgs = { request: Request; params: Record<string, string | undefined>; context?: unknown };
38
+
39
+ type ActionDefinition<TInput, TResult, TUser> = {
40
+ action: (args: RequestArgs) => Promise<TResult>;
41
+ loader: <T extends Record<string, Serializable>>(
42
+ loaderFn: (args: RequestArgs) => Promise<T>
43
+ ) => (args: RequestArgs) => Promise<T & { _actionPermissions: ActionPermissionsMap }>;
44
+ client: ClientDescriptor;
45
+ buildOperation: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
46
+ };
47
+
48
+ type ComposedActions<TActions> = {
49
+ action: (args: RequestArgs) => Promise<unknown>;
50
+ loader: <T extends Record<string, Serializable>>(
51
+ loaderFn: (args: RequestArgs) => Promise<T>
52
+ ) => (args: RequestArgs) => Promise<T & { _actionPermissions: ActionPermissionsMap }>;
53
+ client: ClientDescriptor;
54
+ actions: TActions;
55
+ };
56
+
57
+ type ActionPermissionStatus = { permitted: boolean; invisible: boolean; reason: string | null };
58
+ type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
59
+ ```
60
+
61
+ ### Client (`@cfast/actions/client`)
62
+
63
+ ```typescript
64
+ function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
65
+ function clientDescriptor(actionNames: readonly string[]): ClientDescriptor;
66
+
67
+ function ActionForm(props: ActionFormProps): JSX.Element;
68
+
69
+ type ActionHookResult = {
70
+ permitted: boolean; // user has required permissions
71
+ invisible: boolean; // user lacks ALL permissions (hide the UI)
72
+ reason: string | null; // human-readable denial reason
73
+ submit: () => void; // submits via fetcher with _action discriminator
74
+ pending: boolean; // fetcher in flight
75
+ data: unknown; // action return value
76
+ error: unknown; // error if action failed
77
+ };
78
+
79
+ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
80
+ action: Record<string, string | number | boolean | null | undefined>;
81
+ children: ReactNode;
82
+ };
83
+ ```
84
+
85
+ `ActionForm` is a form wrapper that auto-injects hidden fields from an action descriptor object. Replaces manual `<input type="hidden">` patterns when using `composeActions`.
86
+
87
+ ```tsx
88
+ import { ActionForm } from "@cfast/actions/client";
89
+
90
+ <ActionForm action={{ _action: "addComment", postId: post.id }} method="post">
91
+ <textarea name="content" />
92
+ <button type="submit">Comment</button>
93
+ </ActionForm>
94
+ ```
95
+
96
+ Each key/value pair in the `action` object becomes a hidden input. The above is equivalent to:
97
+ ```tsx
98
+ <Form method="post">
99
+ <input type="hidden" name="_action" value="addComment" />
100
+ <input type="hidden" name="postId" value={post.id} />
101
+ <textarea name="content" />
102
+ <button type="submit">Comment</button>
103
+ </Form>
104
+ ```
105
+
106
+ ## Usage Examples
107
+
108
+ ### 1. Setup the factory (once per app)
109
+ ```typescript
110
+ // app/actions.server.ts
111
+ import { createActions } from "@cfast/actions";
112
+
113
+ export const { createAction, composeActions } = createActions({
114
+ getContext: async ({ request }) => {
115
+ const ctx = await requireAuthContext(request);
116
+ const db = createCfDb(env.DB, ctx);
117
+ return { db, user: ctx.user, grants: ctx.grants };
118
+ },
119
+ });
120
+ ```
121
+
122
+ ### 2. Define an action
123
+ ```typescript
124
+ import { compose } from "@cfast/db";
125
+ import { createAction } from "~/actions.server";
126
+
127
+ export const deletePost = createAction<{ postId: string }, Response>(
128
+ (db, input, ctx) =>
129
+ compose(
130
+ [db.delete(posts).where(eq(posts.id, input.postId))],
131
+ async (runDelete) => { await runDelete({}); return redirect("/"); },
132
+ ),
133
+ );
134
+ ```
135
+
136
+ ### 3. Compose multiple actions in a route
137
+ ```typescript
138
+ import { composeActions } from "~/actions.server";
139
+ import { deletePost, publishPost } from "~/actions/posts";
140
+
141
+ const composed = composeActions({ deletePost, publishPost });
142
+ export const action = composed.action;
143
+ export const loader = composed.loader(async ({ params }) => {
144
+ return { post: await getPost(params.slug) };
145
+ });
146
+ ```
147
+
148
+ ### 4. Use on the client
149
+ ```typescript
150
+ import { useActions } from "@cfast/actions/client";
151
+
152
+ function PostActions({ postId }) {
153
+ const actions = useActions(composed.client);
154
+ const remove = actions.deletePost({ postId });
155
+
156
+ return (
157
+ <button
158
+ onClick={remove.submit}
159
+ disabled={!remove.permitted || remove.pending}
160
+ hidden={remove.invisible}
161
+ >
162
+ Delete
163
+ </button>
164
+ );
165
+ }
166
+ ```
167
+
168
+ ## Integration
169
+ - **@cfast/db**: Actions return `Operation` / `compose()` from `@cfast/db`. Permission descriptors are extracted from operations.
170
+ - **@cfast/permissions**: User `grants` (from `getContext`) are checked against operation permission descriptors via `checkPermissionStatus`.
171
+
172
+ ## Common Mistakes
173
+ - Forgetting to wrap the loader with `.loader()` -- the client will have no permission data and `useActions` will default to `permitted: true`.
174
+ - Using `createAction` directly without first calling `createActions` to set up the factory. `createAction` is returned by `createActions`, not a standalone export.
175
+ - Omitting the `_action` hidden input in forms when using `composeActions`. The discriminator field is required to route to the correct handler.
176
+ - Calling `useActions` with the wrong descriptor. Use `composed.client` for composed actions, or `singleAction.client` for a single action.
177
+
178
+ ## See Also
179
+
180
+ - `@cfast/db` -- Permission descriptors come from Operation results.
181
+ - `@cfast/permissions` -- Defines the grants enforced by action permissions.
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@cfast/actions",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "Multi-action routes and permission-aware action definitions for React Router",
5
+ "keywords": [
6
+ "cfast",
7
+ "cloudflare-workers",
8
+ "react-router",
9
+ "actions",
10
+ "multi-action",
11
+ "permissions"
12
+ ],
5
13
  "license": "MIT",
6
14
  "repository": {
7
15
  "type": "git",
@@ -22,7 +30,8 @@
22
30
  }
23
31
  },
24
32
  "files": [
25
- "dist"
33
+ "dist",
34
+ "llms.txt"
26
35
  ],
27
36
  "sideEffects": false,
28
37
  "publishConfig": {
@@ -33,8 +42,8 @@
33
42
  "react-router": "^7.0.0"
34
43
  },
35
44
  "dependencies": {
36
- "@cfast/db": "0.0.1",
37
- "@cfast/permissions": "0.0.1"
45
+ "@cfast/db": "0.1.1",
46
+ "@cfast/permissions": "0.1.0"
38
47
  },
39
48
  "devDependencies": {
40
49
  "@cloudflare/workers-types": "^4.20260305.1",
@@ -1,49 +0,0 @@
1
- import { Db, Operation } from '@cfast/db';
2
- import { Grant } from '@cfast/permissions';
3
-
4
- type Serializable = string | number | boolean | null | Serializable[] | {
5
- [key: string]: Serializable;
6
- };
7
- type ActionContext<TUser> = {
8
- db: Db;
9
- user: TUser;
10
- grants: Grant[];
11
- };
12
- type RequestArgs = {
13
- request: Request;
14
- params: Record<string, string | undefined>;
15
- context?: unknown;
16
- };
17
- type ActionsConfig<TUser> = {
18
- getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
19
- };
20
- type OperationsFn<TInput, TResult, TUser> = (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
21
- type ActionPermissionStatus = {
22
- permitted: boolean;
23
- invisible: boolean;
24
- reason: string | null;
25
- };
26
- type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
27
- type ClientDescriptor = {
28
- _brand: "ActionClientDescriptor";
29
- actionNames: readonly string[];
30
- permissionsKey: string;
31
- };
32
- type ActionDefinition<TInput, TResult, TUser> = {
33
- action: (args: RequestArgs) => Promise<TResult>;
34
- loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
35
- _actionPermissions: ActionPermissionsMap;
36
- }>;
37
- client: ClientDescriptor;
38
- buildOperation: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
39
- };
40
- type ComposedActions<TActions extends Record<string, ActionDefinition<any, any, any>>> = {
41
- action: (args: RequestArgs) => Promise<unknown>;
42
- loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
43
- _actionPermissions: ActionPermissionsMap;
44
- }>;
45
- client: ClientDescriptor;
46
- actions: TActions;
47
- };
48
-
49
- export type { ActionPermissionStatus as A, ClientDescriptor as C, OperationsFn as O, RequestArgs as R, Serializable as S, ActionsConfig as a, ActionDefinition as b, ComposedActions as c, ActionContext as d, ActionPermissionsMap as e };