@kellanjs/actioncraft 0.0.2 → 0.1.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/README.md CHANGED
@@ -1 +1,1154 @@
1
- ⚠️ Under Construction
1
+ ⚠️🚧 The library hasn't reached a stable release yet. Expect bugs and potentially breaking API changes until then.
2
+
3
+ # ActionCraft
4
+
5
+ Streamline your server actions.
6
+
7
+ - **🔒 Full Type Safety** - End-to-end TypeScript inference from input to output
8
+ - **📝 Schema Validation** - Works with Zod and any Standard Schema V1 compliant library
9
+ - **🎯 Fluent API** - Readable, discoverable builder pattern
10
+ - **⚡ Progressive Enhancement** - Works with and without JavaScript enabled
11
+ - **🔄 React Integration** - Built-in support for `useActionState` and form handling
12
+ - **🛡️ Error Management** - Structured error handling with custom error types
13
+ - **🔗 Lifecycle Hooks** - Callbacks for start, success, error, and completion events
14
+ - **📋 Form State Preservation** - Automatic form value retention on validation errors
15
+
16
+ ## Table of Contents
17
+
18
+ - [Quick Start](#quick-start)
19
+ - [Installation](#installation)
20
+ - [Overview](#overview)
21
+ - [Example](#example)
22
+ - [Result Format](#result-format)
23
+ - [Walkthrough](#walkthrough)
24
+ - [.create() - Configure Your Action](#create)
25
+ - [.schemas() - Add Validation](#schemas)
26
+ - [.errors() - Define Custom Errors](#errors)
27
+ - [.action() - Implement Business Logic](#action)
28
+ - [.callbacks() - Add Lifecycle Hooks](#callbacks)
29
+ - [.craft() - Build Your Action](#craft)
30
+ - [Using Your Actions](#using-your-actions)
31
+ - [Basic Usage](#basic-usage)
32
+ - [Error Handling](#error-handling)
33
+ - [React Forms with useActionState](#react-forms-with-useactionstate)
34
+ - [Progressive Enhancement](#progressive-enhancement)
35
+ - [Complete Example](#complete-example)
36
+ - [Integrations](#integrations)
37
+ - [Utilities](#utilities)
38
+ - [React Query](#react-query)
39
+ - [Advanced Features](#advanced-features)
40
+ - [Bind Arguments](#bind-arguments)
41
+ - [Type Inference Utilities](#type-inference-utilities)
42
+
43
+ ## Quick Start
44
+
45
+ ### Installation
46
+
47
+ ```sh
48
+ npm install @kellanjs/actioncraft
49
+ ```
50
+
51
+ ### Overview
52
+
53
+ ActionCraft makes it easy to create type-safe server actions with first-class error-handling support. Here's the basic structure you'll follow:
54
+
55
+ ```typescript
56
+ const action = create(...)
57
+ .schemas(...)
58
+ .errors(...)
59
+ .action(...)
60
+ .callbacks(...)
61
+ .craft();
62
+ ```
63
+
64
+ ActionCraft uses a fluent builder pattern, making it simple to chain one method after the next to create a full-featured server action. The order in which these methods are defined is important for type inference to work properly, so you'll see this same structure repeated often throughout the documentation. Always make sure to chain your methods together like this for the best experience!
65
+
66
+ ### Example
67
+
68
+ With this basic structure in mind, let's see what a more detailed example looks like:
69
+
70
+ ```typescript
71
+ "use server";
72
+
73
+ import { create } from "@kellanjs/actioncraft";
74
+ import { z } from "zod";
75
+
76
+ const newUserInputSchema = z.object({
77
+ name: z.string(),
78
+ email: z.string().email(),
79
+ age: z.number(),
80
+ });
81
+
82
+ export const createNewUser = create()
83
+ // Define the validation schema
84
+ .schemas({
85
+ inputSchema: newUserInputSchema,
86
+ })
87
+ // Define any errors that can occur in your action
88
+ .errors({
89
+ unauthorized: () =>
90
+ ({
91
+ type: "UNAUTHORIZED",
92
+ message: "You don't have permission to create users",
93
+ }) as const,
94
+ emailTaken: (email: string) =>
95
+ ({
96
+ type: "EMAIL_TAKEN",
97
+ message: `The email "${email}" is already registered`,
98
+ email,
99
+ }) as const,
100
+ })
101
+ // Define your server action logic
102
+ .action(async ({ input, errors }) => {
103
+ // These are your validated input values
104
+ const { name, email, age } = input;
105
+
106
+ // If an error occurs, just return the result of the appropriate error function
107
+ if (!hasPermission()) return errors.unauthorized();
108
+
109
+ if (await emailExists(email)) return errors.emailTaken(email);
110
+
111
+ // Additional business logic here...
112
+
113
+ return { newUser };
114
+ })
115
+ // Define lifecycle callbacks (optional)
116
+ .callbacks({
117
+ onSettled: (result) => {
118
+ // Log what happened if you want
119
+ },
120
+ })
121
+ // Finally, build the full type-safe action
122
+ .craft();
123
+ ```
124
+
125
+ ### Result Format
126
+
127
+ Server actions work best when you're returning serializable data. Throwing errors is less effective in this context, because Next.js will sanitize Error class objects that are thrown in your action, leaving you without useful error information on the client. You might see something in development, but in production, if you try to display `error.message`, you'll likely see something along the lines of: "An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details."
128
+
129
+ ActionCraft was designed with this fundamental behavior in mind! Instead of throwing errors, we're working exclusively with structured, serializable objects every step of the way, so errors in your action will always return the data you need.
130
+
131
+ The default result format should feel pretty familiar: `{ success: true, data: T } | { success: false, error: E }`
132
+
133
+ We'll look at errors in more detail later. But here's a simple example of one way you might work with an action result on the client:
134
+
135
+ ```typescript
136
+ const handleCreateNewUser = async (userData) => {
137
+ // Call your server action like you normally would and get the result
138
+ const result = await createNewUser(userData);
139
+
140
+ if (result.success) {
141
+ // If the action was successful, then you get back typed return data
142
+ toast.success("User created:", result.data.newUser);
143
+ } else {
144
+ // If the action was unsuccessful, then you get type-safe error handling
145
+ switch (result.error.type) {
146
+ case "INPUT_VALIDATION":
147
+ handleInputValidationErrorLogic();
148
+ break;
149
+ case "EMAIL_TAKEN":
150
+ showError(`Email ${result.error.email} is already taken`);
151
+ break;
152
+ case "UNAUTHORIZED":
153
+ handleAuthErrorLogic();
154
+ break;
155
+ case "UNHANDLED":
156
+ handleUncaughtExceptions();
157
+ break;
158
+ }
159
+ }
160
+ };
161
+ ```
162
+
163
+ ## Walkthrough
164
+
165
+ Now that we've covered the basic structure of an action and looked at a simple example, let's take a more detailed look at each method in the chain and what it can do.
166
+
167
+ ### .create()
168
+
169
+ Actions always begin with the `create` method. You can use it with no arguments for sensible defaults, or pass a configuration object to customize behavior:
170
+
171
+ ```typescript
172
+ // We're just taking the default configuration here
173
+ const action = create()
174
+ .schemas(...)
175
+ .errors(...)
176
+ .action(...)
177
+ .callbacks(...)
178
+ .craft();
179
+ ```
180
+
181
+ #### Configuration Options
182
+
183
+ When you want to customize behavior, pass a configuration object:
184
+
185
+ ```typescript
186
+ const action = create({
187
+ useActionState: true,
188
+ resultFormat: "api",
189
+ validationErrorFormat: "flattened",
190
+ handleThrownError: (error) => ({
191
+ type: "CUSTOM_ERROR",
192
+ message: error.message,
193
+ }) as const,
194
+ })
195
+ .schemas(...)
196
+ .errors(...)
197
+ .action(...)
198
+ .callbacks(...)
199
+ .craft();
200
+ ```
201
+
202
+ ##### `useActionState: boolean`
203
+
204
+ **Default:** `false`
205
+
206
+ Set to `true` to make your action compatible with React's `useActionState` hook:
207
+
208
+ ```typescript
209
+ const action = create({ useActionState: true })
210
+ .schemas(...)
211
+ .errors(...)
212
+ .action(...)
213
+ .callbacks(...)
214
+ .craft();
215
+
216
+ // Now you can use it with useActionState like this:
217
+ const [state, formAction] = useActionState(action, initial(action));
218
+ ```
219
+
220
+ ##### `resultFormat: "api" | "functional"`
221
+
222
+ **Default:** `"api"`
223
+
224
+ ActionCraft supports two different return formats:
225
+
226
+ - **`"api"`**: `{ success: true, data: T } | { success: false, error: E }`
227
+ - **`"functional"`**: `{ type: "ok", value: T } | { type: "err", error: E }`
228
+
229
+ ##### `validationErrorFormat: "flattened" | "nested"`
230
+
231
+ **Default:** `"flattened"`
232
+
233
+ Controls how validation errors are structured:
234
+
235
+ - **`"flattened"`**: Returns a flat array of error messages
236
+ - **`"nested"`**: Returns a nested object matching your schema structure
237
+
238
+ ##### `handleThrownError: (error: unknown) => UserDefinedError`
239
+
240
+ By default, ActionCraft catches thrown errors and returns a structured error with type `"UNHANDLED"`. You can customize this behavior by passing an error handler function of your own:
241
+
242
+ ```typescript
243
+ const action = create({
244
+ handleThrownError: (error) =>
245
+ ({
246
+ type: "CUSTOM_ERROR",
247
+ message: error instanceof Error ? error.message : "Something went wrong",
248
+ timestamp: new Date().toISOString(),
249
+ }) as const,
250
+ });
251
+ ```
252
+
253
+ You can even implement more complex logic if you want:
254
+
255
+ ```typescript
256
+ handleThrownError: (error: unknown) => {
257
+ if (error instanceof Error) {
258
+ if (error.message.includes("ECONNREFUSED")) {
259
+ return {
260
+ type: "NETWORK_ERROR",
261
+ message: "Unable to connect to external service",
262
+ originalError: error.message,
263
+ } as const;
264
+ }
265
+
266
+ if (error.message.includes("timeout")) {
267
+ return {
268
+ type: "TIMEOUT_ERROR",
269
+ message: "Operation timed out",
270
+ originalError: error.message,
271
+ } as const;
272
+ }
273
+
274
+ if (error.message.includes("unauthorized")) {
275
+ return {
276
+ type: "AUTHENTICATION_ERROR",
277
+ message: "Authentication failed",
278
+ originalError: error.message,
279
+ } as const;
280
+ }
281
+
282
+ // Generic error transformation
283
+ return {
284
+ type: "CUSTOM_HANDLED_ERROR",
285
+ message: `Custom handler caught: ${error.message}`,
286
+ originalError: error.message,
287
+ } as const;
288
+ }
289
+
290
+ // Handle non-Error objects
291
+ return {
292
+ type: "UNKNOWN_ERROR_TYPE",
293
+ message: "An unknown error occurred",
294
+ originalError: String(error),
295
+ } as const;
296
+ };
297
+ ```
298
+
299
+ ActionCraft's types are smart enough to infer all of these possibilities back on the client:
300
+
301
+ ```typescript
302
+ if (!result.success) {
303
+ console.log(result.error.type);
304
+ // type: "INPUT_VALIDATION" | "INITIAL_STATE" | "NETWORK_ERROR" | "TIMEOUT_ERROR" | "AUTHENTICATION_ERROR" | "CUSTOM_HANDLED_ERROR" | "UNKNOWN_ERROR_TYPE"
305
+ }
306
+ ```
307
+
308
+ Pretty cool!
309
+
310
+ ### .schemas()
311
+
312
+ With our action configured, let's add validation using schemas. ActionCraft supports any library that implements the **Standard Schema V1** interface. Validation is handled automatically - you just need to provide the schemas:
313
+
314
+ ```typescript
315
+ const action = create()
316
+ .schemas({
317
+ inputSchema,
318
+ outputSchema,
319
+ bindSchemas,
320
+ })
321
+ .errors(...)
322
+ .action(...)
323
+ .callbacks(...)
324
+ .craft();
325
+ ```
326
+
327
+ #### Schema Options
328
+
329
+ ##### `inputSchema?: StandardSchemaV1`
330
+
331
+ Validates user input passed to the action. If validation fails, an "INPUT_VALIDATION" error is returned to the client.
332
+
333
+ ##### `outputSchema?: StandardSchemaV1`
334
+
335
+ Validates the data returned from your action. If validation fails, an "OUTPUT_VALIDATION" error is passed to callbacks, but the client always receives an "UNHANDLED" error (this is not affected by `handleThrownError`).
336
+
337
+ ##### `bindSchemas?: StandardSchemaV1[]`
338
+
339
+ Validates arguments bound to the action with `.bind()`. If validation fails, a "BIND_ARGS_VALIDATION" error is returned to the client.
340
+
341
+ ### .errors()
342
+
343
+ Now that we have validation set up, let's define custom errors that our action can return. ActionCraft makes error handling really easy by letting you define structured error types:
344
+
345
+ ```typescript
346
+ const action = create()
347
+ .schemas(...)
348
+ .errors({
349
+ unauthorized: () =>
350
+ ({
351
+ type: "UNAUTHORIZED",
352
+ message: "You don't have permission to perform this action",
353
+ }) as const,
354
+ notFound: (id: string) =>
355
+ ({
356
+ type: "NOT_FOUND",
357
+ message: `User with ID ${id} not found`,
358
+ id,
359
+ }) as const,
360
+ emailTaken: (email: string) =>
361
+ ({
362
+ type: "EMAIL_TAKEN",
363
+ message: `The email "${email}" is already registered`,
364
+ email,
365
+ }) as const,
366
+ })
367
+ .action(...)
368
+ .callbacks(...)
369
+ .craft();
370
+ ```
371
+
372
+ #### Error Structure
373
+
374
+ Each error is defined as a function called an **ErrorDefinition**:
375
+
376
+ - **Takes any arguments** you want (like IDs, emails, etc.)
377
+ - **Returns a UserDefinedError** object with:
378
+ - `type`: A string discriminator (required)
379
+ - `message`: Human-readable error message (optional)
380
+ - Any other custom fields you want
381
+
382
+ #### Why the `as const` Assertion?
383
+
384
+ The `as const` assertion is **required** for proper TypeScript inference. It ensures your error types are treated as literal types rather than generic:
385
+
386
+ ```typescript
387
+ // ❌ Without 'as const' - TypeScript infers { type: string, message: string }
388
+ badErrorDefinition: () => ({ type: "ERROR", message: "Something went wrong" });
389
+
390
+ // ✅ With 'as const' - TypeScript infers { type: "ERROR", message: "Something went wrong" }
391
+ goodErrorDefinition: () =>
392
+ ({ type: "ERROR", message: "Something went wrong" }) as const;
393
+ ```
394
+
395
+ #### Reusing Common Errors
396
+
397
+ Since error definitions are just functions, you can easily share common errors between actions:
398
+
399
+ ```typescript
400
+ // common-errors.ts
401
+ export const unauthorized = () =>
402
+ ({
403
+ type: "UNAUTHORIZED",
404
+ message: "You don't have permission to perform this action",
405
+ }) as const;
406
+
407
+ export const rateLimited = () =>
408
+ ({
409
+ type: "RATE_LIMITED",
410
+ message: "Too many requests. Please try again later.",
411
+ }) as const;
412
+
413
+ export const notFound = (resource: string, id: string) =>
414
+ ({
415
+ type: "NOT_FOUND",
416
+ message: `${resource} with ID ${id} not found`,
417
+ resource,
418
+ id,
419
+ }) as const;
420
+ ```
421
+
422
+ ```typescript
423
+ // my-action.ts
424
+ export const action = create()
425
+ .schemas(...)
426
+ .errors({
427
+ // Easily use common shared errors
428
+ unauthorized,
429
+ rateLimited,
430
+ notFound,
431
+ // Plus any action-specific errors
432
+ emailTaken: (email: string) =>
433
+ ({ type: "EMAIL_TAKEN", email }) as const,
434
+ })
435
+ .action(...)
436
+ .callbacks(...)
437
+ .craft();
438
+ ```
439
+
440
+ #### Using Errors in Your Action
441
+
442
+ Once defined, you can use these errors in your action logic. When an error occurs, just call and return that particular error function:
443
+
444
+ ```typescript
445
+ const action = create()
446
+ .schemas(...)
447
+ .errors(...)
448
+ .action(async ({ input, errors }) => {
449
+ // Check permissions
450
+ if (!hasPermission(input.userId)) {
451
+ return errors.unauthorized();
452
+ }
453
+
454
+ // Find user
455
+ const user = await findUser(input.userId);
456
+ if (!user) {
457
+ return errors.notFound(input.userId);
458
+ }
459
+
460
+ // Success case
461
+ return { user };
462
+ })
463
+ .callbacks(...)
464
+ .craft();
465
+ ```
466
+
467
+ ### .action()
468
+
469
+ The `action` method is where you implement the core functionality of your server action. ActionCraft provides several helpful parameters to make things quick and easy:
470
+
471
+ ```typescript
472
+ const action = create()
473
+ .schemas(...)
474
+ .errors(...)
475
+ .action(async ({ input, bindArgs, errors, metadata }) => {
476
+ // Action logic here
477
+ })
478
+ .callbacks(...)
479
+ .craft();
480
+ ```
481
+
482
+ #### Action Parameters
483
+
484
+ ##### `input`
485
+
486
+ Contains the validated input values (or `undefined` if no input schema was provided).
487
+
488
+ ##### `bindArgs`
489
+
490
+ Contains an array of validated bound argument values (or an empty array if no bind schemas were provided).
491
+
492
+ ##### `errors`
493
+
494
+ Contains all the ErrorDefinition functions you defined in the `.errors()` method.
495
+
496
+ ##### `metadata`
497
+
498
+ Contains additional request information:
499
+
500
+ - `rawInput`: The original, unvalidated input data
501
+ - `rawBindArgs`: The original, unvalidated bound arguments array
502
+ - `prevState`: Previous state (when using `useActionState`)
503
+
504
+ ### .callbacks()
505
+
506
+ Sometimes you need to hook into the action lifecycle for logging, analytics, or other side effects. The `callbacks` method lets you define functions that run at key moments:
507
+
508
+ ```typescript
509
+ const action = create()
510
+ .schemas(...)
511
+ .errors(...)
512
+ .action(...)
513
+ .callbacks({
514
+ onStart: ({metadata}) => { ... },
515
+ onSuccess: ({data}) => { ... },
516
+ onError: ({error}) => { ... },
517
+ onSettled: ({ result }) => { ... },
518
+ })
519
+ .craft();
520
+ ```
521
+
522
+ #### Callback Types
523
+
524
+ ##### `onStart?: (params: { metadata }) => Promise<void> | void`
525
+
526
+ Executes first, before any validation or action logic has occurred.
527
+
528
+ ##### `onSuccess?: (params: { data, metadata }) => Promise<void> | void`
529
+
530
+ Executes when your action completes successfully. The `data` parameter contains your action's return value.
531
+
532
+ ##### `onError?: (params: { error, metadata }) => Promise<void> | void`
533
+
534
+ Executes when your action returns an error (custom errors, validation failures, or unhandled exceptions).
535
+
536
+ ##### `onSettled?: (params: { result, metadata }) => Promise<void> | void`
537
+
538
+ Executes after your action completes, regardless of success or failure. Useful for cleanup or logging.
539
+
540
+ Note: All callback methods support async operations and won't affect your action's result, even if they throw errors.
541
+
542
+ ### .craft()
543
+
544
+ Every action should end with the `craft` method. It doesn't take any arguments or anything like that. Its only purpose is to build the final action based on everything that has been defined in the previous methods.
545
+
546
+ ```typescript
547
+ const action = create()
548
+ .schemas(...)
549
+ .errors(...)
550
+ .action(...)
551
+ .callbacks(...)
552
+ .craft(); // Returns your fully-typed server action
553
+ ```
554
+
555
+ This is the final step in the builder chain. Once you call `craft()`, you have a complete, type-safe server action ready to export and use in your application.
556
+
557
+ ## Using Your Actions
558
+
559
+ Now that you know how to build actions with ActionCraft, let's see how you can use them in your application.
560
+
561
+ ### Basic Usage
562
+
563
+ You can call your action like any async function:
564
+
565
+ ```typescript
566
+ const result = await createNewUser({
567
+ name: "John",
568
+ email: "john@example.com",
569
+ age: 25,
570
+ });
571
+
572
+ if (result.success) {
573
+ // Action succeeded
574
+ console.log("User created:", result.data.newUser);
575
+ } else {
576
+ // Action failed
577
+ console.log("Error:", result.error.type);
578
+ console.log("Message:", result.error.message);
579
+ }
580
+ ```
581
+
582
+ ### Error Handling
583
+
584
+ Thanks to some carefully crafted types, you can always determine exactly what kind of error you're dealing with:
585
+
586
+ ```typescript
587
+ const result = await createNewUser(formData);
588
+
589
+ if (!result.success) {
590
+ switch (result.error.type) {
591
+ case "INPUT_VALIDATION":
592
+ showValidationErrors(result.error.issues);
593
+ break;
594
+ case "UNAUTHORIZED":
595
+ redirectToLogin();
596
+ break;
597
+ case "EMAIL_TAKEN":
598
+ showError(`Email ${result.error.email} is already taken`);
599
+ break;
600
+ case "UNHANDLED":
601
+ showGenericError();
602
+ break;
603
+ }
604
+ }
605
+ ```
606
+
607
+ ### React Forms with useActionState
608
+
609
+ For React forms, you can use actions configured for `useActionState`:
610
+
611
+ ```typescript
612
+ const updateUser = create({ useActionState: true })
613
+ .schemas(...)
614
+ .errors(...)
615
+ .action(...)
616
+ .callbacks(...)
617
+ .craft();
618
+ ```
619
+
620
+ When `useActionState: true` is set, your action's return type changes to include a `values` field. This field contains the raw input values that were last passed to the action. However, on successful executions where an input schema is defined, it contains the validated input values instead.
621
+
622
+ #### The `initial()` Helper
623
+
624
+ When using `useActionState`, you have to provide the hook with a proper initial state that matches the return type of your action. That's where ActionCraft's `initial` function comes in. It returns a special error object with type `"INITIAL_STATE"` that you can use to detect when the form hasn't been submitted yet:
625
+
626
+ ```typescript
627
+ function UserForm() {
628
+ const [state, action] = useActionState(updateUser, initial(updateUser));
629
+ // `state` initializes as:
630
+ // { success: false,
631
+ // error: { type: "INITIAL_STATE", message: "Action has not been executed yet" },
632
+ // values: undefined }
633
+
634
+ return (
635
+ <form action={action}>
636
+ <input name="name" defaultValue={state.values?.name} />
637
+ <input name="email" defaultValue={state.values?.email} />
638
+
639
+ {!state.success && state.error.type !== "INITIAL_STATE" && (
640
+ <p>Error: {state.error.message}</p>
641
+ )}
642
+
643
+ <button type="submit">Update User</button>
644
+ </form>
645
+ );
646
+ }
647
+ ```
648
+
649
+ ### Progressive Enhancement
650
+
651
+ By providing a schema which supports FormData, your action can work with or without JavaScript. For example, when using Zod, you can use the `zod-form-data` library to provide FormData support for your action:
652
+
653
+ ```typescript
654
+ // This action handles FormData from server-side form submissions
655
+ const serverAction = create({ useActionState: true })
656
+ .schemas({
657
+ inputSchema: zfd.formData({
658
+ name: zfd.text(),
659
+ email: zfd.text(z.string().email()),
660
+ }),
661
+ })
662
+ .action(async ({ input }) => {
663
+ // Save the validated user data to database
664
+ const user = await db.user.create({
665
+ data: {
666
+ name: input.name,
667
+ email: input.email,
668
+ },
669
+ });
670
+
671
+ // Send welcome email
672
+ await sendWelcomeEmail(user.email);
673
+
674
+ return { user };
675
+ })
676
+ .craft();
677
+ ```
678
+
679
+ ## Complete Example
680
+
681
+ Now that we've gone over how to create actions and how to use them on the client, let's check out an example that puts a lot of these ideas together:
682
+
683
+ ```typescript
684
+ "use server";
685
+
686
+ import { create } from "@kellanjs/actioncraft";
687
+ import { revalidatePath } from "next/cache";
688
+ import { z } from "zod";
689
+
690
+ const updateProfileSchema = z.object({
691
+ name: z.string().min(1, "Name is required"),
692
+ email: z.string().email("Invalid email"),
693
+ bio: z.string().max(500, "Bio must be under 500 characters"),
694
+ });
695
+
696
+ export const updateProfile = create({ useActionState: true })
697
+ .schemas({ inputSchema: updateProfileSchema })
698
+ .errors({
699
+ unauthorized: () =>
700
+ ({ type: "UNAUTHORIZED", message: "Please log in" }) as const,
701
+ emailTaken: (email: string) =>
702
+ ({
703
+ type: "EMAIL_TAKEN",
704
+ message: `Email ${email} is already taken`,
705
+ email,
706
+ }) as const,
707
+ rateLimited: () =>
708
+ ({
709
+ type: "RATE_LIMITED",
710
+ message: "Too many requests. Please try again later.",
711
+ }) as const,
712
+ })
713
+ .action(async ({ input, errors }) => {
714
+ // Check authentication
715
+ const session = await getSession();
716
+ if (!session) return errors.unauthorized();
717
+
718
+ // Check rate limiting
719
+ if (await isRateLimited(session.userId)) {
720
+ return errors.rateLimited();
721
+ }
722
+
723
+ // Check if email is taken
724
+ const existingUser = await getUserByEmail(input.email);
725
+ if (existingUser && existingUser.id !== session.userId) {
726
+ return errors.emailTaken(input.email);
727
+ }
728
+
729
+ // Update user
730
+ const updatedUser = await updateUser(session.userId, input);
731
+
732
+ return { user: updatedUser };
733
+ })
734
+ .callbacks({
735
+ onStart: ({ metadata }) => {
736
+ // Track when profile updates begin
737
+ analytics.track("profile_update_started", {
738
+ userId: metadata.prevState?.success
739
+ ? metadata.prevState.data?.user?.id
740
+ : null,
741
+ });
742
+ },
743
+ onSuccess: ({ data }) => {
744
+ revalidatePath("/profile");
745
+ logUserActivity(data.user.id, "profile_updated");
746
+ },
747
+ onError: ({ error }) => {
748
+ if (error.type === "UNHANDLED") {
749
+ logError("Profile update failed", error);
750
+ }
751
+ },
752
+ onSettled: ({ result }) => {
753
+ // Log completion for monitoring and analytics
754
+ analytics.track("profile_update_completed", {
755
+ success: result.success,
756
+ });
757
+ },
758
+ })
759
+ .craft();
760
+ ```
761
+
762
+ ```typescript
763
+ "use client";
764
+
765
+ import { useActionState } from "react";
766
+ import { updateProfile } from "./actions";
767
+ import { initial } from "@kellanjs/actioncraft";
768
+
769
+ export default function ProfileForm() {
770
+ const [state, action] = useActionState(updateProfile, initial(updateProfile));
771
+
772
+ return (
773
+ <form action={action}>
774
+ <input
775
+ name="name"
776
+ placeholder="Name"
777
+ defaultValue={state.values?.name}
778
+ />
779
+
780
+ <input
781
+ name="email"
782
+ type="email"
783
+ placeholder="Email"
784
+ defaultValue={state.values?.email}
785
+ />
786
+
787
+ <textarea
788
+ name="bio"
789
+ placeholder="Bio"
790
+ defaultValue={state.values?.bio}
791
+ />
792
+
793
+ {state.success && (
794
+ <div className="success">
795
+ <p>Profile updated successfully!</p>
796
+ </div>
797
+ )}
798
+
799
+ {!state.success && state.error.type !== "INITIAL_STATE" && (
800
+ <div className="error">
801
+ {state.error.type === "EMAIL_TAKEN" && (
802
+ <p>That email is already taken. Please use a different one.</p>
803
+ )}
804
+ {state.error.type === "UNAUTHORIZED" && (
805
+ <p>Please log in to update your profile.</p>
806
+ )}
807
+ {state.error.type === "RATE_LIMITED" && (
808
+ <p>Too many requests. Please try again later.</p>
809
+ )}
810
+ {state.error.type === "INPUT_VALIDATION" && (
811
+ <ul>
812
+ {state.error.issues.map((issue, i) => (
813
+ <li key={i}>{issue.message}</li>
814
+ ))}
815
+ </ul>
816
+ )}
817
+ </div>
818
+ )}
819
+
820
+ <button type="submit">Update Profile</button>
821
+ </form>
822
+ );
823
+ }
824
+ ```
825
+
826
+ ## Integrations
827
+
828
+ ### Utilities
829
+
830
+ ActionCraft comes with several utilities intended to make it easier to integrate with libraries like React Query. Let's take a quick look.
831
+
832
+ #### `ActionCraftError`
833
+
834
+ A standard Error class that wraps ActionCraft error data while preserving type information:
835
+
836
+ ```typescript
837
+ // The error preserves all your action's error data in the `cause` property
838
+ if (error instanceof ActionCraftError) {
839
+ console.log(error.message); // "ActionCraft Error: EMAIL_TAKEN - Email already exists"
840
+ console.log(error.cause); // { type: "EMAIL_TAKEN", message: "Email already exists", email: "user@example.com" }
841
+ }
842
+ ```
843
+
844
+ #### `unwrap(result)`
845
+
846
+ Extracts the data from a successful result or throws an `ActionCraftError`:
847
+
848
+ ```typescript
849
+ const result = await myAction(data);
850
+ const userData = unwrap(result); // Throws if result.success === false
851
+ ```
852
+
853
+ #### `throwable(action)`
854
+
855
+ Wraps an action to automatically throw errors as `ActionCraftError` instances instead of returning them:
856
+
857
+ ```typescript
858
+ const throwingAction = throwable(myAction);
859
+ const userData = await throwingAction(data); // Throws on error
860
+ ```
861
+
862
+ #### `isActionCraftError(error, action)`
863
+
864
+ Type guard that enables full type inference for your action's specific error types:
865
+
866
+ ```typescript
867
+ try {
868
+ const data = await throwable(updateUser)(userData);
869
+ } catch (error) {
870
+ if (isActionCraftError(error, updateUser)) {
871
+ // error.cause is now typed with updateUser's possible error types
872
+ switch (error.cause.type) {
873
+ case "EMAIL_TAKEN": // Fully typed
874
+ case "UNAUTHORIZED": // Fully typed
875
+ case "INPUT_VALIDATION": // Fully typed
876
+ // Handle each error type
877
+ break;
878
+ }
879
+ }
880
+ }
881
+ ```
882
+
883
+ **Note:** The `action` argument is required for proper type inference - it tells TypeScript which action's error types to expect.
884
+
885
+ ### React Query
886
+
887
+ Now let's see how to use these utilities most effectively when working with React Query!
888
+
889
+ #### Usage with useQuery
890
+
891
+ Use the `unwrap()` utility for data fetching queries:
892
+
893
+ ```typescript
894
+ import { useQuery } from "@tanstack/react-query";
895
+ import { fetchUserProfile } from "./actions";
896
+ import { unwrap } from "@kellanjs/actioncraft";
897
+
898
+ function UserProfile({ userId }: { userId: string }) {
899
+ const { data, error, isLoading } = useQuery({
900
+ queryKey: ["user", userId],
901
+ queryFn: async () => {
902
+ const result = await fetchUserProfile({ userId });
903
+ return unwrap(result); // Throws ActionCraftError on failure
904
+ },
905
+ });
906
+
907
+ if (isLoading) return <div>Loading...</div>;
908
+
909
+ if (error) {
910
+ if (isActionCraftError(error, fetchUserProfile)) {
911
+ // Full type inference for your action's specific error types
912
+ switch (error.cause.type) {
913
+ case "USER_NOT_FOUND":
914
+ return <div>User not found</div>;
915
+ case "UNAUTHORIZED":
916
+ return <div>Please log in</div>;
917
+ default:
918
+ return <div>Error: {error.cause.message}</div>;
919
+ }
920
+ }
921
+ return <div>Unexpected error occurred</div>;
922
+ }
923
+
924
+ return (
925
+ <div>
926
+ <h1>{data.user.name}</h1>
927
+ <p>{data.user.email}</p>
928
+ </div>
929
+ );
930
+ }
931
+ ```
932
+
933
+ If you're like me, and that query function is too verbose for your tastes, you can simplify it:
934
+
935
+ ```typescript
936
+ queryFn: () => unwrap(fetchUserProfile({ userId }));
937
+ ```
938
+
939
+ `unwrap` is designed to handle both Results and Promises of Results, and since React Query will handle awaiting the resolved Promise, this syntax will work just fine.
940
+
941
+ #### Usage with useMutation
942
+
943
+ Use the `throwable()` utility for mutations:
944
+
945
+ ```typescript
946
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
947
+ import { updateUserProfile } from "./actions";
948
+ import { throwable, isActionCraftError } from "@kellanjs/actioncraft";
949
+
950
+ function EditProfileForm() {
951
+ const queryClient = useQueryClient();
952
+
953
+ const mutation = useMutation({
954
+ mutationFn: throwable(updateUserProfile), // Throws ActionCraftError on failure
955
+ onSuccess: (data) => {
956
+ // data is properly typed as your action's success data
957
+ queryClient.invalidateQueries({ queryKey: ["user", data.user.id] });
958
+ },
959
+ onError: (error) => {
960
+ if (isActionCraftError(error, updateUserProfile)) {
961
+ // Handle specific error types with full type safety
962
+ switch (error.cause.type) {
963
+ case "UNAUTHORIZED":
964
+ redirectToLogin();
965
+ break;
966
+ case "INPUT_VALIDATION":
967
+ showValidationErrors(error.cause.issues);
968
+ break;
969
+ case "EMAIL_TAKEN":
970
+ showToast(`Email ${error.cause.email} is already taken`);
971
+ break;
972
+ default:
973
+ showToast(error.cause.message || "Update failed");
974
+ }
975
+ } else {
976
+ showToast("An unexpected error occurred");
977
+ }
978
+ },
979
+ });
980
+
981
+ const handleSubmit = (formData: FormData) => {
982
+ mutation.mutate(formData);
983
+ };
984
+
985
+ return (
986
+ <form onSubmit={handleSubmit}>
987
+ {/* form fields */}
988
+ <button type="submit" disabled={mutation.isPending}>
989
+ {mutation.isPending ? "Updating..." : "Update Profile"}
990
+ </button>
991
+ </form>
992
+ );
993
+ }
994
+ ```
995
+
996
+ ## Advanced Features
997
+
998
+ ### Bind Arguments
999
+
1000
+ ActionCraft supports binding arguments to actions. Just provide schemas, and you'll get the validated bindArgs values to use in the action.
1001
+
1002
+ If validation fails, an error with type "BIND_ARGS_VALIDATION" is returned to the client.
1003
+
1004
+ #### Example: Multi-Tenant Action
1005
+
1006
+ ```typescript
1007
+ const createPost = create()
1008
+ .schemas({
1009
+ bindSchemas: [z.string()], // Organization ID
1010
+ inputSchema: z.object({
1011
+ title: z.string(),
1012
+ content: z.string(),
1013
+ }),
1014
+ })
1015
+ .action(async ({ bindArgs, input }) => {
1016
+ const [organizationId] = bindArgs;
1017
+
1018
+ const post = await db.post.create({
1019
+ data: {
1020
+ ...input,
1021
+ organizationId,
1022
+ },
1023
+ });
1024
+
1025
+ return { post };
1026
+ })
1027
+ .craft();
1028
+
1029
+ // Create organization-specific actions
1030
+ const createPostForOrgA = createPost.bind(null, "org-a-id");
1031
+ const createPostForOrgB = createPost.bind(null, "org-b-id");
1032
+
1033
+ // Each bound action automatically includes the correct org ID
1034
+ const result = await createPostForOrgA({
1035
+ title: "My Post",
1036
+ content: "Post content...",
1037
+ });
1038
+ ```
1039
+
1040
+ #### Example: Configuration Binding
1041
+
1042
+ ```typescript
1043
+ const sendEmail = create()
1044
+ .schemas({
1045
+ bindSchemas: [
1046
+ z.object({
1047
+ apiKey: z.string(),
1048
+ fromEmail: z.string(),
1049
+ }),
1050
+ ],
1051
+ inputSchema: z.object({
1052
+ to: z.string().email(),
1053
+ subject: z.string(),
1054
+ body: z.string(),
1055
+ }),
1056
+ })
1057
+ .action(async ({ bindArgs, input }) => {
1058
+ const [config] = bindArgs;
1059
+
1060
+ // Use the bound configuration
1061
+ const emailService = new EmailService(config.apiKey);
1062
+ const result = await emailService.send({
1063
+ from: config.fromEmail,
1064
+ to: input.to,
1065
+ subject: input.subject,
1066
+ body: input.body,
1067
+ });
1068
+
1069
+ return { messageId: result.id };
1070
+ })
1071
+ .craft();
1072
+
1073
+ // Create environment-specific email actions
1074
+ const sendProductionEmail = sendEmail.bind(null, {
1075
+ apiKey: process.env.PROD_EMAIL_API_KEY,
1076
+ fromEmail: "noreply@company.com",
1077
+ });
1078
+
1079
+ const sendDevelopmentEmail = sendEmail.bind(null, {
1080
+ apiKey: process.env.DEV_EMAIL_API_KEY,
1081
+ fromEmail: "dev@company.com",
1082
+ });
1083
+ ```
1084
+
1085
+ ## Type Inference Utilities
1086
+
1087
+ ActionCraft provides several type inference utilities to extract types from your actions for use elsewhere in your application:
1088
+
1089
+ ### Available Utilities
1090
+
1091
+ #### `InferInput<Action>`
1092
+
1093
+ Infers the input type that the action expects. Returns `unknown` if no input schema is defined.
1094
+
1095
+ #### `InferResult<Action>`
1096
+
1097
+ Infers the complete result type, including both success and error cases. Respects your action's configuration (api/functional format, useActionState, etc.).
1098
+
1099
+ #### `InferData<Action>`
1100
+
1101
+ Infers the success data type from your action's return value.
1102
+
1103
+ #### `InferErrors<Action>`
1104
+
1105
+ Infers all possible error types your action can return, including custom errors, validation errors, and thrown errors.
1106
+
1107
+ ### Type Extraction Example
1108
+
1109
+ ```typescript
1110
+ import { create } from "@kellanjs/actioncraft";
1111
+ import type {
1112
+ InferInput,
1113
+ InferResult,
1114
+ InferData,
1115
+ InferErrors,
1116
+ } from "@kellanjs/actioncraft";
1117
+ import { z } from "zod";
1118
+
1119
+ const updateUser = create()
1120
+ .schemas({
1121
+ inputSchema: z.object({
1122
+ id: z.string(),
1123
+ name: z.string(),
1124
+ email: z.string().email(),
1125
+ }),
1126
+ })
1127
+ .errors({
1128
+ notFound: (id: string) => ({ type: "NOT_FOUND", id }) as const,
1129
+ unauthorized: () => ({ type: "UNAUTHORIZED" }) as const,
1130
+ })
1131
+ .action(async ({ input, errors }) => {
1132
+ // ... implementation
1133
+ return { user: input, updatedAt: new Date() };
1134
+ })
1135
+ .craft();
1136
+
1137
+ type ActionInput = InferInput<typeof updateUser>;
1138
+ // { id: string, name: string, email: string }
1139
+
1140
+ type ActionResult = InferResult<typeof updateUser>;
1141
+ // { success: true, data: { user: UserInput, updatedAt: Date } } |
1142
+ // { success: false, error: { type: "NOT_FOUND", id: string } | ... }
1143
+
1144
+ type ActionData = InferData<typeof updateUser>;
1145
+ // { user: { id: string, name: string, email: string }, updatedAt: Date }
1146
+
1147
+ type ActionErrors = InferErrors<typeof updateUser>;
1148
+ // { type: "NOT_FOUND", id: string } | { type: "UNAUTHORIZED" } |
1149
+ // { type: "INPUT_VALIDATION", issues: ... } | { type: "UNHANDLED", message: string }
1150
+ ```
1151
+
1152
+ ## License
1153
+
1154
+ ActionCraft is open source under the terms of the [MIT license](https://github.com/kellanjs/actioncraft/blob/main/LICENSE).