@btst/stack 2.1.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 (229) hide show
  1. package/dist/api/index.cjs +9 -1
  2. package/dist/api/index.d.cts +4 -4
  3. package/dist/api/index.d.mts +4 -4
  4. package/dist/api/index.d.ts +4 -4
  5. package/dist/api/index.mjs +9 -1
  6. package/dist/client/index.d.cts +2 -2
  7. package/dist/client/index.d.mts +2 -2
  8. package/dist/client/index.d.ts +2 -2
  9. package/dist/index.d.cts +1 -1
  10. package/dist/index.d.mts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/packages/stack/src/plugins/ai-chat/api/getters.cjs +42 -0
  13. package/dist/packages/stack/src/plugins/ai-chat/api/getters.mjs +39 -0
  14. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
  15. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
  16. package/dist/packages/stack/src/plugins/blog/api/getters.cjs +131 -0
  17. package/dist/packages/stack/src/plugins/blog/api/getters.mjs +127 -0
  18. package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +60 -107
  19. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +60 -107
  20. package/dist/packages/stack/src/plugins/blog/api/query-key-defs.cjs +18 -0
  21. package/dist/packages/stack/src/plugins/blog/api/query-key-defs.mjs +15 -0
  22. package/dist/packages/stack/src/plugins/blog/api/serializers.cjs +21 -0
  23. package/dist/packages/stack/src/plugins/blog/api/serializers.mjs +18 -0
  24. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +16 -1
  25. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +17 -2
  26. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +156 -0
  27. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +147 -0
  28. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +624 -617
  29. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +623 -616
  30. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +29 -0
  31. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +26 -0
  32. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +1 -1
  33. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +1 -1
  34. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.cjs +6 -3
  35. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.mjs +6 -3
  36. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +15 -0
  37. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +16 -1
  38. package/dist/packages/stack/src/plugins/form-builder/api/getters.cjs +120 -0
  39. package/dist/packages/stack/src/plugins/form-builder/api/getters.mjs +112 -0
  40. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +75 -86
  41. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +71 -82
  42. package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.cjs +37 -0
  43. package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.mjs +33 -0
  44. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.cjs +1 -1
  45. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.mjs +1 -1
  46. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +15 -0
  47. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +16 -1
  48. package/dist/packages/stack/src/plugins/kanban/api/getters.cjs +84 -0
  49. package/dist/packages/stack/src/plugins/kanban/api/getters.mjs +81 -0
  50. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +37 -123
  51. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +37 -123
  52. package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.cjs +26 -0
  53. package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.mjs +23 -0
  54. package/dist/packages/stack/src/plugins/kanban/api/serializers.cjs +30 -0
  55. package/dist/packages/stack/src/plugins/kanban/api/serializers.mjs +26 -0
  56. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +11 -1
  57. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +12 -2
  58. package/dist/packages/stack/src/plugins/utils.cjs +6 -0
  59. package/dist/packages/stack/src/plugins/utils.mjs +6 -1
  60. package/dist/plugins/ai-chat/api/index.cjs +3 -0
  61. package/dist/plugins/ai-chat/api/index.d.cts +27 -4
  62. package/dist/plugins/ai-chat/api/index.d.mts +27 -4
  63. package/dist/plugins/ai-chat/api/index.d.ts +27 -4
  64. package/dist/plugins/ai-chat/api/index.mjs +1 -0
  65. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  66. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  67. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  68. package/dist/plugins/ai-chat/query-keys.d.cts +9 -284
  69. package/dist/plugins/ai-chat/query-keys.d.mts +9 -284
  70. package/dist/plugins/ai-chat/query-keys.d.ts +9 -284
  71. package/dist/plugins/api/index.d.cts +4 -3
  72. package/dist/plugins/api/index.d.mts +4 -3
  73. package/dist/plugins/api/index.d.ts +4 -3
  74. package/dist/plugins/blog/api/index.cjs +9 -0
  75. package/dist/plugins/blog/api/index.d.cts +20 -4
  76. package/dist/plugins/blog/api/index.d.mts +20 -4
  77. package/dist/plugins/blog/api/index.d.ts +20 -4
  78. package/dist/plugins/blog/api/index.mjs +3 -0
  79. package/dist/plugins/blog/client/hooks/index.d.cts +5 -5
  80. package/dist/plugins/blog/client/hooks/index.d.mts +5 -5
  81. package/dist/plugins/blog/client/hooks/index.d.ts +5 -5
  82. package/dist/plugins/blog/client/index.d.cts +1 -1
  83. package/dist/plugins/blog/client/index.d.mts +1 -1
  84. package/dist/plugins/blog/client/index.d.ts +1 -1
  85. package/dist/plugins/blog/query-keys.cjs +13 -9
  86. package/dist/plugins/blog/query-keys.d.cts +8 -333
  87. package/dist/plugins/blog/query-keys.d.mts +8 -333
  88. package/dist/plugins/blog/query-keys.d.ts +8 -333
  89. package/dist/plugins/blog/query-keys.mjs +13 -9
  90. package/dist/plugins/client/index.cjs +1 -0
  91. package/dist/plugins/client/index.d.cts +10 -3
  92. package/dist/plugins/client/index.d.mts +10 -3
  93. package/dist/plugins/client/index.d.ts +10 -3
  94. package/dist/plugins/client/index.mjs +1 -1
  95. package/dist/plugins/cms/api/index.cjs +10 -0
  96. package/dist/plugins/cms/api/index.d.cts +7 -163
  97. package/dist/plugins/cms/api/index.d.mts +7 -163
  98. package/dist/plugins/cms/api/index.d.ts +7 -163
  99. package/dist/plugins/cms/api/index.mjs +2 -0
  100. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  101. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  102. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  103. package/dist/plugins/cms/query-keys.cjs +2 -1
  104. package/dist/plugins/cms/query-keys.d.cts +6 -9
  105. package/dist/plugins/cms/query-keys.d.mts +6 -9
  106. package/dist/plugins/cms/query-keys.d.ts +6 -9
  107. package/dist/plugins/cms/query-keys.mjs +2 -1
  108. package/dist/plugins/form-builder/api/index.cjs +10 -0
  109. package/dist/plugins/form-builder/api/index.d.cts +7 -141
  110. package/dist/plugins/form-builder/api/index.d.mts +7 -141
  111. package/dist/plugins/form-builder/api/index.d.ts +7 -141
  112. package/dist/plugins/form-builder/api/index.mjs +2 -0
  113. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  114. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  115. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  116. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  117. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  118. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  119. package/dist/plugins/form-builder/query-keys.cjs +3 -2
  120. package/dist/plugins/form-builder/query-keys.d.cts +7 -6
  121. package/dist/plugins/form-builder/query-keys.d.mts +7 -6
  122. package/dist/plugins/form-builder/query-keys.d.ts +7 -6
  123. package/dist/plugins/form-builder/query-keys.mjs +3 -2
  124. package/dist/plugins/kanban/api/index.cjs +9 -0
  125. package/dist/plugins/kanban/api/index.d.cts +17 -395
  126. package/dist/plugins/kanban/api/index.d.mts +17 -395
  127. package/dist/plugins/kanban/api/index.d.ts +17 -395
  128. package/dist/plugins/kanban/api/index.mjs +3 -0
  129. package/dist/plugins/kanban/client/components/index.d.cts +1 -1
  130. package/dist/plugins/kanban/client/components/index.d.mts +1 -1
  131. package/dist/plugins/kanban/client/components/index.d.ts +1 -1
  132. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  133. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  134. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  135. package/dist/plugins/kanban/client/index.d.cts +1 -1
  136. package/dist/plugins/kanban/client/index.d.mts +1 -1
  137. package/dist/plugins/kanban/client/index.d.ts +1 -1
  138. package/dist/plugins/kanban/query-keys.cjs +6 -12
  139. package/dist/plugins/kanban/query-keys.d.cts +5 -16
  140. package/dist/plugins/kanban/query-keys.d.mts +5 -16
  141. package/dist/plugins/kanban/query-keys.d.ts +5 -16
  142. package/dist/plugins/kanban/query-keys.mjs +6 -12
  143. package/dist/plugins/open-api/api/index.d.cts +2 -2
  144. package/dist/plugins/open-api/api/index.d.mts +2 -2
  145. package/dist/plugins/open-api/api/index.d.ts +2 -2
  146. package/dist/plugins/route-docs/client/index.d.cts +1 -1
  147. package/dist/plugins/route-docs/client/index.d.mts +1 -1
  148. package/dist/plugins/route-docs/client/index.d.ts +1 -1
  149. package/dist/plugins/ui-builder/index.d.cts +1 -1
  150. package/dist/plugins/ui-builder/index.d.mts +1 -1
  151. package/dist/plugins/ui-builder/index.d.ts +1 -1
  152. package/dist/shared/{stack.BoA0xkJv.d.cts → stack.7n9Y_u7N.d.cts} +33 -7
  153. package/dist/shared/{stack.BoA0xkJv.d.mts → stack.7n9Y_u7N.d.mts} +33 -7
  154. package/dist/shared/{stack.BoA0xkJv.d.ts → stack.7n9Y_u7N.d.ts} +33 -7
  155. package/dist/shared/stack.B1EeBt1b.d.ts +297 -0
  156. package/dist/shared/stack.BIXEI6v_.d.mts +419 -0
  157. package/dist/shared/stack.BKfolAyK.d.ts +419 -0
  158. package/dist/shared/stack.BeSm90va.d.ts +289 -0
  159. package/dist/shared/stack.BpolpQpf.d.cts +445 -0
  160. package/dist/shared/stack.C5dtIncc.d.mts +293 -0
  161. package/dist/shared/stack.CIP6QS9l.d.ts +293 -0
  162. package/dist/shared/stack.CMh_EdxW.d.cts +289 -0
  163. package/dist/shared/stack.CP68pFEH.d.mts +297 -0
  164. package/dist/shared/{stack.BsXokfNh.d.mts → stack.CVDTkMoO.d.cts} +8 -2
  165. package/dist/shared/{stack.BsXokfNh.d.ts → stack.CVDTkMoO.d.mts} +8 -2
  166. package/dist/shared/{stack.BsXokfNh.d.cts → stack.CVDTkMoO.d.ts} +8 -2
  167. package/dist/shared/{stack.DKDMI-QO.d.mts → stack.DJaKVY7v.d.cts} +7 -1
  168. package/dist/shared/{stack.DKDMI-QO.d.ts → stack.DJaKVY7v.d.mts} +7 -1
  169. package/dist/shared/{stack.DKDMI-QO.d.cts → stack.DJaKVY7v.d.ts} +7 -1
  170. package/dist/shared/{stack.DzH_wcvr.d.mts → stack.DdI5W6MB.d.cts} +9 -3
  171. package/dist/shared/{stack.DzH_wcvr.d.ts → stack.DdI5W6MB.d.mts} +9 -3
  172. package/dist/shared/{stack.DzH_wcvr.d.cts → stack.DdI5W6MB.d.ts} +9 -3
  173. package/dist/shared/stack.Dg09R0oB.d.mts +289 -0
  174. package/dist/shared/stack.Dw0Ly2TM.d.cts +293 -0
  175. package/dist/shared/stack.IdtKDRka.d.cts +297 -0
  176. package/dist/shared/stack.TIBF2AOx.d.ts +445 -0
  177. package/dist/shared/stack.rTy7-wQU.d.mts +445 -0
  178. package/dist/shared/stack.snB1EDP7.d.cts +419 -0
  179. package/package.json +3 -3
  180. package/src/__tests__/stack-api.test.ts +118 -0
  181. package/src/api/index.ts +15 -1
  182. package/src/plugins/ai-chat/__tests__/getters.test.ts +109 -0
  183. package/src/plugins/ai-chat/api/getters.ts +71 -0
  184. package/src/plugins/ai-chat/api/index.ts +1 -0
  185. package/src/plugins/ai-chat/api/plugin.ts +8 -0
  186. package/src/plugins/api/index.ts +3 -1
  187. package/src/plugins/blog/__tests__/getters.test.ts +540 -0
  188. package/src/plugins/blog/api/getters.ts +243 -0
  189. package/src/plugins/blog/api/index.ts +9 -0
  190. package/src/plugins/blog/api/plugin.ts +98 -141
  191. package/src/plugins/blog/api/query-key-defs.ts +46 -0
  192. package/src/plugins/blog/api/serializers.ts +27 -0
  193. package/src/plugins/blog/client/plugin.tsx +21 -1
  194. package/src/plugins/blog/query-keys.ts +21 -20
  195. package/src/plugins/client/index.ts +1 -1
  196. package/src/plugins/cms/__tests__/getters.test.ts +206 -0
  197. package/src/plugins/cms/api/getters.ts +268 -0
  198. package/src/plugins/cms/api/index.ts +15 -1
  199. package/src/plugins/cms/api/plugin.ts +151 -150
  200. package/src/plugins/cms/api/query-key-defs.ts +53 -0
  201. package/src/plugins/cms/api/serializers.ts +12 -0
  202. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +1 -1
  203. package/src/plugins/cms/client/hooks/cms-hooks.tsx +3 -0
  204. package/src/plugins/cms/client/plugin.tsx +19 -0
  205. package/src/plugins/cms/query-keys.ts +2 -1
  206. package/src/plugins/cms/types.ts +1 -1
  207. package/src/plugins/form-builder/__tests__/getters.test.ts +159 -0
  208. package/src/plugins/form-builder/api/getters.ts +226 -0
  209. package/src/plugins/form-builder/api/index.ts +15 -1
  210. package/src/plugins/form-builder/api/plugin.ts +107 -109
  211. package/src/plugins/form-builder/api/query-key-defs.ts +79 -0
  212. package/src/plugins/form-builder/api/serializers.ts +12 -0
  213. package/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +1 -1
  214. package/src/plugins/form-builder/client/plugin.tsx +19 -0
  215. package/src/plugins/form-builder/query-keys.ts +6 -2
  216. package/src/plugins/form-builder/types.ts +2 -2
  217. package/src/plugins/kanban/__tests__/getters.test.ts +172 -0
  218. package/src/plugins/kanban/api/getters.ts +149 -0
  219. package/src/plugins/kanban/api/index.ts +4 -0
  220. package/src/plugins/kanban/api/plugin.ts +65 -146
  221. package/src/plugins/kanban/api/query-key-defs.ts +54 -0
  222. package/src/plugins/kanban/api/serializers.ts +49 -0
  223. package/src/plugins/kanban/client/plugin.tsx +15 -1
  224. package/src/plugins/kanban/query-keys.ts +10 -14
  225. package/src/plugins/utils.ts +19 -0
  226. package/src/types.ts +44 -5
  227. package/dist/shared/{stack.CbuN2zVV.d.cts → stack.CBON0dWL.d.cts} +7 -7
  228. package/dist/shared/{stack.CbuN2zVV.d.mts → stack.CBON0dWL.d.mts} +7 -7
  229. package/dist/shared/{stack.CbuN2zVV.d.ts → stack.CBON0dWL.d.ts} +7 -7
@@ -14,8 +14,6 @@ import type {
14
14
  ContentRelation,
15
15
  CMSBackendConfig,
16
16
  CMSHookContext,
17
- SerializedContentType,
18
- SerializedContentItem,
19
17
  SerializedContentItemWithType,
20
18
  RelationConfig,
21
19
  RelationValue,
@@ -23,96 +21,40 @@ import type {
23
21
  } from "../types";
24
22
  import { listContentQuerySchema } from "../schemas";
25
23
  import { slugify } from "../utils";
24
+ import {
25
+ getAllContentTypes,
26
+ getAllContentItems,
27
+ getContentItemBySlug,
28
+ getContentItemById,
29
+ serializeContentType,
30
+ serializeContentItem,
31
+ serializeContentItemWithType,
32
+ } from "./getters";
33
+ import type { QueryClient } from "@tanstack/react-query";
34
+ import { CMS_QUERY_KEYS } from "./query-key-defs";
26
35
 
27
36
  /**
28
- * Migrate a legacy JSON Schema (version 1) to unified format (version 2)
29
- * by merging fieldConfig values into the JSON Schema properties
30
- */
31
- function migrateToUnifiedSchema(
32
- jsonSchemaStr: string,
33
- fieldConfigStr: string | null | undefined,
34
- ): string {
35
- if (!fieldConfigStr) {
36
- return jsonSchemaStr;
37
- }
38
-
39
- try {
40
- const jsonSchema = JSON.parse(jsonSchemaStr);
41
- const fieldConfig = JSON.parse(fieldConfigStr);
42
-
43
- if (!jsonSchema.properties || typeof fieldConfig !== "object") {
44
- return jsonSchemaStr;
45
- }
46
-
47
- // Merge fieldType from fieldConfig into each property
48
- for (const [key, config] of Object.entries(fieldConfig)) {
49
- if (
50
- jsonSchema.properties[key] &&
51
- typeof config === "object" &&
52
- config !== null &&
53
- "fieldType" in config
54
- ) {
55
- jsonSchema.properties[key].fieldType = (
56
- config as { fieldType: string }
57
- ).fieldType;
58
- }
59
- }
60
-
61
- return JSON.stringify(jsonSchema);
62
- } catch {
63
- // If parsing fails, return original
64
- return jsonSchemaStr;
65
- }
66
- }
67
-
68
- /**
69
- * Serialize a ContentType for API response (convert dates to strings)
70
- * Also applies lazy migration for legacy schemas (version 1 → 2)
71
- */
72
- function serializeContentType(ct: ContentType): SerializedContentType {
73
- // Check if this is a legacy schema that needs migration
74
- const needsMigration = !ct.autoFormVersion || ct.autoFormVersion < 2;
75
-
76
- // Apply lazy migration: merge fieldConfig into jsonSchema on read
77
- const migratedJsonSchema = needsMigration
78
- ? migrateToUnifiedSchema(ct.jsonSchema, ct.fieldConfig)
79
- : ct.jsonSchema;
80
-
81
- return {
82
- id: ct.id,
83
- name: ct.name,
84
- slug: ct.slug,
85
- description: ct.description,
86
- jsonSchema: migratedJsonSchema,
87
- createdAt: ct.createdAt.toISOString(),
88
- updatedAt: ct.updatedAt.toISOString(),
89
- };
90
- }
91
-
92
- /**
93
- * Serialize a ContentItem for API response (convert dates to strings)
94
- */
95
- function serializeContentItem(item: ContentItem): SerializedContentItem {
96
- return {
97
- ...item,
98
- createdAt: item.createdAt.toISOString(),
99
- updatedAt: item.updatedAt.toISOString(),
100
- };
101
- }
102
-
103
- /**
104
- * Serialize a ContentItem with parsed data and joined ContentType
37
+ * Route keys for the CMS plugin matches the keys returned by
38
+ * `stackClient.router.getRoute(path).routeKey`.
105
39
  */
106
- function serializeContentItemWithType(
107
- item: ContentItemWithType,
108
- ): SerializedContentItemWithType {
109
- return {
110
- ...serializeContentItem(item),
111
- parsedData: JSON.parse(item.data),
112
- contentType: item.contentType
113
- ? serializeContentType(item.contentType)
114
- : undefined,
115
- };
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>;
116
58
  }
117
59
 
118
60
  /**
@@ -511,34 +453,130 @@ async function populateRelations(
511
453
  *
512
454
  * @param config - Configuration with content types and optional hooks
513
455
  */
514
- export const cmsBackendPlugin = (config: CMSBackendConfig) =>
515
- defineBackendPlugin({
456
+ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
457
+ // Shared sync state — used by both the api factory and routes handlers so
458
+ // that calling a getter before any HTTP request has been made still
459
+ // triggers the one-time content-type sync.
460
+ let syncPromise: Promise<void> | null = null;
461
+
462
+ const ensureSynced = (adapter: Adapter) => {
463
+ if (!syncPromise) {
464
+ syncPromise = syncContentTypes(adapter, config).catch((err) => {
465
+ // Allow retry on next call if sync fails
466
+ syncPromise = null;
467
+ throw err;
468
+ });
469
+ }
470
+ return syncPromise;
471
+ };
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
+
546
+ return defineBackendPlugin({
516
547
  name: "cms",
517
548
 
518
549
  dbPlugin: dbSchema,
519
550
 
520
- routes: (adapter: Adapter) => {
521
- // Sync content types on first request using promise-based lock
522
- // This prevents race conditions when multiple concurrent requests arrive
523
- // on cold start within the same instance
524
- let syncPromise: Promise<void> | null = null;
525
-
526
- const ensureSynced = async () => {
527
- if (!syncPromise) {
528
- syncPromise = syncContentTypes(adapter, config).catch((err) => {
529
- // If sync fails, allow retry on next request
530
- syncPromise = null;
531
- throw err;
532
- });
533
- }
534
- await syncPromise;
535
- };
551
+ api: (adapter) => ({
552
+ getAllContentTypes: async () => {
553
+ await ensureSynced(adapter);
554
+ return getAllContentTypes(adapter);
555
+ },
556
+ getAllContentItems: async (
557
+ contentTypeSlug: string,
558
+ params?: Parameters<typeof getAllContentItems>[2],
559
+ ) => {
560
+ await ensureSynced(adapter);
561
+ return getAllContentItems(adapter, contentTypeSlug, params);
562
+ },
563
+ getContentItemBySlug: async (contentTypeSlug: string, slug: string) => {
564
+ await ensureSynced(adapter);
565
+ return getContentItemBySlug(adapter, contentTypeSlug, slug);
566
+ },
567
+ getContentItemById: async (id: string) => {
568
+ await ensureSynced(adapter);
569
+ return getContentItemById(adapter, id);
570
+ },
571
+ prefetchForRoute: createCMSPrefetchForRoute(adapter),
572
+ }),
536
573
 
574
+ routes: (adapter: Adapter) => {
537
575
  // Helper to get content type by slug
538
576
  const getContentType = async (
539
577
  slug: string,
540
578
  ): Promise<ContentType | null> => {
541
- await ensureSynced();
579
+ await ensureSynced(adapter);
542
580
  return adapter.findOne<ContentType>({
543
581
  model: "contentType",
544
582
  where: [{ field: "slug", value: slug, operator: "eq" as const }],
@@ -560,7 +598,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
560
598
  "/content-types",
561
599
  { method: "GET" },
562
600
  async (ctx) => {
563
- await ensureSynced();
601
+ await ensureSynced(adapter);
564
602
 
565
603
  const contentTypes = await adapter.findMany<ContentType>({
566
604
  model: "contentType",
@@ -627,45 +665,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
627
665
  throw ctx.error(404, { message: "Content type not found" });
628
666
  }
629
667
 
630
- const whereConditions = [
631
- {
632
- field: "contentTypeId",
633
- value: contentType.id,
634
- operator: "eq" as const,
635
- },
636
- ];
637
-
638
- if (slug) {
639
- whereConditions.push({
640
- field: "slug",
641
- value: slug,
642
- operator: "eq" as const,
643
- });
644
- }
645
-
646
- // Get total count
647
- const allItems = await adapter.findMany<ContentItem>({
648
- model: "contentItem",
649
- where: whereConditions,
650
- });
651
- const total = allItems.length;
652
-
653
- // Get paginated items
654
- const items = await adapter.findMany<ContentItemWithType>({
655
- model: "contentItem",
656
- where: whereConditions,
657
- limit,
658
- offset,
659
- sortBy: { field: "createdAt", direction: "desc" },
660
- join: { contentType: true },
661
- });
662
-
663
- return {
664
- items: items.map(serializeContentItemWithType),
665
- total,
666
- limit,
667
- offset,
668
- };
668
+ return getAllContentItems(adapter, typeSlug, { slug, limit, offset });
669
669
  },
670
670
  );
671
671
 
@@ -1139,7 +1139,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
1139
1139
  const { slug } = ctx.params;
1140
1140
  const { itemId } = ctx.query;
1141
1141
 
1142
- await ensureSynced();
1142
+ await ensureSynced(adapter);
1143
1143
 
1144
1144
  // Get the target content type
1145
1145
  const targetContentType = await getContentType(slug);
@@ -1239,7 +1239,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
1239
1239
  const { slug, sourceType } = ctx.params;
1240
1240
  const { itemId, fieldName, limit, offset } = ctx.query;
1241
1241
 
1242
- await ensureSynced();
1242
+ await ensureSynced(adapter);
1243
1243
 
1244
1244
  // Verify target content type exists
1245
1245
  const targetContentType = await getContentType(slug);
@@ -1317,6 +1317,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
1317
1317
  };
1318
1318
  },
1319
1319
  });
1320
+ };
1320
1321
 
1321
1322
  export type CMSApiRouter = ReturnType<
1322
1323
  ReturnType<typeof cmsBackendPlugin>["routes"]
@@ -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";
@@ -241,7 +241,7 @@ export function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {
241
241
  contentType={contentType}
242
242
  initialData={
243
243
  isEditing
244
- ? item?.parsedData
244
+ ? (item?.parsedData ?? undefined)
245
245
  : Object.keys(prefillParams).length > 0
246
246
  ? convertPrefillToFormData(
247
247
  prefillParams,
@@ -608,9 +608,11 @@ export function useCreateContent<TData = Record<string, unknown>>(
608
608
  onSuccess: async () => {
609
609
  await queryClient.invalidateQueries({
610
610
  queryKey: queries.cmsContent.list._def,
611
+ refetchType: "all",
611
612
  });
612
613
  await queryClient.invalidateQueries({
613
614
  queryKey: queries.cmsTypes.list._def,
615
+ refetchType: "all",
614
616
  });
615
617
  if (refresh) {
616
618
  await refresh();
@@ -675,6 +677,7 @@ export function useUpdateContent<TData = Record<string, unknown>>(
675
677
  }
676
678
  await queryClient.invalidateQueries({
677
679
  queryKey: queries.cmsContent.list._def,
680
+ refetchType: "all",
678
681
  });
679
682
  if (refresh) {
680
683
  await refresh();
@@ -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", {
@@ -178,7 +178,7 @@ export interface SerializedContentItem
178
178
  */
179
179
  export interface SerializedContentItemWithType<TData = Record<string, unknown>>
180
180
  extends SerializedContentItem {
181
- /** Parsed data object (JSON.parse of data field) */
181
+ /** Parsed data object (JSON.parse of data field). */
182
182
  parsedData: TData;
183
183
  /** Joined content type */
184
184
  contentType?: SerializedContentType;
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createMemoryAdapter } from "@btst/adapter-memory";
3
+ import { defineDb } from "@btst/db";
4
+ import type { Adapter } from "@btst/db";
5
+ import { formBuilderSchema } from "../db";
6
+ import { getAllForms, getFormBySlug, getFormSubmissions } from "../api/getters";
7
+
8
+ const createTestAdapter = (): Adapter => {
9
+ const db = defineDb({}).use(formBuilderSchema);
10
+ return createMemoryAdapter(db)({});
11
+ };
12
+
13
+ const SIMPLE_SCHEMA = JSON.stringify({
14
+ type: "object",
15
+ properties: { name: { type: "string" } },
16
+ });
17
+
18
+ async function createForm(
19
+ adapter: Adapter,
20
+ slug: string,
21
+ status = "active",
22
+ ): Promise<any> {
23
+ return adapter.create({
24
+ model: "form",
25
+ data: {
26
+ name: `Form ${slug}`,
27
+ slug,
28
+ schema: SIMPLE_SCHEMA,
29
+ status,
30
+ createdAt: new Date(),
31
+ updatedAt: new Date(),
32
+ },
33
+ });
34
+ }
35
+
36
+ describe("form-builder getters", () => {
37
+ let adapter: Adapter;
38
+
39
+ beforeEach(() => {
40
+ adapter = createTestAdapter();
41
+ });
42
+
43
+ describe("getAllForms", () => {
44
+ it("returns empty result when no forms exist", async () => {
45
+ const result = await getAllForms(adapter);
46
+ expect(result.items).toEqual([]);
47
+ expect(result.total).toBe(0);
48
+ });
49
+
50
+ it("returns all forms serialized", async () => {
51
+ await createForm(adapter, "contact");
52
+ await createForm(adapter, "feedback");
53
+
54
+ const result = await getAllForms(adapter);
55
+ expect(result.items).toHaveLength(2);
56
+ expect(result.total).toBe(2);
57
+ expect(typeof result.items[0]!.createdAt).toBe("string");
58
+ });
59
+
60
+ it("filters forms by status", async () => {
61
+ await createForm(adapter, "active-form", "active");
62
+ await createForm(adapter, "inactive-form", "inactive");
63
+
64
+ const active = await getAllForms(adapter, { status: "active" });
65
+ expect(active.items).toHaveLength(1);
66
+ expect(active.items[0]!.slug).toBe("active-form");
67
+
68
+ const inactive = await getAllForms(adapter, { status: "inactive" });
69
+ expect(inactive.items).toHaveLength(1);
70
+ expect(inactive.items[0]!.slug).toBe("inactive-form");
71
+ });
72
+
73
+ it("respects limit and offset", async () => {
74
+ for (let i = 1; i <= 4; i++) {
75
+ await createForm(adapter, `form-${i}`);
76
+ }
77
+
78
+ const page1 = await getAllForms(adapter, { limit: 2, offset: 0 });
79
+ expect(page1.items).toHaveLength(2);
80
+ expect(page1.total).toBe(4);
81
+
82
+ const page2 = await getAllForms(adapter, { limit: 2, offset: 2 });
83
+ expect(page2.items).toHaveLength(2);
84
+ });
85
+ });
86
+
87
+ describe("getFormBySlug", () => {
88
+ it("returns null when form does not exist", async () => {
89
+ const form = await getFormBySlug(adapter, "nonexistent");
90
+ expect(form).toBeNull();
91
+ });
92
+
93
+ it("returns the form when it exists", async () => {
94
+ await createForm(adapter, "contact");
95
+
96
+ const form = await getFormBySlug(adapter, "contact");
97
+ expect(form).not.toBeNull();
98
+ expect(form!.slug).toBe("contact");
99
+ expect(typeof form!.createdAt).toBe("string");
100
+ });
101
+ });
102
+
103
+ describe("getFormSubmissions", () => {
104
+ it("returns empty result when form does not exist", async () => {
105
+ const result = await getFormSubmissions(adapter, "nonexistent-id");
106
+ expect(result.items).toEqual([]);
107
+ expect(result.total).toBe(0);
108
+ });
109
+
110
+ it("returns submissions for a form", async () => {
111
+ const form = (await createForm(adapter, "contact")) as any;
112
+
113
+ await adapter.create({
114
+ model: "formSubmission",
115
+ data: {
116
+ formId: form.id,
117
+ data: JSON.stringify({ name: "Alice" }),
118
+ submittedAt: new Date(),
119
+ },
120
+ });
121
+ await adapter.create({
122
+ model: "formSubmission",
123
+ data: {
124
+ formId: form.id,
125
+ data: JSON.stringify({ name: "Bob" }),
126
+ submittedAt: new Date(),
127
+ },
128
+ });
129
+
130
+ const result = await getFormSubmissions(adapter, form.id);
131
+ expect(result.items).toHaveLength(2);
132
+ expect(result.total).toBe(2);
133
+ expect(typeof result.items[0]!.submittedAt).toBe("string");
134
+ expect(result.items[0]!.parsedData).toBeDefined();
135
+ });
136
+
137
+ it("respects pagination", async () => {
138
+ const form = (await createForm(adapter, "survey")) as any;
139
+
140
+ for (let i = 1; i <= 5; i++) {
141
+ await adapter.create({
142
+ model: "formSubmission",
143
+ data: {
144
+ formId: form.id,
145
+ data: JSON.stringify({ name: `User ${i}` }),
146
+ submittedAt: new Date(Date.now() + i * 1000),
147
+ },
148
+ });
149
+ }
150
+
151
+ const page1 = await getFormSubmissions(adapter, form.id, {
152
+ limit: 2,
153
+ offset: 0,
154
+ });
155
+ expect(page1.items).toHaveLength(2);
156
+ expect(page1.total).toBe(5);
157
+ });
158
+ });
159
+ });