@jay-framework/fullstack-component 0.9.0 → 0.11.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
@@ -47,7 +47,7 @@ Jay Stack automatically generates **phase-specific ViewState types** from your c
47
47
 
48
48
  **Example:**
49
49
 
50
- ````typescript
50
+ ```typescript
51
51
  // TypeScript automatically knows which properties are valid in each phase
52
52
  .withSlowlyRender(async () => {
53
53
  return partialRender({
@@ -61,6 +61,7 @@ Jay Stack automatically generates **phase-specific ViewState types** from your c
61
61
  productName: 'Widget', // ❌ TypeScript Error: Not in FastViewState
62
62
  }, {});
63
63
  })
64
+ ```
64
65
 
65
66
  ### Specifying Phases in Contracts
66
67
 
@@ -94,7 +95,7 @@ You can annotate your contract properties with the `phase` attribute to control
94
95
  </div>
95
96
  </body>
96
97
  </html>
97
- ````
98
+ ```
98
99
 
99
100
  #### Jay Contract (Headless)
100
101
 
@@ -532,6 +533,208 @@ Creates a redirect response.
532
533
  return redirect3xx(301, 'http://some.domain.com');
533
534
  ```
534
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
+
535
738
  ## Complete Example with Phase Validation
536
739
 
537
740
  Here's a complete example showing how phase annotations in your contract provide compile-time type safety:
@@ -665,6 +868,172 @@ export const userProfile = makeJayStackComponent<UserProfileContract>()
665
868
  - ⚡ **Optimal performance**: Framework can cache slow data aggressively
666
869
  - 🧹 **No boilerplate**: No manual type annotations needed in render functions
667
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
+
668
1037
  ## Advanced Examples
669
1038
 
670
1039
  ### A Product Page with URL Parameters