@cfast/actions 0.2.0 → 0.3.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
@@ -32,6 +32,60 @@ declare function useActions<const TNames extends readonly string[]>(descriptor:
32
32
  [K in TNames[number]]: (input?: Serializable) => ActionHookResult;
33
33
  };
34
34
 
35
+ /** Permission methods added to items with `_can`. */
36
+ type CfastItemMethods = {
37
+ canRead: () => ActionHookResult;
38
+ canCreate: () => ActionHookResult;
39
+ canEdit: () => ActionHookResult;
40
+ canDelete: () => ActionHookResult;
41
+ };
42
+ /** An item with `_can` gets permission methods mixed in. */
43
+ type CfastItem<T> = T & CfastItemMethods;
44
+ /** A collection gets `canAdd()` plus each item gets permission methods. */
45
+ type CfastCollection<T> = CfastItem<T>[] & {
46
+ canAdd: () => ActionHookResult;
47
+ };
48
+ /**
49
+ * Transforms a loader return type:
50
+ * - Arrays of objects with `_can` -> {@link CfastCollection}
51
+ * - Objects with `_can` -> {@link CfastItem}
52
+ * - Other values -> unchanged
53
+ */
54
+ type CfastLoaderData<T> = {
55
+ [K in keyof T]: T[K] extends (infer U)[] ? U extends Record<string, unknown> ? CfastCollection<U> : T[K] : T[K] extends Record<string, unknown> ? CfastItem<T[K]> : T[K];
56
+ };
57
+ /**
58
+ * Client hook that replaces `useLoaderData()` for routes using `cfastJson()`.
59
+ *
60
+ * Returns the same data but wraps arrays and objects that have `_can` with
61
+ * permission helper methods:
62
+ *
63
+ * - **Collections** (arrays with items that have `_can`): array works normally
64
+ * (indexing, `.map()`, etc.) AND has `canAdd()`. Each item has `canEdit()`,
65
+ * `canDelete()`, `canRead()`, `canCreate()`.
66
+ * - **Items** (objects with `_can`): row properties directly accessible,
67
+ * plus `canEdit()`, `canDelete()`, `canRead()`, `canCreate()`.
68
+ * - **Other fields**: passed through unchanged.
69
+ *
70
+ * Each `can*()` method returns an {@link ActionHookResult}.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * const { documents } = useCfastLoader<typeof loader>();
75
+ *
76
+ * // Collection-level
77
+ * documents.canAdd() // -> ActionHookResult
78
+ *
79
+ * // Row fields accessible directly
80
+ * documents[0].title // "Hello World"
81
+ *
82
+ * // Row-level
83
+ * documents[0].canEdit() // -> ActionHookResult
84
+ * documents[0].canDelete() // -> ActionHookResult
85
+ * ```
86
+ */
87
+ declare function useCfastLoader<T extends (...args: any[]) => any = () => Record<string, unknown>>(): CfastLoaderData<ReturnType<T> extends Promise<infer R> ? R : ReturnType<T>>;
88
+
35
89
  type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
36
90
  /** Object with `_action` key and input fields to inject as hidden inputs. */
37
91
  action: Record<string, string | number | boolean | null | undefined>;
@@ -57,4 +111,4 @@ type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action">
57
111
  */
58
112
  declare function ActionForm({ action, children, ...formProps }: ActionFormProps): react_jsx_runtime.JSX.Element;
59
113
 
60
- export { ActionForm, type ActionHookResult, ClientDescriptor, clientDescriptor, useActions };
114
+ export { ActionForm, type ActionHookResult, type CfastCollection, type CfastItem, type CfastItemMethods, type CfastLoaderData, 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,76 @@ 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. The return type is `CfastLoaderData<...>`, which automatically adds permission methods to the TypeScript types so consumers get full autocomplete without local type helpers.
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
+
160
+ #### Permission helper types
161
+
162
+ Exported from `@cfast/actions/client` for consumers who need to annotate variables or build custom abstractions:
163
+
164
+ ```typescript
165
+ import type {
166
+ CfastItem,
167
+ CfastCollection,
168
+ CfastItemMethods,
169
+ CfastLoaderData,
170
+ } from "@cfast/actions/client";
171
+
172
+ /** Permission methods added to items with _can */
173
+ type CfastItemMethods = {
174
+ canRead: () => ActionHookResult;
175
+ canCreate: () => ActionHookResult;
176
+ canEdit: () => ActionHookResult;
177
+ canDelete: () => ActionHookResult;
178
+ };
179
+
180
+ /** An item with _can gets permission methods mixed in */
181
+ type CfastItem<T> = T & CfastItemMethods;
182
+
183
+ /** A collection gets canAdd() plus each item gets permission methods */
184
+ type CfastCollection<T> = CfastItem<T>[] & {
185
+ canAdd: () => ActionHookResult;
186
+ };
187
+
188
+ /** Transforms a loader return type -- arrays become CfastCollection, objects become CfastItem, primitives pass through */
189
+ type CfastLoaderData<T> = {
190
+ [K in keyof T]: T[K] extends (infer U)[]
191
+ ? U extends Record<string, unknown>
192
+ ? CfastCollection<U>
193
+ : T[K]
194
+ : T[K] extends Record<string, unknown>
195
+ ? CfastItem<T[K]>
196
+ : T[K];
197
+ };
198
+ ```
199
+
115
200
  `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
201
 
117
202
  ```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.1",
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",