@cfast/actions 0.1.3 → 0.3.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-CJpjon5s.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,41 @@ 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
+ };
34
+
35
+ /**
36
+ * Client hook that replaces `useLoaderData()` for routes using `cfastJson()`.
37
+ *
38
+ * Returns the same data but wraps arrays and objects that have `_can` with
39
+ * permission helper methods:
40
+ *
41
+ * - **Collections** (arrays with items that have `_can`): array works normally
42
+ * (indexing, `.map()`, etc.) AND has `canAdd()`. Each item has `canEdit()`,
43
+ * `canDelete()`, `canRead()`, `canCreate()`.
44
+ * - **Items** (objects with `_can`): row properties directly accessible,
45
+ * plus `canEdit()`, `canDelete()`, `canRead()`, `canCreate()`.
46
+ * - **Other fields**: passed through unchanged.
47
+ *
48
+ * Each `can*()` method returns an {@link ActionHookResult}.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * const { documents } = useCfastLoader<typeof loader>();
53
+ *
54
+ * // Collection-level
55
+ * documents.canAdd() // -> ActionHookResult
56
+ *
57
+ * // Row fields accessible directly
58
+ * documents[0].title // "Hello World"
59
+ *
60
+ * // Row-level
61
+ * documents[0].canEdit() // -> ActionHookResult
62
+ * documents[0].canDelete() // -> ActionHookResult
63
+ * ```
64
+ */
65
+ declare function useCfastLoader<T extends (...args: any[]) => any = () => Record<string, unknown>>(): ReturnType<T> extends Promise<infer R> ? R : ReturnType<T>;
24
66
 
25
67
  type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
26
68
  /** Object with `_action` key and input fields to inject as hidden inputs. */
@@ -47,4 +89,4 @@ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action">
47
89
  */
48
90
  declare function ActionForm({ action, children, ...formProps }: ActionFormProps): react_jsx_runtime.JSX.Element;
49
91
 
50
- export { ActionForm, type ActionHookResult, ClientDescriptor, clientDescriptor, useActions };
92
+ export { ActionForm, type ActionHookResult, ClientDescriptor, clientDescriptor, useActions, useCfastLoader };
package/dist/client.js CHANGED
@@ -67,6 +67,117 @@ function useActions(descriptor) {
67
67
  return result;
68
68
  }
69
69
 
70
+ // src/client/use-cfast-loader.ts
71
+ import { useMemo } from "react";
72
+ import { useLoaderData as useLoaderData2 } from "react-router";
73
+ function makeResult(permitted) {
74
+ return {
75
+ permitted,
76
+ invisible: false,
77
+ reason: null,
78
+ submit: () => {
79
+ },
80
+ pending: false,
81
+ data: void 0,
82
+ error: void 0
83
+ };
84
+ }
85
+ function wrapItem(item) {
86
+ const can = item._can ?? {};
87
+ const wrapped = /* @__PURE__ */ Object.create(null);
88
+ for (const [key, value] of Object.entries(item)) {
89
+ if (key === "_can") continue;
90
+ Object.defineProperty(wrapped, key, {
91
+ value,
92
+ enumerable: true,
93
+ writable: true,
94
+ configurable: true
95
+ });
96
+ }
97
+ Object.defineProperty(wrapped, "_can", {
98
+ value: item._can,
99
+ enumerable: false,
100
+ writable: false,
101
+ configurable: false
102
+ });
103
+ Object.defineProperty(wrapped, "canRead", {
104
+ value: () => makeResult(can.read ?? false),
105
+ enumerable: false,
106
+ writable: false,
107
+ configurable: false
108
+ });
109
+ Object.defineProperty(wrapped, "canCreate", {
110
+ value: () => makeResult(can.create ?? false),
111
+ enumerable: false,
112
+ writable: false,
113
+ configurable: false
114
+ });
115
+ Object.defineProperty(wrapped, "canEdit", {
116
+ value: () => makeResult(can.update ?? false),
117
+ enumerable: false,
118
+ writable: false,
119
+ configurable: false
120
+ });
121
+ Object.defineProperty(wrapped, "canDelete", {
122
+ value: () => makeResult(can.delete ?? false),
123
+ enumerable: false,
124
+ writable: false,
125
+ configurable: false
126
+ });
127
+ return wrapped;
128
+ }
129
+ function wrapCollection(arr, tablePerms) {
130
+ const wrappedItems = arr.map((item) => {
131
+ if (item && typeof item === "object" && "_can" in item) {
132
+ return wrapItem(item);
133
+ }
134
+ return item;
135
+ });
136
+ const tableName = arr._tableName;
137
+ const perms = tableName ? tablePerms[tableName] : void 0;
138
+ const result = [...wrappedItems];
139
+ if (tableName) {
140
+ Object.defineProperty(result, "_tableName", {
141
+ value: tableName,
142
+ enumerable: false,
143
+ writable: false,
144
+ configurable: false
145
+ });
146
+ }
147
+ Object.defineProperty(result, "canAdd", {
148
+ value: () => makeResult(perms?.create ?? false),
149
+ enumerable: false,
150
+ writable: false,
151
+ configurable: false
152
+ });
153
+ return result;
154
+ }
155
+ function useCfastLoader() {
156
+ const raw = useLoaderData2();
157
+ return useMemo(() => {
158
+ const tablePerms = raw._tablePerms ?? {};
159
+ const result = {};
160
+ for (const [key, value] of Object.entries(raw)) {
161
+ if (key === "_tablePerms") {
162
+ continue;
163
+ }
164
+ if (Array.isArray(value)) {
165
+ const hasCanItems = value.length > 0 && value[0] !== null && typeof value[0] === "object" && "_can" in value[0];
166
+ if (hasCanItems) {
167
+ result[key] = wrapCollection(value, tablePerms);
168
+ } else {
169
+ result[key] = value;
170
+ }
171
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value) && "_can" in value) {
172
+ result[key] = wrapItem(value);
173
+ } else {
174
+ result[key] = value;
175
+ }
176
+ }
177
+ return result;
178
+ }, [raw]);
179
+ }
180
+
70
181
  // src/client/action-form.tsx
71
182
  import { Form } from "react-router";
72
183
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -81,5 +192,6 @@ function ActionForm({ action, children, ...formProps }) {
81
192
  export {
82
193
  ActionForm,
83
194
  clientDescriptor,
84
- useActions
195
+ useActions,
196
+ useCfastLoader
85
197
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
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-CJpjon5s.js';
3
- export { e as ActionContext, f as ActionPermissionsMap, C as ClientDescriptor, D as DispatchArgs, R as RequestArgs, S as Serializable } from './types-CJpjon5s.js';
1
+ import { Grant, PermissionDescriptor, SchemaMap, CrudAction } 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-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
  /**
@@ -241,6 +241,41 @@ declare function createActions<TUser = any, TServices extends ActionServices = A
241
241
  composeActions: <TActions extends Record<string, ActionDefinition<any, any, any>>>(actions: TActions) => ComposedActions<TActions>;
242
242
  };
243
243
 
244
+ /**
245
+ * Server-side loader helper that wraps data with table-level permission
246
+ * metadata and serializes dates.
247
+ *
248
+ * Replaces the manual pattern of calling `can()` per table and `toJSON()`
249
+ * separately. The returned object contains:
250
+ *
251
+ * - All fields from `data`, with Dates converted to ISO strings.
252
+ * - `_tablePerms`: a `Record<tableName, Record<CrudAction, boolean>>` map
253
+ * covering every table in the schema.
254
+ * - Each array value in `data` is annotated with a non-enumerable
255
+ * `_tableName` property matching the first table whose SQL name appears
256
+ * as the data key. This allows the client-side `useCfastLoader` hook to
257
+ * look up `canAdd()` for the correct table.
258
+ *
259
+ * @param grants - The user's resolved permission grants.
260
+ * @param schema - A schema map (e.g. `import * as schema from "./schema"`).
261
+ * @param data - The loader data to wrap.
262
+ * @returns A serializable object with `_tablePerms` embedded.
263
+ *
264
+ * @example
265
+ * ```ts
266
+ * import { cfastJson } from "@cfast/actions";
267
+ * import * as schema from "../db/schema";
268
+ *
269
+ * export async function loader({ context }) {
270
+ * const documents = await db.query(documentsTable).findMany().run();
271
+ * return cfastJson(ctx.auth.grants, schema, { documents });
272
+ * }
273
+ * ```
274
+ */
275
+ declare function cfastJson<TData extends Record<string, unknown>>(grants: Grant[], schema: SchemaMap, data: TData): TData & {
276
+ _tablePerms: Record<string, Record<CrudAction, boolean>>;
277
+ };
278
+
244
279
  /**
245
280
  * Options accepted by {@link forwardRequest}.
246
281
  *
@@ -328,4 +363,4 @@ type ForwardRequestInit = {
328
363
  */
329
364
  declare function forwardRequest(original: Request, init?: ForwardRequestInit): Request;
330
365
 
331
- export { ActionDefinition, ActionPermissionStatus, ActionServices, ActionsConfig, ComposedActions, type ForwardRequestInit, type InferInput, type InputField, type InputParser, type InputSchema, InvalidInputError, OperationsFn, checkPermissionStatus, createActions, defineInput, forwardRequest, z };
366
+ export { ActionDefinition, ActionPermissionStatus, ActionServices, ActionsConfig, ComposedActions, type ForwardRequestInit, type InferInput, type InputField, type InputParser, type InputSchema, InvalidInputError, OperationsFn, cfastJson, checkPermissionStatus, createActions, defineInput, forwardRequest, z };
package/dist/index.js CHANGED
@@ -154,6 +154,51 @@ function createActions(config) {
154
154
  return { createAction, composeActions };
155
155
  }
156
156
 
157
+ // src/cfast-json.ts
158
+ import { resolveTablePermissions } from "@cfast/permissions";
159
+ import { toJSON } from "@cfast/db";
160
+ function cfastJson(grants, schema, data) {
161
+ const tablePerms = resolveTablePermissions(grants, schema);
162
+ const serialized = toJSON(data);
163
+ const keyToTableName = {};
164
+ for (const [jsKey, table] of Object.entries(schema)) {
165
+ const DRIZZLE_NAME_SYMBOL = /* @__PURE__ */ Symbol.for("drizzle:Name");
166
+ if (typeof table === "object" && table !== null) {
167
+ const name = Reflect.get(table, DRIZZLE_NAME_SYMBOL);
168
+ if (typeof name === "string") {
169
+ keyToTableName[jsKey] = name;
170
+ }
171
+ }
172
+ }
173
+ for (const [key, value] of Object.entries(serialized)) {
174
+ if (Array.isArray(value)) {
175
+ let tableName;
176
+ if (keyToTableName[key]) {
177
+ tableName = keyToTableName[key];
178
+ } else {
179
+ for (const sqlName of Object.values(keyToTableName)) {
180
+ if (sqlName === key) {
181
+ tableName = sqlName;
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ if (tableName) {
187
+ Object.defineProperty(value, "_tableName", {
188
+ value: tableName,
189
+ enumerable: false,
190
+ writable: false,
191
+ configurable: false
192
+ });
193
+ }
194
+ }
195
+ }
196
+ return {
197
+ ...serialized,
198
+ _tablePerms: tablePerms
199
+ };
200
+ }
201
+
157
202
  // src/input-schema.ts
158
203
  var InvalidInputError = class extends Error {
159
204
  /** Field-keyed map of validation errors. */
@@ -340,6 +385,7 @@ function forwardRequest(original, init = {}) {
340
385
  }
341
386
  export {
342
387
  InvalidInputError,
388
+ cfastJson,
343
389
  checkPermissionStatus,
344
390
  createActions,
345
391
  defineInput,
@@ -223,11 +223,11 @@ type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
223
223
  * Created by {@link ActionDefinition.client} or {@link ComposedActions.client}.
224
224
  * Contains the action names and the key used to read permission data from loader results.
225
225
  */
226
- type ClientDescriptor = {
226
+ type ClientDescriptor<TNames extends readonly string[] = readonly string[]> = {
227
227
  /** Brand field to distinguish this type at the type level. */
228
228
  _brand: "ActionClientDescriptor";
229
229
  /** The list of action names this descriptor covers. */
230
- actionNames: readonly string[];
230
+ actionNames: TNames;
231
231
  /** The loader-data key where {@link ActionPermissionsMap} is stored. */
232
232
  permissionsKey: string;
233
233
  };
package/llms.txt CHANGED
@@ -16,6 +16,21 @@ Use this package when you need React Router route actions that automatically che
16
16
 
17
17
  ### Server (`@cfast/actions`)
18
18
 
19
+ #### `cfastJson(grants, schema, data)`
20
+ Server-side loader helper that wraps data with table-level permission metadata and serializes dates. Replaces the manual pattern of calling `can()` per table and `toJSON()` separately.
21
+
22
+ ```typescript
23
+ import { cfastJson } from "@cfast/actions";
24
+ import * as schema from "../db/schema";
25
+
26
+ export async function loader({ context }) {
27
+ const documents = await db.query(documentsTable).findMany().run();
28
+ return cfastJson(ctx.auth.grants, schema, { documents });
29
+ }
30
+ ```
31
+
32
+ Returns the data with `_tablePerms` embedded and dates serialized. Arrays whose keys match schema table names are annotated with a non-enumerable `_tableName` property so `useCfastLoader` can resolve `canAdd()` for the correct table.
33
+
19
34
  ```typescript
20
35
  function createActions<TUser>(config: ActionsConfig<TUser>): {
21
36
  createAction: <TInput, TResult>(
@@ -68,9 +83,31 @@ type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
68
83
 
69
84
  ### Client (`@cfast/actions/client`)
70
85
 
86
+ #### Type-safe `clientDescriptor()` and `useActions()`
87
+
88
+ `clientDescriptor()` now uses `const` type parameters to infer the literal tuple
89
+ type from the action names array. `useActions()` returns a mapped type keyed by
90
+ those literal names instead of `Record<string, ...>`, giving compile-time
91
+ autocomplete and error checking on action names.
92
+
93
+ ```typescript
94
+ // Type-safe: actions.create and actions.delete are typed, typos are caught
95
+ const client = clientDescriptor(["create", "delete"]);
96
+ // client is ClientDescriptor<readonly ["create", "delete"]>
97
+
98
+ const actions = useActions(client);
99
+ // actions is { create: (input?) => ActionHookResult; delete: (input?) => ActionHookResult }
100
+ // actions.creat → TypeScript error (typo caught at compile time)
101
+ ```
102
+
71
103
  ```typescript
72
- function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
73
- function clientDescriptor(actionNames: readonly string[]): ClientDescriptor;
104
+ function useActions<const TNames extends readonly string[]>(
105
+ descriptor: ClientDescriptor<TNames>,
106
+ ): { [K in TNames[number]]: (input?: Serializable) => ActionHookResult };
107
+
108
+ function clientDescriptor<const TNames extends readonly string[]>(
109
+ actionNames: TNames,
110
+ ): ClientDescriptor<TNames>;
74
111
 
75
112
  function ActionForm(props: ActionFormProps): JSX.Element;
76
113
 
@@ -90,6 +127,36 @@ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action">
90
127
  };
91
128
  ```
92
129
 
130
+ #### `useCfastLoader<T>()`
131
+ Client hook that replaces `useLoaderData()` for routes using `cfastJson()`. Returns the same data but wraps arrays and objects that have `_can` with permission helper methods.
132
+
133
+ ```typescript
134
+ import { useCfastLoader } from "@cfast/actions/client";
135
+
136
+ const { documents } = useCfastLoader<typeof loader>();
137
+
138
+ // Collection-level: "can I add to this table?"
139
+ documents.canAdd() // -> ActionHookResult
140
+
141
+ // Row fields accessible directly (no .data wrapper)
142
+ documents[0].title // "Hello World"
143
+
144
+ // Row-level: "can I edit THIS item?"
145
+ documents[0].canEdit() // -> ActionHookResult
146
+ documents[0].canDelete() // -> ActionHookResult
147
+
148
+ // Single items work too
149
+ const { document } = useCfastLoader<typeof loader>();
150
+ document.canEdit() // -> ActionHookResult
151
+ ```
152
+
153
+ Wrapping rules:
154
+ - Array where items have `_can` -> collection: array + `canAdd()`, each item has `canEdit()`, `canDelete()`, `canRead()`, `canCreate()`
155
+ - Object with `_can` -> item: row properties directly accessible + `can*()` methods
156
+ - Otherwise -> pass through unchanged
157
+
158
+ All `can*()` methods return `ActionHookResult` with `permitted`, `invisible`, `reason`, `submit` (no-op), `pending` (false), `data`, `error`.
159
+
93
160
  `ActionForm` is a form wrapper that auto-injects hidden fields from an action descriptor object. Replaces manual `<input type="hidden">` patterns when using `composeActions`.
94
161
 
95
162
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/actions",
3
- "version": "0.1.3",
3
+ "version": "0.3.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.6.0",
42
+ "@cfast/permissions": ">=0.3.0 <0.7.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.1",
63
- "@cfast/permissions": "0.5.1"
62
+ "@cfast/db": "0.8.0",
63
+ "@cfast/permissions": "0.7.0"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsup src/index.ts src/client.ts --format esm --dts",