@btst/stack 2.1.0 → 2.2.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 (179) 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 +9 -107
  19. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +9 -107
  20. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +1 -1
  21. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +1 -1
  22. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +146 -0
  23. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +138 -0
  24. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +560 -622
  25. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +559 -621
  26. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +1 -1
  27. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +1 -1
  28. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.cjs +6 -3
  29. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.mjs +6 -3
  30. package/dist/packages/stack/src/plugins/form-builder/api/getters.cjs +111 -0
  31. package/dist/packages/stack/src/plugins/form-builder/api/getters.mjs +104 -0
  32. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +16 -88
  33. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +12 -84
  34. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.cjs +1 -1
  35. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.mjs +1 -1
  36. package/dist/packages/stack/src/plugins/kanban/api/getters.cjs +84 -0
  37. package/dist/packages/stack/src/plugins/kanban/api/getters.mjs +81 -0
  38. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +9 -123
  39. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +9 -123
  40. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +1 -1
  41. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +1 -1
  42. package/dist/plugins/ai-chat/api/index.cjs +3 -0
  43. package/dist/plugins/ai-chat/api/index.d.cts +27 -4
  44. package/dist/plugins/ai-chat/api/index.d.mts +27 -4
  45. package/dist/plugins/ai-chat/api/index.d.ts +27 -4
  46. package/dist/plugins/ai-chat/api/index.mjs +1 -0
  47. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  48. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  49. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  50. package/dist/plugins/ai-chat/query-keys.d.cts +9 -284
  51. package/dist/plugins/ai-chat/query-keys.d.mts +9 -284
  52. package/dist/plugins/ai-chat/query-keys.d.ts +9 -284
  53. package/dist/plugins/api/index.d.cts +4 -3
  54. package/dist/plugins/api/index.d.mts +4 -3
  55. package/dist/plugins/api/index.d.ts +4 -3
  56. package/dist/plugins/blog/api/index.cjs +4 -0
  57. package/dist/plugins/blog/api/index.d.cts +3 -2
  58. package/dist/plugins/blog/api/index.d.mts +3 -2
  59. package/dist/plugins/blog/api/index.d.ts +3 -2
  60. package/dist/plugins/blog/api/index.mjs +1 -0
  61. package/dist/plugins/blog/client/hooks/index.d.cts +4 -4
  62. package/dist/plugins/blog/client/hooks/index.d.mts +4 -4
  63. package/dist/plugins/blog/client/hooks/index.d.ts +4 -4
  64. package/dist/plugins/blog/client/index.d.cts +1 -1
  65. package/dist/plugins/blog/client/index.d.mts +1 -1
  66. package/dist/plugins/blog/client/index.d.ts +1 -1
  67. package/dist/plugins/blog/query-keys.cjs +7 -4
  68. package/dist/plugins/blog/query-keys.d.cts +81 -27
  69. package/dist/plugins/blog/query-keys.d.mts +81 -27
  70. package/dist/plugins/blog/query-keys.d.ts +81 -27
  71. package/dist/plugins/blog/query-keys.mjs +7 -4
  72. package/dist/plugins/client/index.d.cts +2 -2
  73. package/dist/plugins/client/index.d.mts +2 -2
  74. package/dist/plugins/client/index.d.ts +2 -2
  75. package/dist/plugins/cms/api/index.cjs +4 -0
  76. package/dist/plugins/cms/api/index.d.cts +61 -5
  77. package/dist/plugins/cms/api/index.d.mts +61 -5
  78. package/dist/plugins/cms/api/index.d.ts +61 -5
  79. package/dist/plugins/cms/api/index.mjs +1 -0
  80. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  81. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  82. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  83. package/dist/plugins/cms/query-keys.d.cts +2 -1
  84. package/dist/plugins/cms/query-keys.d.mts +2 -1
  85. package/dist/plugins/cms/query-keys.d.ts +2 -1
  86. package/dist/plugins/form-builder/api/index.cjs +4 -0
  87. package/dist/plugins/form-builder/api/index.d.cts +77 -7
  88. package/dist/plugins/form-builder/api/index.d.mts +77 -7
  89. package/dist/plugins/form-builder/api/index.d.ts +77 -7
  90. package/dist/plugins/form-builder/api/index.mjs +1 -0
  91. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  92. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  93. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  94. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  95. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  96. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  97. package/dist/plugins/form-builder/query-keys.d.cts +2 -1
  98. package/dist/plugins/form-builder/query-keys.d.mts +2 -1
  99. package/dist/plugins/form-builder/query-keys.d.ts +2 -1
  100. package/dist/plugins/kanban/api/index.cjs +3 -0
  101. package/dist/plugins/kanban/api/index.d.cts +40 -43
  102. package/dist/plugins/kanban/api/index.d.mts +40 -43
  103. package/dist/plugins/kanban/api/index.d.ts +40 -43
  104. package/dist/plugins/kanban/api/index.mjs +1 -0
  105. package/dist/plugins/kanban/client/components/index.d.cts +1 -1
  106. package/dist/plugins/kanban/client/components/index.d.mts +1 -1
  107. package/dist/plugins/kanban/client/components/index.d.ts +1 -1
  108. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  109. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  110. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  111. package/dist/plugins/kanban/client/index.d.cts +1 -1
  112. package/dist/plugins/kanban/client/index.d.mts +1 -1
  113. package/dist/plugins/kanban/client/index.d.ts +1 -1
  114. package/dist/plugins/kanban/query-keys.cjs +4 -3
  115. package/dist/plugins/kanban/query-keys.d.cts +2 -1
  116. package/dist/plugins/kanban/query-keys.d.mts +2 -1
  117. package/dist/plugins/kanban/query-keys.d.ts +2 -1
  118. package/dist/plugins/kanban/query-keys.mjs +4 -3
  119. package/dist/plugins/open-api/api/index.d.cts +2 -2
  120. package/dist/plugins/open-api/api/index.d.mts +2 -2
  121. package/dist/plugins/open-api/api/index.d.ts +2 -2
  122. package/dist/plugins/route-docs/client/index.d.cts +1 -1
  123. package/dist/plugins/route-docs/client/index.d.mts +1 -1
  124. package/dist/plugins/route-docs/client/index.d.ts +1 -1
  125. package/dist/plugins/ui-builder/index.d.cts +1 -1
  126. package/dist/plugins/ui-builder/index.d.mts +1 -1
  127. package/dist/plugins/ui-builder/index.d.ts +1 -1
  128. package/dist/shared/{stack.BoA0xkJv.d.cts → stack.7n9Y_u7N.d.cts} +33 -7
  129. package/dist/shared/{stack.BoA0xkJv.d.mts → stack.7n9Y_u7N.d.mts} +33 -7
  130. package/dist/shared/{stack.BoA0xkJv.d.ts → stack.7n9Y_u7N.d.ts} +33 -7
  131. package/dist/shared/stack.BeSm90va.d.ts +289 -0
  132. package/dist/shared/{stack.DzH_wcvr.d.mts → stack.CIrIsc-A.d.cts} +2 -2
  133. package/dist/shared/{stack.DzH_wcvr.d.ts → stack.CIrIsc-A.d.mts} +2 -2
  134. package/dist/shared/{stack.DzH_wcvr.d.cts → stack.CIrIsc-A.d.ts} +2 -2
  135. package/dist/shared/stack.CMh_EdxW.d.cts +289 -0
  136. package/dist/shared/{stack.BsXokfNh.d.mts → stack.CXjzTMsb.d.cts} +1 -1
  137. package/dist/shared/{stack.BsXokfNh.d.ts → stack.CXjzTMsb.d.mts} +1 -1
  138. package/dist/shared/{stack.BsXokfNh.d.cts → stack.CXjzTMsb.d.ts} +1 -1
  139. package/dist/shared/stack.Dg09R0oB.d.mts +289 -0
  140. package/dist/shared/{stack.DKDMI-QO.d.mts → stack.QD1y_7NY.d.cts} +7 -1
  141. package/dist/shared/{stack.DKDMI-QO.d.ts → stack.QD1y_7NY.d.mts} +7 -1
  142. package/dist/shared/{stack.DKDMI-QO.d.cts → stack.QD1y_7NY.d.ts} +7 -1
  143. package/package.json +1 -1
  144. package/src/__tests__/stack-api.test.ts +118 -0
  145. package/src/api/index.ts +15 -1
  146. package/src/plugins/ai-chat/__tests__/getters.test.ts +109 -0
  147. package/src/plugins/ai-chat/api/getters.ts +71 -0
  148. package/src/plugins/ai-chat/api/index.ts +1 -0
  149. package/src/plugins/ai-chat/api/plugin.ts +8 -0
  150. package/src/plugins/api/index.ts +3 -1
  151. package/src/plugins/blog/__tests__/getters.test.ts +540 -0
  152. package/src/plugins/blog/api/getters.ts +243 -0
  153. package/src/plugins/blog/api/index.ts +7 -0
  154. package/src/plugins/blog/api/plugin.ts +13 -141
  155. package/src/plugins/blog/client/plugin.tsx +2 -1
  156. package/src/plugins/blog/query-keys.ts +16 -13
  157. package/src/plugins/cms/__tests__/getters.test.ts +206 -0
  158. package/src/plugins/cms/api/getters.ts +244 -0
  159. package/src/plugins/cms/api/index.ts +5 -0
  160. package/src/plugins/cms/api/plugin.ts +50 -154
  161. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +1 -1
  162. package/src/plugins/cms/client/hooks/cms-hooks.tsx +3 -0
  163. package/src/plugins/cms/types.ts +1 -1
  164. package/src/plugins/form-builder/__tests__/getters.test.ts +159 -0
  165. package/src/plugins/form-builder/api/getters.ts +203 -0
  166. package/src/plugins/form-builder/api/index.ts +1 -0
  167. package/src/plugins/form-builder/api/plugin.ts +22 -115
  168. package/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +1 -1
  169. package/src/plugins/form-builder/types.ts +2 -2
  170. package/src/plugins/kanban/__tests__/getters.test.ts +172 -0
  171. package/src/plugins/kanban/api/getters.ts +149 -0
  172. package/src/plugins/kanban/api/index.ts +1 -0
  173. package/src/plugins/kanban/api/plugin.ts +16 -146
  174. package/src/plugins/kanban/client/plugin.tsx +2 -1
  175. package/src/plugins/kanban/query-keys.ts +8 -5
  176. package/src/types.ts +44 -5
  177. package/dist/shared/{stack.CbuN2zVV.d.cts → stack.BkYlUT_8.d.cts} +6 -6
  178. package/dist/shared/{stack.CbuN2zVV.d.mts → stack.BkYlUT_8.d.mts} +6 -6
  179. package/dist/shared/{stack.CbuN2zVV.d.ts → stack.BkYlUT_8.d.ts} +6 -6
@@ -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,97 +21,14 @@ import type {
23
21
  } from "../types";
24
22
  import { listContentQuerySchema } from "../schemas";
25
23
  import { slugify } from "../utils";
26
-
27
- /**
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
105
- */
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
- };
116
- }
24
+ import {
25
+ getAllContentTypes,
26
+ getAllContentItems,
27
+ getContentItemBySlug,
28
+ serializeContentType,
29
+ serializeContentItem,
30
+ serializeContentItemWithType,
31
+ } from "./getters";
117
32
 
118
33
  /**
119
34
  * Sync content types from config to database
@@ -511,34 +426,52 @@ async function populateRelations(
511
426
  *
512
427
  * @param config - Configuration with content types and optional hooks
513
428
  */
514
- export const cmsBackendPlugin = (config: CMSBackendConfig) =>
515
- defineBackendPlugin({
429
+ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
430
+ // Shared sync state — used by both the api factory and routes handlers so
431
+ // that calling a getter before any HTTP request has been made still
432
+ // triggers the one-time content-type sync.
433
+ let syncPromise: Promise<void> | null = null;
434
+
435
+ const ensureSynced = (adapter: Adapter) => {
436
+ if (!syncPromise) {
437
+ syncPromise = syncContentTypes(adapter, config).catch((err) => {
438
+ // Allow retry on next call if sync fails
439
+ syncPromise = null;
440
+ throw err;
441
+ });
442
+ }
443
+ return syncPromise;
444
+ };
445
+
446
+ return defineBackendPlugin({
516
447
  name: "cms",
517
448
 
518
449
  dbPlugin: dbSchema,
519
450
 
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
- };
451
+ api: (adapter) => ({
452
+ getAllContentTypes: async () => {
453
+ await ensureSynced(adapter);
454
+ return getAllContentTypes(adapter);
455
+ },
456
+ getAllContentItems: async (
457
+ contentTypeSlug: string,
458
+ params?: Parameters<typeof getAllContentItems>[2],
459
+ ) => {
460
+ await ensureSynced(adapter);
461
+ return getAllContentItems(adapter, contentTypeSlug, params);
462
+ },
463
+ getContentItemBySlug: async (contentTypeSlug: string, slug: string) => {
464
+ await ensureSynced(adapter);
465
+ return getContentItemBySlug(adapter, contentTypeSlug, slug);
466
+ },
467
+ }),
536
468
 
469
+ routes: (adapter: Adapter) => {
537
470
  // Helper to get content type by slug
538
471
  const getContentType = async (
539
472
  slug: string,
540
473
  ): Promise<ContentType | null> => {
541
- await ensureSynced();
474
+ await ensureSynced(adapter);
542
475
  return adapter.findOne<ContentType>({
543
476
  model: "contentType",
544
477
  where: [{ field: "slug", value: slug, operator: "eq" as const }],
@@ -560,7 +493,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
560
493
  "/content-types",
561
494
  { method: "GET" },
562
495
  async (ctx) => {
563
- await ensureSynced();
496
+ await ensureSynced(adapter);
564
497
 
565
498
  const contentTypes = await adapter.findMany<ContentType>({
566
499
  model: "contentType",
@@ -627,45 +560,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
627
560
  throw ctx.error(404, { message: "Content type not found" });
628
561
  }
629
562
 
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
- };
563
+ return getAllContentItems(adapter, typeSlug, { slug, limit, offset });
669
564
  },
670
565
  );
671
566
 
@@ -1139,7 +1034,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
1139
1034
  const { slug } = ctx.params;
1140
1035
  const { itemId } = ctx.query;
1141
1036
 
1142
- await ensureSynced();
1037
+ await ensureSynced(adapter);
1143
1038
 
1144
1039
  // Get the target content type
1145
1040
  const targetContentType = await getContentType(slug);
@@ -1239,7 +1134,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
1239
1134
  const { slug, sourceType } = ctx.params;
1240
1135
  const { itemId, fieldName, limit, offset } = ctx.query;
1241
1136
 
1242
- await ensureSynced();
1137
+ await ensureSynced(adapter);
1243
1138
 
1244
1139
  // Verify target content type exists
1245
1140
  const targetContentType = await getContentType(slug);
@@ -1317,6 +1212,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
1317
1212
  };
1318
1213
  },
1319
1214
  });
1215
+ };
1320
1216
 
1321
1217
  export type CMSApiRouter = ReturnType<
1322
1218
  ReturnType<typeof cmsBackendPlugin>["routes"]
@@ -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();
@@ -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
+ });
@@ -0,0 +1,203 @@
1
+ import type { Adapter } from "@btst/db";
2
+ import type {
3
+ Form,
4
+ FormSubmission,
5
+ FormSubmissionWithForm,
6
+ SerializedForm,
7
+ SerializedFormSubmission,
8
+ SerializedFormSubmissionWithData,
9
+ } from "../types";
10
+
11
+ /**
12
+ * Serialize a Form for SSR/SSG use (convert dates to strings).
13
+ */
14
+ export function serializeForm(form: Form): SerializedForm {
15
+ return {
16
+ id: form.id,
17
+ name: form.name,
18
+ slug: form.slug,
19
+ description: form.description,
20
+ schema: form.schema,
21
+ successMessage: form.successMessage,
22
+ redirectUrl: form.redirectUrl,
23
+ status: form.status,
24
+ createdBy: form.createdBy,
25
+ createdAt: form.createdAt.toISOString(),
26
+ updatedAt: form.updatedAt.toISOString(),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Serialize a FormSubmission for SSR/SSG use (convert dates to strings).
32
+ */
33
+ export function serializeFormSubmission(
34
+ submission: FormSubmission,
35
+ ): SerializedFormSubmission {
36
+ return {
37
+ ...submission,
38
+ submittedAt: submission.submittedAt.toISOString(),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Serialize a FormSubmission with parsed data and joined Form.
44
+ * If `submission.data` is corrupted JSON, `parsedData` is set to `null` rather
45
+ * than throwing, so one bad row cannot crash the entire list or SSG build.
46
+ */
47
+ export function serializeFormSubmissionWithData(
48
+ submission: FormSubmissionWithForm,
49
+ ): SerializedFormSubmissionWithData {
50
+ let parsedData: Record<string, unknown> | null = null;
51
+ try {
52
+ parsedData = JSON.parse(submission.data);
53
+ } catch {
54
+ // Corrupted JSON — leave parsedData as null so callers can handle it
55
+ }
56
+ return {
57
+ ...serializeFormSubmission(submission),
58
+ parsedData,
59
+ form: submission.form ? serializeForm(submission.form) : undefined,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Retrieve all forms with optional status filter and pagination.
65
+ * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use.
66
+ *
67
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeListForms`) are NOT
68
+ * called. The caller is responsible for any access-control checks before
69
+ * invoking this function.
70
+ *
71
+ * @param adapter - The database adapter
72
+ * @param params - Optional filter/pagination parameters
73
+ */
74
+ export async function getAllForms(
75
+ adapter: Adapter,
76
+ params?: { status?: string; limit?: number; offset?: number },
77
+ ): Promise<{
78
+ items: SerializedForm[];
79
+ total: number;
80
+ limit?: number;
81
+ offset?: number;
82
+ }> {
83
+ const whereConditions: Array<{
84
+ field: string;
85
+ value: string;
86
+ operator: "eq";
87
+ }> = [];
88
+
89
+ if (params?.status) {
90
+ whereConditions.push({
91
+ field: "status",
92
+ value: params.status,
93
+ operator: "eq" as const,
94
+ });
95
+ }
96
+
97
+ // TODO: remove cast once @btst/db types expose adapter.count()
98
+ const total: number = await adapter.count({
99
+ model: "form",
100
+ where: whereConditions.length > 0 ? whereConditions : undefined,
101
+ });
102
+
103
+ const forms = await adapter.findMany<Form>({
104
+ model: "form",
105
+ where: whereConditions.length > 0 ? whereConditions : undefined,
106
+ limit: params?.limit,
107
+ offset: params?.offset,
108
+ sortBy: { field: "createdAt", direction: "desc" },
109
+ });
110
+
111
+ return {
112
+ items: forms.map(serializeForm),
113
+ total,
114
+ limit: params?.limit,
115
+ offset: params?.offset,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Retrieve a single form by its slug.
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 slug - The form slug
129
+ */
130
+ export async function getFormBySlug(
131
+ adapter: Adapter,
132
+ slug: string,
133
+ ): Promise<SerializedForm | null> {
134
+ const form = await adapter.findOne<Form>({
135
+ model: "form",
136
+ where: [{ field: "slug", value: slug, operator: "eq" as const }],
137
+ });
138
+
139
+ if (!form) {
140
+ return null;
141
+ }
142
+
143
+ return serializeForm(form);
144
+ }
145
+
146
+ /**
147
+ * Retrieve submissions for a form by form ID, with optional pagination.
148
+ * Returns an empty result if the form does not exist.
149
+ * Pure DB function — no hooks, no HTTP context. Safe for server-side use.
150
+ *
151
+ * @remarks **Security:** Authorization hooks are NOT called. The caller is
152
+ * responsible for any access-control checks before invoking this function.
153
+ *
154
+ * @param adapter - The database adapter
155
+ * @param formId - The form ID
156
+ * @param params - Optional pagination parameters
157
+ */
158
+ export async function getFormSubmissions(
159
+ adapter: Adapter,
160
+ formId: string,
161
+ params?: { limit?: number; offset?: number },
162
+ ): Promise<{
163
+ items: SerializedFormSubmissionWithData[];
164
+ total: number;
165
+ limit?: number;
166
+ offset?: number;
167
+ }> {
168
+ const form = await adapter.findOne<Form>({
169
+ model: "form",
170
+ where: [{ field: "id", value: formId, operator: "eq" as const }],
171
+ });
172
+
173
+ if (!form) {
174
+ return {
175
+ items: [],
176
+ total: 0,
177
+ limit: params?.limit,
178
+ offset: params?.offset,
179
+ };
180
+ }
181
+
182
+ // TODO: remove cast once @btst/db types expose adapter.count()
183
+ const total: number = await adapter.count({
184
+ model: "formSubmission",
185
+ where: [{ field: "formId", value: formId, operator: "eq" as const }],
186
+ });
187
+
188
+ const submissions = await adapter.findMany<FormSubmissionWithForm>({
189
+ model: "formSubmission",
190
+ where: [{ field: "formId", value: formId, operator: "eq" as const }],
191
+ limit: params?.limit,
192
+ offset: params?.offset,
193
+ sortBy: { field: "submittedAt", direction: "desc" },
194
+ join: { form: true },
195
+ });
196
+
197
+ return {
198
+ items: submissions.map(serializeFormSubmissionWithData),
199
+ total,
200
+ limit: params?.limit,
201
+ offset: params?.offset,
202
+ };
203
+ }
@@ -1 +1,2 @@
1
1
  export { formBuilderBackendPlugin, type FormBuilderApiRouter } from "./plugin";
2
+ export { getAllForms, getFormBySlug, getFormSubmissions } from "./getters";