@btst/stack 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +52 -1
  2. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +52 -1
  3. package/dist/packages/stack/src/plugins/blog/api/query-key-defs.cjs +18 -0
  4. package/dist/packages/stack/src/plugins/blog/api/query-key-defs.mjs +15 -0
  5. package/dist/packages/stack/src/plugins/blog/api/serializers.cjs +21 -0
  6. package/dist/packages/stack/src/plugins/blog/api/serializers.mjs +18 -0
  7. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +15 -0
  8. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +16 -1
  9. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +10 -0
  10. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +10 -1
  11. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +70 -1
  12. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +71 -2
  13. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +29 -0
  14. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +26 -0
  15. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +15 -0
  16. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +16 -1
  17. package/dist/packages/stack/src/plugins/form-builder/api/getters.cjs +9 -0
  18. package/dist/packages/stack/src/plugins/form-builder/api/getters.mjs +9 -1
  19. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +62 -1
  20. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +63 -2
  21. package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.cjs +37 -0
  22. package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.mjs +33 -0
  23. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +15 -0
  24. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +16 -1
  25. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +29 -1
  26. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +29 -1
  27. package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.cjs +26 -0
  28. package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.mjs +23 -0
  29. package/dist/packages/stack/src/plugins/kanban/api/serializers.cjs +30 -0
  30. package/dist/packages/stack/src/plugins/kanban/api/serializers.mjs +26 -0
  31. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +10 -0
  32. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +11 -1
  33. package/dist/packages/stack/src/plugins/utils.cjs +6 -0
  34. package/dist/packages/stack/src/plugins/utils.mjs +6 -1
  35. package/dist/plugins/blog/api/index.cjs +5 -0
  36. package/dist/plugins/blog/api/index.d.cts +19 -4
  37. package/dist/plugins/blog/api/index.d.mts +19 -4
  38. package/dist/plugins/blog/api/index.d.ts +19 -4
  39. package/dist/plugins/blog/api/index.mjs +2 -0
  40. package/dist/plugins/blog/client/hooks/index.d.cts +4 -4
  41. package/dist/plugins/blog/client/hooks/index.d.mts +4 -4
  42. package/dist/plugins/blog/client/hooks/index.d.ts +4 -4
  43. package/dist/plugins/blog/client/index.d.cts +1 -1
  44. package/dist/plugins/blog/client/index.d.mts +1 -1
  45. package/dist/plugins/blog/client/index.d.ts +1 -1
  46. package/dist/plugins/blog/query-keys.cjs +6 -5
  47. package/dist/plugins/blog/query-keys.d.cts +8 -387
  48. package/dist/plugins/blog/query-keys.d.mts +8 -387
  49. package/dist/plugins/blog/query-keys.d.ts +8 -387
  50. package/dist/plugins/blog/query-keys.mjs +6 -5
  51. package/dist/plugins/client/index.cjs +1 -0
  52. package/dist/plugins/client/index.d.cts +8 -1
  53. package/dist/plugins/client/index.d.mts +8 -1
  54. package/dist/plugins/client/index.d.ts +8 -1
  55. package/dist/plugins/client/index.mjs +1 -1
  56. package/dist/plugins/cms/api/index.cjs +6 -0
  57. package/dist/plugins/cms/api/index.d.cts +7 -219
  58. package/dist/plugins/cms/api/index.d.mts +7 -219
  59. package/dist/plugins/cms/api/index.d.ts +7 -219
  60. package/dist/plugins/cms/api/index.mjs +2 -1
  61. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  62. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  63. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  64. package/dist/plugins/cms/query-keys.cjs +2 -1
  65. package/dist/plugins/cms/query-keys.d.cts +5 -9
  66. package/dist/plugins/cms/query-keys.d.mts +5 -9
  67. package/dist/plugins/cms/query-keys.d.ts +5 -9
  68. package/dist/plugins/cms/query-keys.mjs +2 -1
  69. package/dist/plugins/form-builder/api/index.cjs +6 -0
  70. package/dist/plugins/form-builder/api/index.d.cts +7 -211
  71. package/dist/plugins/form-builder/api/index.d.mts +7 -211
  72. package/dist/plugins/form-builder/api/index.d.ts +7 -211
  73. package/dist/plugins/form-builder/api/index.mjs +2 -1
  74. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  75. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  76. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  77. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  78. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  79. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  80. package/dist/plugins/form-builder/query-keys.cjs +3 -2
  81. package/dist/plugins/form-builder/query-keys.d.cts +6 -6
  82. package/dist/plugins/form-builder/query-keys.d.mts +6 -6
  83. package/dist/plugins/form-builder/query-keys.d.ts +6 -6
  84. package/dist/plugins/form-builder/query-keys.mjs +3 -2
  85. package/dist/plugins/kanban/api/index.cjs +6 -0
  86. package/dist/plugins/kanban/api/index.d.cts +17 -392
  87. package/dist/plugins/kanban/api/index.d.mts +17 -392
  88. package/dist/plugins/kanban/api/index.d.ts +17 -392
  89. package/dist/plugins/kanban/api/index.mjs +2 -0
  90. package/dist/plugins/kanban/client/components/index.d.cts +1 -1
  91. package/dist/plugins/kanban/client/components/index.d.mts +1 -1
  92. package/dist/plugins/kanban/client/components/index.d.ts +1 -1
  93. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  94. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  95. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  96. package/dist/plugins/kanban/client/index.d.cts +1 -1
  97. package/dist/plugins/kanban/client/index.d.mts +1 -1
  98. package/dist/plugins/kanban/client/index.d.ts +1 -1
  99. package/dist/plugins/kanban/query-keys.cjs +2 -9
  100. package/dist/plugins/kanban/query-keys.d.cts +4 -16
  101. package/dist/plugins/kanban/query-keys.d.mts +4 -16
  102. package/dist/plugins/kanban/query-keys.d.ts +4 -16
  103. package/dist/plugins/kanban/query-keys.mjs +2 -9
  104. package/dist/plugins/ui-builder/index.d.cts +1 -1
  105. package/dist/plugins/ui-builder/index.d.mts +1 -1
  106. package/dist/plugins/ui-builder/index.d.ts +1 -1
  107. package/dist/shared/stack.B1EeBt1b.d.ts +297 -0
  108. package/dist/shared/stack.BIXEI6v_.d.mts +419 -0
  109. package/dist/shared/stack.BKfolAyK.d.ts +419 -0
  110. package/dist/shared/stack.BpolpQpf.d.cts +445 -0
  111. package/dist/shared/stack.C5dtIncc.d.mts +293 -0
  112. package/dist/shared/stack.CIP6QS9l.d.ts +293 -0
  113. package/dist/shared/stack.CP68pFEH.d.mts +297 -0
  114. package/dist/shared/{stack.CXjzTMsb.d.mts → stack.CVDTkMoO.d.cts} +7 -1
  115. package/dist/shared/{stack.CXjzTMsb.d.ts → stack.CVDTkMoO.d.mts} +7 -1
  116. package/dist/shared/{stack.CXjzTMsb.d.cts → stack.CVDTkMoO.d.ts} +7 -1
  117. package/dist/shared/{stack.QD1y_7NY.d.mts → stack.DJaKVY7v.d.cts} +1 -1
  118. package/dist/shared/{stack.QD1y_7NY.d.ts → stack.DJaKVY7v.d.mts} +1 -1
  119. package/dist/shared/{stack.QD1y_7NY.d.cts → stack.DJaKVY7v.d.ts} +1 -1
  120. package/dist/shared/{stack.CIrIsc-A.d.mts → stack.DdI5W6MB.d.cts} +7 -1
  121. package/dist/shared/{stack.CIrIsc-A.d.ts → stack.DdI5W6MB.d.mts} +7 -1
  122. package/dist/shared/{stack.CIrIsc-A.d.cts → stack.DdI5W6MB.d.ts} +7 -1
  123. package/dist/shared/stack.Dw0Ly2TM.d.cts +293 -0
  124. package/dist/shared/stack.IdtKDRka.d.cts +297 -0
  125. package/dist/shared/stack.TIBF2AOx.d.ts +445 -0
  126. package/dist/shared/stack.rTy7-wQU.d.mts +445 -0
  127. package/dist/shared/stack.snB1EDP7.d.cts +419 -0
  128. package/package.json +3 -3
  129. package/src/plugins/blog/api/index.ts +2 -0
  130. package/src/plugins/blog/api/plugin.ts +85 -0
  131. package/src/plugins/blog/api/query-key-defs.ts +46 -0
  132. package/src/plugins/blog/api/serializers.ts +27 -0
  133. package/src/plugins/blog/client/plugin.tsx +19 -0
  134. package/src/plugins/blog/query-keys.ts +5 -7
  135. package/src/plugins/client/index.ts +1 -1
  136. package/src/plugins/cms/api/getters.ts +24 -0
  137. package/src/plugins/cms/api/index.ts +10 -1
  138. package/src/plugins/cms/api/plugin.ts +105 -0
  139. package/src/plugins/cms/api/query-key-defs.ts +53 -0
  140. package/src/plugins/cms/api/serializers.ts +12 -0
  141. package/src/plugins/cms/client/plugin.tsx +19 -0
  142. package/src/plugins/cms/query-keys.ts +2 -1
  143. package/src/plugins/form-builder/api/getters.ts +23 -0
  144. package/src/plugins/form-builder/api/index.ts +15 -2
  145. package/src/plugins/form-builder/api/plugin.ts +91 -0
  146. package/src/plugins/form-builder/api/query-key-defs.ts +79 -0
  147. package/src/plugins/form-builder/api/serializers.ts +12 -0
  148. package/src/plugins/form-builder/client/plugin.tsx +19 -0
  149. package/src/plugins/form-builder/query-keys.ts +6 -2
  150. package/src/plugins/kanban/api/index.ts +3 -0
  151. package/src/plugins/kanban/api/plugin.ts +49 -0
  152. package/src/plugins/kanban/api/query-key-defs.ts +54 -0
  153. package/src/plugins/kanban/api/serializers.ts +49 -0
  154. package/src/plugins/kanban/client/plugin.tsx +13 -0
  155. package/src/plugins/kanban/query-keys.ts +2 -9
  156. package/src/plugins/utils.ts +19 -0
  157. package/dist/shared/{stack.BkYlUT_8.d.cts → stack.CBON0dWL.d.cts} +6 -6
  158. package/dist/shared/{stack.BkYlUT_8.d.mts → stack.CBON0dWL.d.mts} +6 -6
  159. package/dist/shared/{stack.BkYlUT_8.d.ts → stack.CBON0dWL.d.ts} +6 -6
@@ -5,6 +5,7 @@ import {
5
5
  import type { BlogApiRouter } from "./api";
6
6
  import { createApiClient } from "@btst/stack/plugins/client";
7
7
  import type { SerializedPost, SerializedTag } from "./types";
8
+ import { postsListDiscriminator } from "./api/query-key-defs";
8
9
 
9
10
  interface PostsListParams {
10
11
  query?: string;
@@ -71,15 +72,12 @@ function createPostsQueries(
71
72
  return createQueryKeys("posts", {
72
73
  list: (params?: PostsListParams) => ({
73
74
  queryKey: [
74
- {
75
- query:
76
- params?.query !== undefined && params?.query?.trim() === ""
77
- ? undefined
78
- : params?.query,
79
- limit: params?.limit ?? 10,
75
+ postsListDiscriminator({
80
76
  published: params?.published ?? true,
77
+ limit: params?.limit ?? 10,
81
78
  tagSlug: params?.tagSlug,
82
- },
79
+ query: params?.query,
80
+ }),
83
81
  ],
84
82
  queryFn: async ({ pageParam }: { pageParam?: number }) => {
85
83
  try {
@@ -18,7 +18,7 @@ export type {
18
18
  PluginOverrides,
19
19
  } from "../../types";
20
20
 
21
- export { createApiClient } from "../utils";
21
+ export { createApiClient, isConnectionError } from "../utils";
22
22
 
23
23
  // Re-export Yar types needed for plugins
24
24
  export type { Route } from "@btst/yar";
@@ -191,6 +191,30 @@ export async function getAllContentItems(
191
191
  };
192
192
  }
193
193
 
194
+ /**
195
+ * Retrieve a single content item by its ID.
196
+ * Returns null if the item is not found.
197
+ * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use.
198
+ *
199
+ * @remarks **Security:** Authorization hooks are NOT called. The caller is
200
+ * responsible for any access-control checks before invoking this function.
201
+ *
202
+ * @param adapter - The database adapter
203
+ * @param id - The content item ID (UUID)
204
+ */
205
+ export async function getContentItemById(
206
+ adapter: Adapter,
207
+ id: string,
208
+ ): Promise<SerializedContentItemWithType | null> {
209
+ const item = await adapter.findOne<ContentItemWithType>({
210
+ model: "contentItem",
211
+ where: [{ field: "id", value: id, operator: "eq" as const }],
212
+ join: { contentType: true },
213
+ });
214
+ if (!item) return null;
215
+ return serializeContentItemWithType(item);
216
+ }
217
+
194
218
  /**
195
219
  * Retrieve a single content item by its slug within a content type.
196
220
  * Returns null if the content type or item is not found.
@@ -1,6 +1,15 @@
1
- export { cmsBackendPlugin, type CMSApiRouter } from "./plugin";
1
+ export {
2
+ cmsBackendPlugin,
3
+ type CMSApiRouter,
4
+ type CMSRouteKey,
5
+ } from "./plugin";
2
6
  export {
3
7
  getAllContentTypes,
4
8
  getAllContentItems,
5
9
  getContentItemBySlug,
10
+ getContentItemById,
11
+ serializeContentType,
12
+ serializeContentItem,
13
+ serializeContentItemWithType,
6
14
  } from "./getters";
15
+ export { CMS_QUERY_KEYS } from "./query-key-defs";
@@ -25,10 +25,37 @@ import {
25
25
  getAllContentTypes,
26
26
  getAllContentItems,
27
27
  getContentItemBySlug,
28
+ getContentItemById,
28
29
  serializeContentType,
29
30
  serializeContentItem,
30
31
  serializeContentItemWithType,
31
32
  } from "./getters";
33
+ import type { QueryClient } from "@tanstack/react-query";
34
+ import { CMS_QUERY_KEYS } from "./query-key-defs";
35
+
36
+ /**
37
+ * Route keys for the CMS plugin — matches the keys returned by
38
+ * `stackClient.router.getRoute(path).routeKey`.
39
+ */
40
+ export type CMSRouteKey =
41
+ | "dashboard"
42
+ | "contentList"
43
+ | "newContent"
44
+ | "editContent";
45
+
46
+ interface CMSPrefetchForRoute {
47
+ (key: "dashboard" | "newContent", qc: QueryClient): Promise<void>;
48
+ (
49
+ key: "contentList",
50
+ qc: QueryClient,
51
+ params: { typeSlug: string },
52
+ ): Promise<void>;
53
+ (
54
+ key: "editContent",
55
+ qc: QueryClient,
56
+ params: { typeSlug: string; id: string },
57
+ ): Promise<void>;
58
+ }
32
59
 
33
60
  /**
34
61
  * Sync content types from config to database
@@ -443,6 +470,79 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
443
470
  return syncPromise;
444
471
  };
445
472
 
473
+ const getContentTypesWithCounts = async (adapter: Adapter) => {
474
+ const contentTypes = await getAllContentTypes(adapter);
475
+ return Promise.all(
476
+ contentTypes.map(async (ct) => {
477
+ const count: number = await adapter.count({
478
+ model: "contentItem",
479
+ where: [
480
+ { field: "contentTypeId", value: ct.id, operator: "eq" as const },
481
+ ],
482
+ });
483
+ return { ...ct, itemCount: count };
484
+ }),
485
+ );
486
+ };
487
+
488
+ const createCMSPrefetchForRoute = (adapter: Adapter): CMSPrefetchForRoute => {
489
+ return async function prefetchForRoute(
490
+ key: CMSRouteKey,
491
+ qc: QueryClient,
492
+ params?: Record<string, string>,
493
+ ): Promise<void> {
494
+ // Sync content types once at the top — idempotent for concurrent SSG calls
495
+ await ensureSynced(adapter);
496
+
497
+ switch (key) {
498
+ case "dashboard":
499
+ case "newContent": {
500
+ const typesWithCounts = await getContentTypesWithCounts(adapter);
501
+ qc.setQueryData(CMS_QUERY_KEYS.typesList(), typesWithCounts);
502
+ break;
503
+ }
504
+ case "contentList": {
505
+ const typeSlug = params?.typeSlug ?? "";
506
+ const [contentTypes, contentItems] = await Promise.all([
507
+ getContentTypesWithCounts(adapter),
508
+ getAllContentItems(adapter, typeSlug, { limit: 20, offset: 0 }),
509
+ ]);
510
+ qc.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes);
511
+ qc.setQueryData(
512
+ CMS_QUERY_KEYS.contentList({ typeSlug, limit: 20, offset: 0 }),
513
+ {
514
+ pages: [
515
+ {
516
+ items: contentItems.items,
517
+ total: contentItems.total,
518
+ limit: contentItems.limit ?? 20,
519
+ offset: contentItems.offset ?? 0,
520
+ },
521
+ ],
522
+ pageParams: [0],
523
+ },
524
+ );
525
+ break;
526
+ }
527
+ case "editContent": {
528
+ const typeSlug = params?.typeSlug ?? "";
529
+ const id = params?.id ?? "";
530
+ const [contentTypes, item] = await Promise.all([
531
+ getContentTypesWithCounts(adapter),
532
+ id ? getContentItemById(adapter, id) : Promise.resolve(null),
533
+ ]);
534
+ qc.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes);
535
+ if (id) {
536
+ qc.setQueryData(CMS_QUERY_KEYS.contentDetail(typeSlug, id), item);
537
+ }
538
+ break;
539
+ }
540
+ default:
541
+ break;
542
+ }
543
+ } as CMSPrefetchForRoute;
544
+ };
545
+
446
546
  return defineBackendPlugin({
447
547
  name: "cms",
448
548
 
@@ -464,6 +564,11 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
464
564
  await ensureSynced(adapter);
465
565
  return getContentItemBySlug(adapter, contentTypeSlug, slug);
466
566
  },
567
+ getContentItemById: async (id: string) => {
568
+ await ensureSynced(adapter);
569
+ return getContentItemById(adapter, id);
570
+ },
571
+ prefetchForRoute: createCMSPrefetchForRoute(adapter),
467
572
  }),
468
573
 
469
574
  routes: (adapter: Adapter) => {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Internal query key constants for the CMS plugin.
3
+ * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path)
4
+ * to prevent key drift between SSR loaders and SSG prefetching.
5
+ */
6
+
7
+ export interface ContentListDiscriminator {
8
+ typeSlug: string;
9
+ limit: number;
10
+ offset: number;
11
+ }
12
+
13
+ /**
14
+ * Builds the discriminator object used as the cache key for the content list.
15
+ * Mirrors the params object used in createContentQueries.list so both paths stay in sync.
16
+ */
17
+ export function contentListDiscriminator(params: {
18
+ typeSlug: string;
19
+ limit?: number;
20
+ offset?: number;
21
+ }): ContentListDiscriminator {
22
+ return {
23
+ typeSlug: params.typeSlug,
24
+ limit: params.limit ?? 20,
25
+ offset: params.offset ?? 0,
26
+ };
27
+ }
28
+
29
+ /** Full query key builders — use these with queryClient.setQueryData() */
30
+ export const CMS_QUERY_KEYS = {
31
+ /**
32
+ * Key for the cmsTypes.list() query.
33
+ * Full key: ["cmsTypes", "list", "list"]
34
+ */
35
+ typesList: () => ["cmsTypes", "list", "list"] as const,
36
+
37
+ /**
38
+ * Key for the cmsContent.list({ typeSlug, limit, offset }) query.
39
+ * Full key: ["cmsContent", "list", { typeSlug, limit, offset }]
40
+ */
41
+ contentList: (params: {
42
+ typeSlug: string;
43
+ limit?: number;
44
+ offset?: number;
45
+ }) => ["cmsContent", "list", contentListDiscriminator(params)] as const,
46
+
47
+ /**
48
+ * Key for the cmsContent.detail(typeSlug, id) query.
49
+ * Full key: ["cmsContent", "detail", typeSlug, id]
50
+ */
51
+ contentDetail: (typeSlug: string, id: string) =>
52
+ ["cmsContent", "detail", typeSlug, id] as const,
53
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Re-exports serialization helpers from getters.ts for consumers who import
3
+ * from @btst/stack/plugins/cms/api.
4
+ *
5
+ * The actual implementations live in getters.ts alongside the DB functions
6
+ * they serialize so they stay in sync with the returned types.
7
+ */
8
+ export {
9
+ serializeContentType,
10
+ serializeContentItem,
11
+ serializeContentItemWithType,
12
+ } from "./getters";
@@ -2,6 +2,7 @@ import { lazy } from "react";
2
2
  import {
3
3
  defineClientPlugin,
4
4
  createApiClient,
5
+ isConnectionError,
5
6
  } from "@btst/stack/plugins/client";
6
7
  import { createRoute } from "@btst/yar";
7
8
  import type { QueryClient } from "@tanstack/react-query";
@@ -181,6 +182,12 @@ function createDashboardLoader(config: CMSClientConfig) {
181
182
  } catch (error) {
182
183
  // Error hook - log the error but don't throw during SSR
183
184
  // Let Error Boundaries handle errors when components render
185
+ if (isConnectionError(error)) {
186
+ console.warn(
187
+ "[btst/cms] route.loader() failed — no server running at build time. " +
188
+ "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.",
189
+ );
190
+ }
184
191
  if (hooks?.onLoadError) {
185
192
  await hooks.onLoadError(error as Error, context);
186
193
  }
@@ -275,6 +282,12 @@ function createContentListLoader(typeSlug: string, config: CMSClientConfig) {
275
282
  } catch (error) {
276
283
  // Error hook - log the error but don't throw during SSR
277
284
  // Let Error Boundaries handle errors when components render
285
+ if (isConnectionError(error)) {
286
+ console.warn(
287
+ "[btst/cms] route.loader() failed — no server running at build time. " +
288
+ "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.",
289
+ );
290
+ }
278
291
  if (hooks?.onLoadError) {
279
292
  await hooks.onLoadError(error as Error, context);
280
293
  }
@@ -357,6 +370,12 @@ function createContentEditorLoader(
357
370
  } catch (error) {
358
371
  // Error hook - log the error but don't throw during SSR
359
372
  // Let Error Boundaries handle errors when components render
373
+ if (isConnectionError(error)) {
374
+ console.warn(
375
+ "[btst/cms] route.loader() failed — no server running at build time. " +
376
+ "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.",
377
+ );
378
+ }
360
379
  if (hooks?.onLoadError) {
361
380
  await hooks.onLoadError(error as Error, context);
362
381
  }
@@ -9,6 +9,7 @@ import type {
9
9
  SerializedContentItemWithType,
10
10
  PaginatedContentItems,
11
11
  } from "./types";
12
+ import { contentListDiscriminator } from "./api/query-key-defs";
12
13
 
13
14
  interface ContentListParams {
14
15
  limit?: number;
@@ -115,7 +116,7 @@ function createContentQueries(
115
116
  ) {
116
117
  return createQueryKeys("cmsContent", {
117
118
  list: (params: { typeSlug: string } & ContentListParams) => ({
118
- queryKey: [params],
119
+ queryKey: [contentListDiscriminator(params)],
119
120
  queryFn: async () => {
120
121
  try {
121
122
  const response: unknown = await client("/content/:typeSlug", {
@@ -116,6 +116,29 @@ export async function getAllForms(
116
116
  };
117
117
  }
118
118
 
119
+ /**
120
+ * Retrieve a single form by its ID (UUID).
121
+ * Returns null if the form is not found.
122
+ * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use.
123
+ *
124
+ * @remarks **Security:** Authorization hooks are NOT called. The caller is
125
+ * responsible for any access-control checks before invoking this function.
126
+ *
127
+ * @param adapter - The database adapter
128
+ * @param id - The form UUID
129
+ */
130
+ export async function getFormById(
131
+ adapter: Adapter,
132
+ id: string,
133
+ ): Promise<SerializedForm | null> {
134
+ const form = await adapter.findOne<Form>({
135
+ model: "form",
136
+ where: [{ field: "id", value: id, operator: "eq" as const }],
137
+ });
138
+ if (!form) return null;
139
+ return serializeForm(form);
140
+ }
141
+
119
142
  /**
120
143
  * Retrieve a single form by its slug.
121
144
  * Returns null if the form is not found.
@@ -1,2 +1,15 @@
1
- export { formBuilderBackendPlugin, type FormBuilderApiRouter } from "./plugin";
2
- export { getAllForms, getFormBySlug, getFormSubmissions } from "./getters";
1
+ export {
2
+ formBuilderBackendPlugin,
3
+ type FormBuilderApiRouter,
4
+ type FormBuilderRouteKey,
5
+ } from "./plugin";
6
+ export {
7
+ getAllForms,
8
+ getFormById,
9
+ getFormBySlug,
10
+ getFormSubmissions,
11
+ serializeForm,
12
+ serializeFormSubmission,
13
+ serializeFormSubmissionWithData,
14
+ } from "./getters";
15
+ export { FORM_QUERY_KEYS } from "./query-key-defs";
@@ -23,12 +23,101 @@ import {
23
23
  import { slugify, extractIpAddress, extractUserAgent } from "../utils";
24
24
  import {
25
25
  getAllForms,
26
+ getFormById as getFormByIdFromDb,
26
27
  getFormBySlug as getFormBySlugFromDb,
27
28
  getFormSubmissions,
28
29
  serializeForm,
29
30
  serializeFormSubmission,
30
31
  serializeFormSubmissionWithData,
31
32
  } from "./getters";
33
+ import { FORM_QUERY_KEYS } from "./query-key-defs";
34
+ import type { QueryClient } from "@tanstack/react-query";
35
+
36
+ /**
37
+ * Route keys for the Form Builder plugin — matches the keys returned by
38
+ * `stackClient.router.getRoute(path).routeKey`.
39
+ */
40
+ export type FormBuilderRouteKey =
41
+ | "formList"
42
+ | "newForm"
43
+ | "editForm"
44
+ | "submissions";
45
+
46
+ interface FormBuilderPrefetchForRoute {
47
+ (key: "formList" | "newForm", qc: QueryClient): Promise<void>;
48
+ (
49
+ key: "editForm" | "submissions",
50
+ qc: QueryClient,
51
+ params: { id: string },
52
+ ): Promise<void>;
53
+ }
54
+
55
+ function createFormBuilderPrefetchForRoute(
56
+ adapter: Parameters<typeof getAllForms>[0],
57
+ ): FormBuilderPrefetchForRoute {
58
+ return async function prefetchForRoute(
59
+ key: FormBuilderRouteKey,
60
+ qc: QueryClient,
61
+ params?: Record<string, string>,
62
+ ): Promise<void> {
63
+ switch (key) {
64
+ case "formList": {
65
+ const result = await getAllForms(adapter, { limit: 20, offset: 0 });
66
+ qc.setQueryData(FORM_QUERY_KEYS.formsList({ limit: 20, offset: 0 }), {
67
+ pages: [
68
+ {
69
+ items: result.items,
70
+ total: result.total,
71
+ limit: result.limit ?? 20,
72
+ offset: result.offset ?? 0,
73
+ },
74
+ ],
75
+ pageParams: [0],
76
+ });
77
+ break;
78
+ }
79
+ case "editForm": {
80
+ const id = params?.id ?? "";
81
+ if (id) {
82
+ const form = await getFormByIdFromDb(adapter, id);
83
+ qc.setQueryData(FORM_QUERY_KEYS.formById(id), form);
84
+ }
85
+ break;
86
+ }
87
+ case "submissions": {
88
+ const id = params?.id ?? "";
89
+ if (id) {
90
+ const [form, submissionsResult] = await Promise.all([
91
+ getFormByIdFromDb(adapter, id),
92
+ getFormSubmissions(adapter, id, { limit: 20, offset: 0 }),
93
+ ]);
94
+ qc.setQueryData(FORM_QUERY_KEYS.formById(id), form);
95
+ qc.setQueryData(
96
+ FORM_QUERY_KEYS.submissionsList({
97
+ formId: id,
98
+ limit: 20,
99
+ offset: 0,
100
+ }),
101
+ {
102
+ pages: [
103
+ {
104
+ items: submissionsResult.items,
105
+ total: submissionsResult.total,
106
+ limit: submissionsResult.limit ?? 20,
107
+ offset: submissionsResult.offset ?? 0,
108
+ },
109
+ ],
110
+ pageParams: [0],
111
+ },
112
+ );
113
+ }
114
+ break;
115
+ }
116
+ default:
117
+ break;
118
+ }
119
+ } as FormBuilderPrefetchForRoute;
120
+ }
32
121
 
33
122
  /**
34
123
  * Form Builder backend plugin
@@ -47,11 +136,13 @@ export const formBuilderBackendPlugin = (
47
136
  api: (adapter) => ({
48
137
  getAllForms: (params?: Parameters<typeof getAllForms>[1]) =>
49
138
  getAllForms(adapter, params),
139
+ getFormById: (id: string) => getFormByIdFromDb(adapter, id),
50
140
  getFormBySlug: (slug: string) => getFormBySlugFromDb(adapter, slug),
51
141
  getFormSubmissions: (
52
142
  formId: string,
53
143
  params?: Parameters<typeof getFormSubmissions>[2],
54
144
  ) => getFormSubmissions(adapter, formId, params),
145
+ prefetchForRoute: createFormBuilderPrefetchForRoute(adapter),
55
146
  }),
56
147
 
57
148
  routes: (adapter: Adapter) => {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Internal query key constants for the Form Builder plugin.
3
+ * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path)
4
+ * to prevent key drift between SSR loaders and SSG prefetching.
5
+ */
6
+
7
+ export interface FormsListDiscriminator {
8
+ status?: "active" | "inactive" | "archived";
9
+ limit: number;
10
+ offset: number;
11
+ }
12
+
13
+ export interface SubmissionsListDiscriminator {
14
+ formId: string;
15
+ limit: number;
16
+ offset: number;
17
+ }
18
+
19
+ /**
20
+ * Builds the discriminator object for the forms list query key.
21
+ * Mirrors the params object used in createFormsQueries.list.
22
+ */
23
+ export function formsListDiscriminator(params?: {
24
+ status?: "active" | "inactive" | "archived";
25
+ limit?: number;
26
+ offset?: number;
27
+ }): FormsListDiscriminator {
28
+ return {
29
+ status: params?.status,
30
+ limit: params?.limit ?? 20,
31
+ offset: params?.offset ?? 0,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Builds the discriminator object for the submissions list query key.
37
+ * Mirrors the params object used in createSubmissionsQueries.list.
38
+ */
39
+ export function submissionsListDiscriminator(params: {
40
+ formId: string;
41
+ limit?: number;
42
+ offset?: number;
43
+ }): SubmissionsListDiscriminator {
44
+ return {
45
+ formId: params.formId,
46
+ limit: params.limit ?? 20,
47
+ offset: params.offset ?? 0,
48
+ };
49
+ }
50
+
51
+ /** Full query key builders — use these with queryClient.setQueryData() */
52
+ export const FORM_QUERY_KEYS = {
53
+ /**
54
+ * Key for forms.list(params) query.
55
+ * Full key: ["forms", "list", "list", { status, limit, offset }]
56
+ */
57
+ formsList: (params?: {
58
+ status?: "active" | "inactive" | "archived";
59
+ limit?: number;
60
+ offset?: number;
61
+ }) => ["forms", "list", "list", formsListDiscriminator(params)] as const,
62
+
63
+ /**
64
+ * Key for forms.byId(id) query.
65
+ * Full key: ["forms", "byId", "byId", id]
66
+ */
67
+ formById: (id: string) => ["forms", "byId", "byId", id] as const,
68
+
69
+ /**
70
+ * Key for formSubmissions.list(params) query.
71
+ * Full key: ["formSubmissions", "list", { formId, limit, offset }]
72
+ */
73
+ submissionsList: (params: {
74
+ formId: string;
75
+ limit?: number;
76
+ offset?: number;
77
+ }) =>
78
+ ["formSubmissions", "list", submissionsListDiscriminator(params)] as const,
79
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Re-exports serialization helpers from getters.ts for consumers who import
3
+ * from @btst/stack/plugins/form-builder/api.
4
+ *
5
+ * The actual implementations live in getters.ts alongside the DB functions
6
+ * they serialize so they stay in sync with the returned types.
7
+ */
8
+ export {
9
+ serializeForm,
10
+ serializeFormSubmission,
11
+ serializeFormSubmissionWithData,
12
+ } from "./getters";
@@ -3,6 +3,7 @@ import { lazy } from "react";
3
3
  import {
4
4
  defineClientPlugin,
5
5
  createApiClient,
6
+ isConnectionError,
6
7
  } from "@btst/stack/plugins/client";
7
8
  import { createRoute } from "@btst/yar";
8
9
  import type { QueryClient } from "@tanstack/react-query";
@@ -197,6 +198,12 @@ function createFormListLoader(config: FormBuilderClientConfig) {
197
198
  }
198
199
  } catch (error) {
199
200
  // Error hook - log the error but don't throw during SSR
201
+ if (isConnectionError(error)) {
202
+ console.warn(
203
+ "[btst/form-builder] route.loader() failed — no server running at build time. " +
204
+ "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.",
205
+ );
206
+ }
200
207
  if (hooks?.onLoadError) {
201
208
  await hooks.onLoadError(error as Error, context);
202
209
  }
@@ -265,6 +272,12 @@ function createFormBuilderLoader(
265
272
  }
266
273
  } catch (error) {
267
274
  // Error hook - log the error but don't throw during SSR
275
+ if (isConnectionError(error)) {
276
+ console.warn(
277
+ "[btst/form-builder] route.loader() failed — no server running at build time. " +
278
+ "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.",
279
+ );
280
+ }
268
281
  if (hooks?.onLoadError) {
269
282
  await hooks.onLoadError(error as Error, context);
270
283
  }
@@ -364,6 +377,12 @@ function createSubmissionsLoader(
364
377
  }
365
378
  } catch (error) {
366
379
  // Error hook - log the error but don't throw during SSR
380
+ if (isConnectionError(error)) {
381
+ console.warn(
382
+ "[btst/form-builder] route.loader() failed — no server running at build time. " +
383
+ "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.",
384
+ );
385
+ }
367
386
  if (hooks?.onLoadError) {
368
387
  await hooks.onLoadError(error as Error, context);
369
388
  }
@@ -10,6 +10,10 @@ import type {
10
10
  PaginatedFormSubmissions,
11
11
  SerializedFormSubmissionWithData,
12
12
  } from "./types";
13
+ import {
14
+ formsListDiscriminator,
15
+ submissionsListDiscriminator,
16
+ } from "./api/query-key-defs";
13
17
 
14
18
  interface FormListParams {
15
19
  status?: "active" | "inactive" | "archived";
@@ -75,7 +79,7 @@ function createFormsQueries(
75
79
  ) {
76
80
  return createQueryKeys("forms", {
77
81
  list: (params: FormListParams = {}) => ({
78
- queryKey: ["list", params],
82
+ queryKey: ["list", formsListDiscriminator(params)],
79
83
  queryFn: async () => {
80
84
  try {
81
85
  const response: unknown = await client("/forms", {
@@ -147,7 +151,7 @@ function createSubmissionsQueries(
147
151
  ) {
148
152
  return createQueryKeys("formSubmissions", {
149
153
  list: (params: SubmissionListParams) => ({
150
- queryKey: [params],
154
+ queryKey: [submissionsListDiscriminator(params)],
151
155
  queryFn: async () => {
152
156
  try {
153
157
  const response: unknown = await client("/forms/:formId/submissions", {
@@ -1,7 +1,10 @@
1
1
  export {
2
2
  kanbanBackendPlugin,
3
3
  type KanbanApiRouter,
4
+ type KanbanRouteKey,
4
5
  type KanbanApiContext,
5
6
  type KanbanBackendHooks,
6
7
  } from "./plugin";
7
8
  export { getAllBoards, getBoardById, type BoardListResult } from "./getters";
9
+ export { serializeBoard, serializeColumn, serializeTask } from "./serializers";
10
+ export { KANBAN_QUERY_KEYS } from "./query-key-defs";