@cfast/actions 0.2.0 → 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
@@ -32,6 +32,38 @@ declare function useActions<const TNames extends readonly string[]>(descriptor:
32
32
  [K in TNames[number]]: (input?: Serializable) => ActionHookResult;
33
33
  };
34
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>;
66
+
35
67
  type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
36
68
  /** Object with `_action` key and input fields to inject as hidden inputs. */
37
69
  action: Record<string, string | number | boolean | null | undefined>;
@@ -57,4 +89,4 @@ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action">
57
89
  */
58
90
  declare function ActionForm({ action, children, ...formProps }: ActionFormProps): react_jsx_runtime.JSX.Element;
59
91
 
60
- 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,4 +1,4 @@
1
- import { Grant, PermissionDescriptor } from '@cfast/permissions';
1
+ import { Grant, PermissionDescriptor, SchemaMap, CrudAction } from '@cfast/permissions';
2
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
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';
@@ -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,
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>(
@@ -112,6 +127,36 @@ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action">
112
127
  };
113
128
  ```
114
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
+
115
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`.
116
161
 
117
162
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/actions",
3
- "version": "0.2.0",
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.5.0",
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",