@jay-framework/fullstack-component 0.8.0 → 0.10.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
@@ -26,16 +26,118 @@ The `@jay-framework/fullstack-component` package provides a fluent builder API f
26
26
 
27
27
  ## Rendering Phases
28
28
 
29
- - **Slow Rendering**: Use for static data that doesn't change often
30
- - **Fast Rendering**: Use for dynamic data that can be cached
31
- - **Partial Renders**: Only update the parts of the view state that change
32
- - **Carry Forward**: Pass data between render phases to avoid recomputation
29
+ Jay Stack components support three rendering phases, each optimized for different data lifecycles:
33
30
 
34
- | Rendering Phase | Rendered Where | When Rendered | Carry Forward |
35
- | ---------------------- | -------------- | ------------------------------ | ------------------ |
36
- | Slowly Changing Render | SSR | Build time or data change time | Slowly Fast |
37
- | Fast Changing Render | SSR | Page serving | Fast Interactive |
38
- | Interactive Render | CSR | User interaction | - |
31
+ | Rendering Phase | Rendered Where | When Rendered | Use Case |
32
+ | ------------------ | -------------- | ------------------------------ | --------------------------------- |
33
+ | **Slow (Static)** | SSR | Build time or data change time | Product names, descriptions, SKUs |
34
+ | **Fast (Dynamic)** | SSR | Page serving (per request) | Inventory, pricing, availability |
35
+ | **Interactive** | CSR | User interaction | Cart count, user selections |
36
+
37
+ ### Phase-Based Type Validation
38
+
39
+ Jay Stack automatically generates **phase-specific ViewState types** from your contracts, ensuring that each render function can only return properties appropriate for its phase. This prevents accidentally including fast-changing data in slow renders or slow data in fast renders.
40
+
41
+ **Benefits:**
42
+
43
+ - 🛡️ **Compile-time safety**: TypeScript catches phase violations before deployment
44
+ - 📝 **Self-documenting**: The contract explicitly shows which data is static vs dynamic
45
+ - ⚡ **Performance**: Ensures optimal caching and rendering strategies
46
+ - 🎯 **Intent clarity**: Makes data lifecycle explicit in the contract
47
+
48
+ **Example:**
49
+
50
+ ```typescript
51
+ // TypeScript automatically knows which properties are valid in each phase
52
+ .withSlowlyRender(async () => {
53
+ return partialRender({
54
+ productName: 'Widget', // ✅ Allowed (slow phase)
55
+ price: 29.99, // ❌ TypeScript Error: Not in SlowViewState
56
+ }, {});
57
+ })
58
+ .withFastRender(async () => {
59
+ return partialRender({
60
+ price: 29.99, // ✅ Allowed (fast phase)
61
+ productName: 'Widget', // ❌ TypeScript Error: Not in FastViewState
62
+ }, {});
63
+ })
64
+ ```
65
+
66
+ ### Specifying Phases in Contracts
67
+
68
+ You can annotate your contract properties with the `phase` attribute to control when data is rendered:
69
+
70
+ #### Jay HTML Contract
71
+
72
+ ```html
73
+ <html>
74
+ <head>
75
+ <script type="application/yaml-jay">
76
+ data:
77
+ # Static data - rendered at build time
78
+ - {tag: productName, dataType: string, phase: slow}
79
+ - {tag: description, dataType: string, phase: slow}
80
+ - {tag: sku, dataType: string, phase: slow}
81
+
82
+ # Dynamic data - rendered per request
83
+ - {tag: price, dataType: number, phase: fast}
84
+ - {tag: inStock, dataType: boolean, phase: fast}
85
+
86
+ # No phase specified = defaults to 'slow'
87
+ - {tag: category, dataType: string}
88
+ </script>
89
+ </head>
90
+ <body>
91
+ <div>
92
+ <h1>{productName}</h1>
93
+ <p>{description}</p>
94
+ <p>Price: ${price}</p>
95
+ </div>
96
+ </body>
97
+ </html>
98
+ ```
99
+
100
+ #### Jay Contract (Headless)
101
+
102
+ ```yaml
103
+ name: product-contract
104
+ tags:
105
+ # Static product information
106
+ - tag: productName
107
+ dataType: string
108
+ phase: slow
109
+
110
+ - tag: description
111
+ dataType: string
112
+ phase: slow
113
+
114
+ - tag: sku
115
+ dataType: string
116
+ phase: slow
117
+
118
+ # Dynamic pricing and availability
119
+ - tag: price
120
+ dataType: number
121
+ phase: fast
122
+
123
+ - tag: inStock
124
+ dataType: boolean
125
+ phase: fast
126
+
127
+ # Interactive elements go in refs, not data
128
+ interactive:
129
+ - tag: addToCartButton
130
+ elementType: [button]
131
+ ```
132
+
133
+ **Phase Rules:**
134
+
135
+ - `slow`: Value is set at build time (default if not specified)
136
+ - `fast`: Value is set at request time
137
+ - `fast+interactive`: Value is set at request time and can be modified on the client
138
+ - `interactive` tags are implicitly `fast+interactive` and go into the `Refs` type, not `ViewState`
139
+ - For nested objects, the parent's phase serves as the default for children
140
+ - Array children cannot have an earlier phase than their parent array
39
141
 
40
142
  ## Installation
41
143
 
@@ -57,12 +159,15 @@ For headless components, create a Jay Contract file (`my-contract.jay-contract`)
57
159
  <head>
58
160
  <script type="application/yaml-jay">
59
161
  data:
60
- id: string
61
- name: string
62
- age: number
63
- address: string
64
- stars: number
65
- rating: number
162
+ # User profile data - slow changing
163
+ - {tag: id, dataType: string, phase: slow}
164
+ - {tag: name, dataType: string, phase: slow}
165
+ - {tag: age, dataType: number, phase: slow}
166
+ - {tag: address, dataType: string, phase: slow}
167
+
168
+ # User ratings - fast changing
169
+ - {tag: stars, dataType: number, phase: fast}
170
+ - {tag: rating, dataType: number, phase: fast}
66
171
  </script>
67
172
  </head>
68
173
  <body>
@@ -84,18 +189,31 @@ For headless components, create a Jay Contract file (`my-contract.jay-contract`)
84
189
  ```yaml
85
190
  name: my-contract
86
191
  tags:
192
+ # User profile data - slow changing
87
193
  - tag: id
88
194
  dataType: string
195
+ phase: slow
196
+
89
197
  - tag: name
90
198
  dataType: string
199
+ phase: slow
200
+
91
201
  - tag: age
92
202
  dataType: number
203
+ phase: slow
204
+
93
205
  - tag: address
94
206
  dataType: string
207
+ phase: slow
208
+
209
+ # User ratings - fast changing
95
210
  - tag: stars
96
211
  dataType: number
212
+ phase: fast
213
+
97
214
  - tag: rating
98
215
  dataType: number
216
+ phase: fast
99
217
  ```
100
218
 
101
219
  ### 2. Generate Definition Files
@@ -106,6 +224,47 @@ Run the Jay CLI to generate TypeScript definition files from your Jay HTML or co
106
224
  jay-cli definitions <path to your sources>
107
225
  ```
108
226
 
227
+ This will generate a `.d.ts` file with:
228
+
229
+ - **Full ViewState**: All properties from your contract
230
+ - **Phase-specific ViewStates**: Separate types for `Slow`, `Fast`, and `Interactive` phases
231
+ - **Contract type**: A `JayContract` type that includes all ViewState types
232
+
233
+ **Generated Types Example** (`my-component.jay-html.d.ts`):
234
+
235
+ ```typescript
236
+ import { JayContract } from '@jay-framework/fullstack-component';
237
+
238
+ // Full ViewState - all properties
239
+ export interface MyComponentViewState {
240
+ id: string;
241
+ name: string;
242
+ age: number;
243
+ address: string;
244
+ stars: number;
245
+ rating: number;
246
+ }
247
+
248
+ export interface MyComponentElementRefs {}
249
+
250
+ // Phase-specific ViewStates (automatically generated)
251
+ export type MyComponentSlowViewState = Pick<
252
+ MyComponentViewState,
253
+ 'id' | 'name' | 'age' | 'address'
254
+ >;
255
+ export type MyComponentFastViewState = Pick<MyComponentViewState, 'stars' | 'rating'>;
256
+ export type MyComponentInteractiveViewState = {};
257
+
258
+ // Contract type with all ViewState types
259
+ export type MyComponentContract = JayContract<
260
+ MyComponentViewState,
261
+ MyComponentElementRefs,
262
+ MyComponentSlowViewState,
263
+ MyComponentFastViewState,
264
+ MyComponentInteractiveViewState
265
+ >;
266
+ ```
267
+
109
268
  ### 3. Build Your Full-Stack Component
110
269
 
111
270
  ```typescript
@@ -258,16 +417,24 @@ After props, the function receives the services declared using `withServices`.
258
417
 
259
418
  The function should return one of:
260
419
 
261
- - `PartialRender<ViewState, CarryForward>` - for partial rendering
420
+ - `PartialRender<SlowViewState, CarryForward>` - for partial rendering
262
421
  - `ServerError5xx` - for server errors
263
422
  - `Redirect3xx` - for semi-static redirects
264
423
 
424
+ **Type Safety:** TypeScript automatically validates that `partialRender` only receives properties from `SlowViewState` (as defined by `phase: slow` in your contract).
425
+
265
426
  ```typescript
266
427
  makeJayStackComponent<MyComponentContract>()
267
428
  .withServices(DATABASE_SERVICE)
268
429
  .withSlowlyRender(async (props, database: Database) => {
269
430
  const data = await database.getData();
270
- return partialRender({ someKey: data.value }, { carryForwardKey: data.id });
431
+ return partialRender(
432
+ {
433
+ productName: data.name, // ✅ OK if phase: slow
434
+ // price: data.price, // ❌ TypeScript error if phase: fast
435
+ },
436
+ { carryForwardKey: data.id },
437
+ );
271
438
  });
272
439
  ```
273
440
 
@@ -284,17 +451,26 @@ After that, the function receives the services declared using `withServices`.
284
451
 
285
452
  The function should return one of:
286
453
 
287
- - `PartialRender<ViewState, CarryForward>` - for partial rendering
454
+ - `PartialRender<FastViewState, CarryForward>` - for partial rendering
288
455
  - `ServerError5xx` - for server errors
289
456
  - `ClientError4xx` - for client errors
290
457
  - `Redirect3xx` - for dynamic redirects
291
458
 
459
+ **Type Safety:** TypeScript automatically validates that `partialRender` only receives properties from `FastViewState` (as defined by `phase: fast` in your contract).
460
+
292
461
  ```typescript
293
462
  makeJayStackComponent<MyComponentContract>()
294
463
  .withServices(INVENTORY_SERVICE)
295
464
  .withFastRender(async (props, carryForward, inventory: InventoryService) => {
296
465
  const status = await inventory.getStatus(carryForward.productId);
297
- return partialRender({ inStock: status.available > 0 }, { carryForwardKey: 'data' });
466
+ return partialRender(
467
+ {
468
+ inStock: status.available > 0, // ✅ OK if phase: fast
469
+ price: 29.99, // ✅ OK if phase: fast
470
+ // productName: 'Widget', // ❌ TypeScript error if phase: slow
471
+ },
472
+ { carryForwardKey: 'data' },
473
+ );
298
474
  });
299
475
  ```
300
476
 
@@ -357,6 +533,507 @@ Creates a redirect response.
357
533
  return redirect3xx(301, 'http://some.domain.com');
358
534
  ```
359
535
 
536
+ ## RenderPipeline - Functional Composition API
537
+
538
+ The `RenderPipeline` class provides a functional programming approach to building render results with automatic error propagation and type-safe chaining.
539
+
540
+ ### Why Use RenderPipeline?
541
+
542
+ Traditional render functions require manual error handling:
543
+
544
+ ```typescript
545
+ // Traditional approach - verbose error handling
546
+ async function renderSlowlyChanging(props) {
547
+ try {
548
+ const product = await getProductBySlug(props.slug);
549
+ if (!product) return notFound();
550
+ return partialRender({ name: product.name }, { productId: product.id });
551
+ } catch (error) {
552
+ return serverError5xx(503);
553
+ }
554
+ }
555
+ ```
556
+
557
+ With `RenderPipeline`, errors propagate automatically and you get clean, chainable code:
558
+
559
+ ```typescript
560
+ // RenderPipeline approach - clean and composable
561
+ async function renderSlowlyChanging(props) {
562
+ const Pipeline = RenderPipeline.for<ProductSlowVS, ProductCF>();
563
+
564
+ return Pipeline.try(() => getProductBySlug(props.slug))
565
+ .recover((err) => Pipeline.serverError(503, 'Database unavailable'))
566
+ .map((product) => (product ? product : Pipeline.notFound('Product not found')))
567
+ .toPhaseOutput((product) => ({
568
+ viewState: { name: product.name },
569
+ carryForward: { productId: product.id },
570
+ }));
571
+ }
572
+ ```
573
+
574
+ ### Key Features
575
+
576
+ - **Type-safe from start**: Declare target `ViewState` and `CarryForward` types upfront
577
+ - **Unified `map()`**: Handles sync values, async values, and conditional errors
578
+ - **Automatic error propagation**: Errors pass through the chain untouched
579
+ - **Single async point**: Only `toPhaseOutput()` is async - all `map()` calls are sync
580
+ - **Clean error recovery**: Handle errors at any point with `recover()`
581
+
582
+ ### Basic Usage
583
+
584
+ ```typescript
585
+ import { RenderPipeline } from '@jay-framework/fullstack-component';
586
+
587
+ // 1. Create a typed pipeline factory
588
+ const Pipeline = RenderPipeline.for<MyViewState, MyCarryForward>();
589
+
590
+ // 2. Start the pipeline
591
+ Pipeline.ok(value) // Start with a value
592
+ Pipeline.try(() => fetchData()) // Start with a function (catches errors)
593
+ Pipeline.notFound('Not found') // Start with an error
594
+
595
+ // 3. Transform with map()
596
+ pipeline
597
+ .map(x => x * 2) // Sync transformation
598
+ .map(async x => fetchDetails(x)) // Async transformation
599
+ .map(x => x.valid ? x : Pipeline.notFound()) // Conditional error
600
+
601
+ // 4. Handle errors with recover()
602
+ pipeline.recover(err => Pipeline.ok({ fallback: true }))
603
+
604
+ // 5. Produce final output with toPhaseOutput()
605
+ pipeline.toPhaseOutput(data => ({
606
+ viewState: { ... },
607
+ carryForward: { ... }
608
+ }))
609
+ ```
610
+
611
+ ### Complete Example
612
+
613
+ ```typescript
614
+ import { RenderPipeline, SlowlyRenderResult } from '@jay-framework/fullstack-component';
615
+
616
+ interface ProductViewState {
617
+ name: string;
618
+ price: number;
619
+ }
620
+
621
+ interface ProductCarryForward {
622
+ productId: string;
623
+ inventoryItemId: string;
624
+ }
625
+
626
+ async function renderSlowlyChanging(
627
+ props: PageProps & { slug: string },
628
+ ): Promise<SlowlyRenderResult<ProductViewState, ProductCarryForward>> {
629
+ const Pipeline = RenderPipeline.for<ProductViewState, ProductCarryForward>();
630
+
631
+ return Pipeline.try(() => getProductBySlug(props.slug))
632
+ .recover((err) => Pipeline.serverError(503, 'Database unavailable'))
633
+ .map((product) =>
634
+ product ? product : Pipeline.notFound('Product not found', { slug: props.slug }),
635
+ )
636
+ .map(async (product) => {
637
+ // Chain additional async operations
638
+ const pricing = await getPricing(product.id);
639
+ return { ...product, pricing };
640
+ })
641
+ .toPhaseOutput((data) => ({
642
+ viewState: {
643
+ name: data.name,
644
+ price: data.pricing.amount,
645
+ },
646
+ carryForward: {
647
+ productId: data.id,
648
+ inventoryItemId: data.inventoryItemId,
649
+ },
650
+ }));
651
+ }
652
+ ```
653
+
654
+ ### API Reference
655
+
656
+ #### `RenderPipeline.for<ViewState, CarryForward>()`
657
+
658
+ Creates a typed pipeline factory. Returns an object with entry point methods:
659
+
660
+ ```typescript
661
+ const Pipeline = RenderPipeline.for<MyViewState, MyCarryForward>();
662
+
663
+ Pipeline.ok(value); // Start with success value
664
+ Pipeline.try(fn); // Start with function (catches errors)
665
+ Pipeline.from(outcome); // Start from existing RenderOutcome
666
+ Pipeline.notFound(msg, details); // Start with 404 error
667
+ Pipeline.badRequest(msg); // Start with 400 error
668
+ Pipeline.unauthorized(msg); // Start with 401 error
669
+ Pipeline.forbidden(msg); // Start with 403 error
670
+ Pipeline.serverError(status, msg); // Start with 5xx error
671
+ Pipeline.clientError(status, msg); // Start with 4xx error
672
+ Pipeline.redirect(status, url); // Start with redirect
673
+ ```
674
+
675
+ #### `pipeline.map(fn)`
676
+
677
+ Transforms the working value. Always returns `RenderPipeline` (sync).
678
+
679
+ The function can return:
680
+
681
+ - `U` - Plain value (sync transformation)
682
+ - `Promise<U>` - Async value (resolved at `toPhaseOutput`)
683
+ - `RenderPipeline<U>` - For conditional errors/branching
684
+
685
+ ```typescript
686
+ pipeline
687
+ .map((x) => x * 2) // Sync
688
+ .map(async (x) => fetchData(x)) // Async
689
+ .map((x) => (x.valid ? x : Pipeline.notFound())); // Conditional
690
+ ```
691
+
692
+ #### `pipeline.recover(fn)`
693
+
694
+ Handles errors, potentially recovering to success:
695
+
696
+ ```typescript
697
+ pipeline.recover((error) => {
698
+ console.error('Error:', error.message);
699
+ return Pipeline.ok({ fallback: true });
700
+ });
701
+ ```
702
+
703
+ #### `pipeline.toPhaseOutput(fn)`
704
+
705
+ Converts to final `RenderOutcome`. This is the **only async method**.
706
+
707
+ Resolves all pending promises and applies the final mapping to produce `ViewState` and `CarryForward`:
708
+
709
+ ```typescript
710
+ await pipeline.toPhaseOutput((data) => ({
711
+ viewState: { name: data.name, value: data.value },
712
+ carryForward: { id: data.id },
713
+ }));
714
+ ```
715
+
716
+ #### Utility Methods
717
+
718
+ ```typescript
719
+ pipeline.isOk(); // true if success state
720
+ pipeline.isError(); // true if error state
721
+ ```
722
+
723
+ ### Error Types with Messages
724
+
725
+ All error types now support optional `message`, `code`, and `details`:
726
+
727
+ ```typescript
728
+ Pipeline.notFound('Product not found', { slug: 'my-product' });
729
+ Pipeline.serverError(503, 'Database unavailable', { retryAfter: 30 });
730
+ ```
731
+
732
+ The error types are:
733
+
734
+ - `ServerError5xx` - Server errors (5xx status codes)
735
+ - `ClientError4xx` - Client errors (4xx status codes)
736
+ - `Redirect3xx` - Redirects (3xx status codes)
737
+
738
+ ## Complete Example with Phase Validation
739
+
740
+ Here's a complete example showing how phase annotations in your contract provide compile-time type safety:
741
+
742
+ ### 1. Define Contract with Phases
743
+
744
+ **`user-profile.jay-html`**:
745
+
746
+ ```html
747
+ <html>
748
+ <head>
749
+ <script type="application/yaml-jay">
750
+ data:
751
+ # Static user info - rendered at build time
752
+ - {tag: userId, dataType: string, phase: slow}
753
+ - {tag: username, dataType: string, phase: slow}
754
+ - {tag: bio, dataType: string, phase: slow}
755
+
756
+ # Dynamic activity - rendered per request
757
+ - {tag: lastSeen, dataType: string, phase: fast}
758
+ - {tag: isOnline, dataType: boolean, phase: fast}
759
+ - {tag: followerCount, dataType: number, phase: fast}
760
+ </script>
761
+ </head>
762
+ <body>
763
+ <div>
764
+ <h1>{username}</h1>
765
+ <p>{bio}</p>
766
+ <p>Followers: {followerCount}</p>
767
+ <span>{isOnline ? 'Online' : 'Last seen: ' + lastSeen}</span>
768
+ </div>
769
+ </body>
770
+ </html>
771
+ ```
772
+
773
+ ### 2. Generated Types
774
+
775
+ **`user-profile.jay-html.d.ts`** (auto-generated):
776
+
777
+ ```typescript
778
+ export interface UserProfileViewState {
779
+ userId: string;
780
+ username: string;
781
+ bio: string;
782
+ lastSeen: string;
783
+ isOnline: boolean;
784
+ followerCount: number;
785
+ }
786
+
787
+ export interface UserProfileElementRefs {}
788
+
789
+ // Phase-specific types - automatically generated
790
+ export type UserProfileSlowViewState = Pick<UserProfileViewState, 'userId' | 'username' | 'bio'>;
791
+ export type UserProfileFastViewState = Pick<
792
+ UserProfileViewState,
793
+ 'lastSeen' | 'isOnline' | 'followerCount'
794
+ >;
795
+ export type UserProfileInteractiveViewState = {};
796
+
797
+ export type UserProfileContract = JayContract<
798
+ UserProfileViewState,
799
+ UserProfileElementRefs,
800
+ UserProfileSlowViewState,
801
+ UserProfileFastViewState,
802
+ UserProfileInteractiveViewState
803
+ >;
804
+ ```
805
+
806
+ ### 3. Implement with Type Safety
807
+
808
+ ```typescript
809
+ import {
810
+ makeJayStackComponent,
811
+ partialRender,
812
+ createJayService,
813
+ } from '@jay-framework/fullstack-component';
814
+ import { UserProfileContract } from './user-profile.jay-html';
815
+
816
+ interface UserDatabase {
817
+ getUser(id: string): Promise<{ id: string; name: string; bio: string }>;
818
+ }
819
+ const USER_DB = createJayService<UserDatabase>('UserDB');
820
+
821
+ interface ActivityService {
822
+ getUserActivity(id: string): Promise<{ lastSeen: string; isOnline: boolean; followers: number }>;
823
+ }
824
+ const ACTIVITY_SERVICE = createJayService<ActivityService>('Activity');
825
+
826
+ export const userProfile = makeJayStackComponent<UserProfileContract>()
827
+ .withProps()
828
+ .withServices(USER_DB, ACTIVITY_SERVICE)
829
+ .withSlowlyRender(async (props, userDb) => {
830
+ // ✅ TypeScript knows only slow properties are allowed
831
+ const user = await userDb.getUser('123');
832
+ return partialRender(
833
+ {
834
+ userId: user.id,
835
+ username: user.name,
836
+ bio: user.bio,
837
+ // followerCount: 100, // ❌ TypeScript Error: Property 'followerCount'
838
+ // does not exist in type 'UserProfileSlowViewState'
839
+ },
840
+ { userId: user.id },
841
+ );
842
+ })
843
+ .withFastRender(async (props, carryForward, userDb, activityService) => {
844
+ // ✅ TypeScript knows only fast properties are allowed
845
+ const activity = await activityService.getUserActivity(carryForward.userId);
846
+ return partialRender(
847
+ {
848
+ lastSeen: activity.lastSeen,
849
+ isOnline: activity.isOnline,
850
+ followerCount: activity.followers,
851
+ // username: 'John', // ❌ TypeScript Error: Property 'username'
852
+ // does not exist in type 'UserProfileFastViewState'
853
+ },
854
+ {},
855
+ );
856
+ })
857
+ .withInteractive((props, refs) => {
858
+ return {
859
+ render: () => ({}),
860
+ };
861
+ });
862
+ ```
863
+
864
+ **Key Benefits:**
865
+
866
+ - 🔒 **Compile-time guarantees**: TypeScript prevents phase violations before deployment
867
+ - 📊 **Clear separation**: Slow (static) data is visually separated from fast (dynamic) data
868
+ - ⚡ **Optimal performance**: Framework can cache slow data aggressively
869
+ - 🧹 **No boilerplate**: No manual type annotations needed in render functions
870
+
871
+ ## Server Actions
872
+
873
+ Server actions enable client-side code to call server-side functions after the initial page load. They provide type-safe RPC communication between interactive components and the server.
874
+
875
+ ### Action Builder API
876
+
877
+ ```typescript
878
+ import { makeJayAction, makeJayQuery, ActionError } from '@jay-framework/fullstack-component';
879
+ ```
880
+
881
+ | Builder | Default Method | Use Case |
882
+ | --------------- | -------------- | --------------------------------------------------- |
883
+ | `makeJayAction` | POST | Mutations: add to cart, submit form, update profile |
884
+ | `makeJayQuery` | GET | Reads: search, get details, list items (cacheable) |
885
+
886
+ ### Defining Actions
887
+
888
+ ```typescript
889
+ // src/actions/cart.actions.ts
890
+ import { makeJayAction, ActionError } from '@jay-framework/fullstack-component';
891
+ import { CART_SERVICE, INVENTORY_SERVICE } from '../services';
892
+
893
+ export const addToCart = makeJayAction('cart.addToCart')
894
+ .withServices(CART_SERVICE, INVENTORY_SERVICE)
895
+ .withHandler(async (input: { productId: string; quantity: number }, cartService, inventory) => {
896
+ const available = await inventory.getAvailableUnits(input.productId);
897
+ if (available < input.quantity) {
898
+ throw new ActionError('NOT_AVAILABLE', `Only ${available} units available`);
899
+ }
900
+
901
+ const cart = await cartService.addItem(input.productId, input.quantity);
902
+ return { cartItemCount: cart.items.length };
903
+ });
904
+ ```
905
+
906
+ ### Defining Queries (GET with Caching)
907
+
908
+ ```typescript
909
+ // src/actions/search.actions.ts
910
+ import { makeJayQuery } from '@jay-framework/fullstack-component';
911
+ import { PRODUCTS_DATABASE_SERVICE } from '../services';
912
+
913
+ export const searchProducts = makeJayQuery('products.search')
914
+ .withServices(PRODUCTS_DATABASE_SERVICE)
915
+ .withCaching({ maxAge: 60, staleWhileRevalidate: 120 })
916
+ .withHandler(async (input: { query: string; page?: number }, productsDb) => {
917
+ const results = await productsDb.search(input.query, {
918
+ page: input.page ?? 1,
919
+ limit: 20,
920
+ });
921
+ return {
922
+ products: results.items,
923
+ totalCount: results.total,
924
+ hasMore: results.hasMore,
925
+ };
926
+ });
927
+ ```
928
+
929
+ ### Builder Methods
930
+
931
+ #### `.withServices(...serviceMarkers)`
932
+
933
+ Inject server-side services (same pattern as `makeJayStackComponent`):
934
+
935
+ ```typescript
936
+ export const addToCart = makeJayAction('cart.addToCart')
937
+ .withServices(CART_SERVICE, INVENTORY_SERVICE)
938
+ .withHandler(async (input, cartService, inventory) => {
939
+ // Services are injected after input
940
+ });
941
+ ```
942
+
943
+ #### `.withMethod(method)`
944
+
945
+ Override the default HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`):
946
+
947
+ ```typescript
948
+ export const deleteProduct = makeJayAction('products.delete')
949
+ .withMethod('DELETE')
950
+ .withHandler(async (input: { id: string }) => {
951
+ /* ... */
952
+ });
953
+ ```
954
+
955
+ #### `.withCaching(options)`
956
+
957
+ Enable caching for GET requests:
958
+
959
+ ```typescript
960
+ export const getCategories = makeJayQuery('products.categories')
961
+ .withCaching({ maxAge: 300, staleWhileRevalidate: 600 })
962
+ .withHandler(async () => {
963
+ /* ... */
964
+ });
965
+ ```
966
+
967
+ ### Error Handling
968
+
969
+ Use `ActionError` for business logic errors (returns 422 status):
970
+
971
+ ```typescript
972
+ import { ActionError } from '@jay-framework/fullstack-component';
973
+
974
+ export const addToCart = makeJayAction('cart.addToCart').withHandler(
975
+ async (input, cartService, inventory) => {
976
+ const available = await inventory.check(input.productId);
977
+
978
+ if (available < input.quantity) {
979
+ throw new ActionError('NOT_AVAILABLE', 'Product is out of stock');
980
+ }
981
+
982
+ return { success: true };
983
+ },
984
+ );
985
+ ```
986
+
987
+ ### Client Usage
988
+
989
+ Actions are imported and called like regular async functions:
990
+
991
+ ```typescript
992
+ // pages/products/[slug]/page.ts
993
+ import { addToCart } from '../../../actions/cart.actions';
994
+ import { ActionError } from '@jay-framework/fullstack-component';
995
+
996
+ function ProductPageInteractive(props, refs, viewState, carryForward) {
997
+ refs.addToCart.onclick(async () => {
998
+ try {
999
+ const result = await addToCart({
1000
+ productId: carryForward.productId,
1001
+ quantity: 1,
1002
+ });
1003
+ // Success! result.cartItemCount is available
1004
+ } catch (e) {
1005
+ if (e instanceof ActionError) {
1006
+ showNotification(e.message); // "Product is out of stock"
1007
+ }
1008
+ }
1009
+ });
1010
+
1011
+ return { render: () => ({}) };
1012
+ }
1013
+ ```
1014
+
1015
+ ### Auto-Registration
1016
+
1017
+ Actions in `src/actions/*.actions.ts` are automatically discovered and registered on server startup. No manual registration needed.
1018
+
1019
+ ### Type Inference
1020
+
1021
+ Input and output types are inferred from the handler function:
1022
+
1023
+ ```typescript
1024
+ export const addToCart = makeJayAction('cart.addToCart').withHandler(
1025
+ async (
1026
+ input: { productId: string; quantity: number }, // ← Input type
1027
+ ) => {
1028
+ return { cartItemCount: 5 }; // ← Output type
1029
+ },
1030
+ );
1031
+
1032
+ // Client gets full type safety
1033
+ const result = await addToCart({ productId: '123', quantity: 1 });
1034
+ // ^? { cartItemCount: number }
1035
+ ```
1036
+
360
1037
  ## Advanced Examples
361
1038
 
362
1039
  ### A Product Page with URL Parameters