@btst/stack 2.3.0 → 2.5.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 (208) hide show
  1. package/dist/packages/stack/src/client/components/compose.cjs +1 -2
  2. package/dist/packages/stack/src/client/components/compose.mjs +1 -2
  3. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +71 -0
  4. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +68 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +87 -54
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +87 -54
  7. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +2 -2
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +2 -2
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +89 -22
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +90 -23
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +110 -33
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +112 -35
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +1 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +1 -1
  15. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +14 -21
  16. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +15 -22
  17. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
  18. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
  19. package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +28 -45
  20. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +22 -39
  21. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
  22. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
  23. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
  24. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
  25. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
  26. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
  27. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
  28. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
  29. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
  30. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
  31. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +23 -27
  32. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +24 -28
  33. package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
  34. package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
  35. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +21 -18
  36. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +21 -18
  37. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +11 -15
  38. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +12 -16
  39. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +58 -62
  40. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +58 -62
  41. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +12 -12
  42. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +13 -13
  43. package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
  44. package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
  45. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +92 -118
  46. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +89 -115
  47. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
  48. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
  49. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +22 -29
  50. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +23 -30
  51. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
  52. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
  53. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.cjs +8 -8
  54. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.mjs +9 -9
  55. package/dist/packages/stack/src/plugins/utils.cjs +42 -0
  56. package/dist/packages/stack/src/plugins/utils.mjs +41 -1
  57. package/dist/plugins/ai-chat/api/index.d.cts +1 -1
  58. package/dist/plugins/ai-chat/api/index.d.mts +1 -1
  59. package/dist/plugins/ai-chat/api/index.d.ts +1 -1
  60. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  61. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  62. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  63. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
  64. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
  65. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
  66. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
  67. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
  68. package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
  69. package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
  70. package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
  71. package/dist/plugins/ai-chat/client/index.d.cts +10 -10
  72. package/dist/plugins/ai-chat/client/index.d.mts +10 -10
  73. package/dist/plugins/ai-chat/client/index.d.ts +10 -10
  74. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  75. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  76. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  77. package/dist/plugins/blog/api/index.d.cts +2 -2
  78. package/dist/plugins/blog/api/index.d.mts +2 -2
  79. package/dist/plugins/blog/api/index.d.ts +2 -2
  80. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  81. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  82. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  83. package/dist/plugins/blog/client/index.d.cts +13 -13
  84. package/dist/plugins/blog/client/index.d.mts +13 -13
  85. package/dist/plugins/blog/client/index.d.ts +13 -13
  86. package/dist/plugins/blog/query-keys.d.cts +2 -2
  87. package/dist/plugins/blog/query-keys.d.mts +2 -2
  88. package/dist/plugins/blog/query-keys.d.ts +2 -2
  89. package/dist/plugins/client/index.cjs +1 -0
  90. package/dist/plugins/client/index.d.cts +8 -1
  91. package/dist/plugins/client/index.d.mts +8 -1
  92. package/dist/plugins/client/index.d.ts +8 -1
  93. package/dist/plugins/client/index.mjs +1 -1
  94. package/dist/plugins/cms/api/index.cjs +2 -0
  95. package/dist/plugins/cms/api/index.d.cts +2 -2
  96. package/dist/plugins/cms/api/index.d.mts +2 -2
  97. package/dist/plugins/cms/api/index.d.ts +2 -2
  98. package/dist/plugins/cms/api/index.mjs +1 -0
  99. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  100. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  101. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  102. package/dist/plugins/cms/client/index.d.cts +6 -6
  103. package/dist/plugins/cms/client/index.d.mts +6 -6
  104. package/dist/plugins/cms/client/index.d.ts +6 -6
  105. package/dist/plugins/cms/query-keys.d.cts +2 -2
  106. package/dist/plugins/cms/query-keys.d.mts +2 -2
  107. package/dist/plugins/cms/query-keys.d.ts +2 -2
  108. package/dist/plugins/form-builder/api/index.d.cts +2 -2
  109. package/dist/plugins/form-builder/api/index.d.mts +2 -2
  110. package/dist/plugins/form-builder/api/index.d.ts +2 -2
  111. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  112. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  113. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  114. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  115. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  116. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  117. package/dist/plugins/form-builder/client/index.d.cts +6 -6
  118. package/dist/plugins/form-builder/client/index.d.mts +6 -6
  119. package/dist/plugins/form-builder/client/index.d.ts +6 -6
  120. package/dist/plugins/form-builder/query-keys.d.cts +2 -2
  121. package/dist/plugins/form-builder/query-keys.d.mts +2 -2
  122. package/dist/plugins/form-builder/query-keys.d.ts +2 -2
  123. package/dist/plugins/kanban/api/index.cjs +4 -0
  124. package/dist/plugins/kanban/api/index.d.cts +1 -1
  125. package/dist/plugins/kanban/api/index.d.mts +1 -1
  126. package/dist/plugins/kanban/api/index.d.ts +1 -1
  127. package/dist/plugins/kanban/api/index.mjs +1 -0
  128. package/dist/plugins/kanban/client/index.d.cts +12 -12
  129. package/dist/plugins/kanban/client/index.d.mts +12 -12
  130. package/dist/plugins/kanban/client/index.d.ts +12 -12
  131. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  132. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  133. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  134. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  135. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  136. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  137. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  138. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  139. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  140. package/dist/plugins/ui-builder/index.d.cts +2 -2
  141. package/dist/plugins/ui-builder/index.d.mts +2 -2
  142. package/dist/plugins/ui-builder/index.d.ts +2 -2
  143. package/dist/shared/{stack.C-WUPMT6.d.cts → stack.B2xZTSiO.d.cts} +4 -4
  144. package/dist/shared/{stack.B1EeBt1b.d.ts → stack.B58oHdqm.d.mts} +33 -3
  145. package/dist/shared/{stack.CVDTkMoO.d.mts → stack.B8QD11QU.d.cts} +7 -7
  146. package/dist/shared/{stack.CVDTkMoO.d.cts → stack.B8QD11QU.d.mts} +7 -7
  147. package/dist/shared/{stack.CVDTkMoO.d.ts → stack.B8QD11QU.d.ts} +7 -7
  148. package/dist/shared/{stack.CIP6QS9l.d.ts → stack.BDVEpue1.d.ts} +1 -1
  149. package/dist/shared/{stack.C5dtIncc.d.mts → stack.BTvbxZvw.d.cts} +1 -1
  150. package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
  151. package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
  152. package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
  153. package/dist/shared/{stack.DdI5W6MB.d.mts → stack.BozPgbrZ.d.cts} +19 -19
  154. package/dist/shared/{stack.DdI5W6MB.d.ts → stack.BozPgbrZ.d.mts} +19 -19
  155. package/dist/shared/{stack.DdI5W6MB.d.cts → stack.BozPgbrZ.d.ts} +19 -19
  156. package/dist/shared/{stack.CP68pFEH.d.mts → stack.C9Mg2Q46.d.cts} +33 -3
  157. package/dist/shared/{stack.BeSm90va.d.ts → stack.CTDVxbrA.d.ts} +72 -14
  158. package/dist/shared/{stack.C-Ptrz8s.d.ts → stack.Cj_zKww4.d.ts} +4 -4
  159. package/dist/shared/{stack.TIBF2AOx.d.ts → stack.CxaFNQCV.d.mts} +89 -34
  160. package/dist/shared/{stack.CMh_EdxW.d.cts → stack.D-b5zbPm.d.cts} +72 -14
  161. package/dist/shared/{stack.Dw0Ly2TM.d.cts → stack.DTtmJPQO.d.mts} +1 -1
  162. package/dist/shared/{stack.BKfolAyK.d.ts → stack.DXnclTG7.d.ts} +11 -11
  163. package/dist/shared/{stack.snB1EDP7.d.cts → stack.DaZM10cp.d.cts} +11 -11
  164. package/dist/shared/{stack.Dg09R0oB.d.mts → stack.FVWf2JhZ.d.mts} +72 -14
  165. package/dist/shared/{stack.BIXEI6v_.d.mts → stack.cfCkioTe.d.mts} +11 -11
  166. package/dist/shared/{stack.6fUOjLs9.d.mts → stack.dH7u-TJH.d.mts} +4 -4
  167. package/dist/shared/{stack.BpolpQpf.d.cts → stack.j75TpKh2.d.ts} +89 -34
  168. package/dist/shared/{stack.rTy7-wQU.d.mts → stack.n1_i1p2B.d.cts} +89 -34
  169. package/dist/shared/{stack.IdtKDRka.d.cts → stack.sO33ZDhK.d.ts} +33 -3
  170. package/package.json +14 -1
  171. package/src/client/components/compose.tsx +7 -4
  172. package/src/plugins/ai-chat/api/page-tools.ts +111 -0
  173. package/src/plugins/ai-chat/api/plugin.ts +228 -72
  174. package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
  175. package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
  176. package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
  177. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
  178. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
  179. package/src/plugins/ai-chat/client/plugin.tsx +23 -31
  180. package/src/plugins/ai-chat/schemas.ts +16 -0
  181. package/src/plugins/blog/api/plugin.ts +31 -47
  182. package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
  183. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
  184. package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
  185. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
  186. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
  187. package/src/plugins/blog/client/plugin.tsx +36 -39
  188. package/src/plugins/client/index.ts +5 -1
  189. package/src/plugins/cms/api/index.ts +4 -0
  190. package/src/plugins/cms/api/mutations.ts +84 -0
  191. package/src/plugins/cms/api/plugin.ts +23 -17
  192. package/src/plugins/cms/client/plugin.tsx +18 -21
  193. package/src/plugins/cms/types.ts +7 -7
  194. package/src/plugins/form-builder/api/plugin.ts +64 -64
  195. package/src/plugins/form-builder/client/plugin.tsx +19 -18
  196. package/src/plugins/form-builder/types.ts +19 -24
  197. package/src/plugins/kanban/api/index.ts +6 -0
  198. package/src/plugins/kanban/api/mutations.ts +169 -0
  199. package/src/plugins/kanban/api/plugin.ts +123 -136
  200. package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
  201. package/src/plugins/kanban/client/plugin.tsx +35 -41
  202. package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
  203. package/src/plugins/ui-builder/client/plugin.tsx +11 -10
  204. package/src/plugins/ui-builder/types.ts +4 -4
  205. package/src/plugins/utils.ts +92 -1
  206. package/dist/shared/{stack.CBON0dWL.d.mts → stack.BQmuNl5p.d.cts} +2 -2
  207. package/dist/shared/{stack.CBON0dWL.d.ts → stack.BQmuNl5p.d.mts} +2 -2
  208. package/dist/shared/{stack.CBON0dWL.d.cts → stack.BQmuNl5p.d.ts} +2 -2
@@ -32,6 +32,7 @@ import {
32
32
  } from "./getters";
33
33
  import { FORM_QUERY_KEYS } from "./query-key-defs";
34
34
  import type { QueryClient } from "@tanstack/react-query";
35
+ import { runHookWithShim } from "../../utils";
35
36
 
36
37
  /**
37
38
  * Route keys for the Form Builder plugin — matches the keys returned by
@@ -177,10 +178,11 @@ export const formBuilderBackendPlugin = (
177
178
  const context = createContext(ctx.headers);
178
179
 
179
180
  if (config.hooks?.onBeforeListForms) {
180
- const canList = await config.hooks.onBeforeListForms(context);
181
- if (!canList) {
182
- throw ctx.error(403, { message: "Access denied" });
183
- }
181
+ await runHookWithShim(
182
+ () => config.hooks!.onBeforeListForms!(context),
183
+ ctx.error,
184
+ "Access denied",
185
+ );
184
186
  }
185
187
 
186
188
  return getAllForms(adapter, { status, limit, offset });
@@ -199,10 +201,11 @@ export const formBuilderBackendPlugin = (
199
201
 
200
202
  // Call before hook for access check
201
203
  if (config.hooks?.onBeforeGetForm) {
202
- const canGet = await config.hooks.onBeforeGetForm(slug, context);
203
- if (!canGet) {
204
- throw ctx.error(403, { message: "Access denied" });
205
- }
204
+ await runHookWithShim(
205
+ () => config.hooks!.onBeforeGetForm!(slug, context),
206
+ ctx.error,
207
+ "Access denied",
208
+ );
206
209
  }
207
210
 
208
211
  const form = await getFormBySlugFromDb(adapter, slug);
@@ -227,10 +230,11 @@ export const formBuilderBackendPlugin = (
227
230
 
228
231
  // Call before hook for access check
229
232
  if (config.hooks?.onBeforeGetForm) {
230
- const canGet = await config.hooks.onBeforeGetForm(id, context);
231
- if (!canGet) {
232
- throw ctx.error(403, { message: "Access denied" });
233
- }
233
+ await runHookWithShim(
234
+ () => config.hooks!.onBeforeGetForm!(id, context),
235
+ ctx.error,
236
+ "Access denied",
237
+ );
234
238
  }
235
239
 
236
240
  const form = await adapter.findOne<Form>({
@@ -297,15 +301,13 @@ export const formBuilderBackendPlugin = (
297
301
 
298
302
  // Call before hook - may modify data or deny operation
299
303
  if (config.hooks?.onBeforeFormCreated) {
300
- const result = await config.hooks.onBeforeFormCreated(
301
- formInput,
302
- context,
304
+ const hookResult = await runHookWithShim(
305
+ () => config.hooks!.onBeforeFormCreated!(formInput, context),
306
+ ctx.error,
307
+ "Create operation denied",
303
308
  );
304
- if (result === false) {
305
- throw ctx.error(403, { message: "Create operation denied" });
306
- }
307
- if (result && typeof result === "object") {
308
- formInput = result;
309
+ if (hookResult && typeof hookResult === "object") {
310
+ formInput = hookResult as typeof formInput;
309
311
  }
310
312
  }
311
313
 
@@ -410,16 +412,14 @@ export const formBuilderBackendPlugin = (
410
412
 
411
413
  // Call before hook - may modify data or deny operation
412
414
  if (config.hooks?.onBeforeFormUpdated) {
413
- const result = await config.hooks.onBeforeFormUpdated(
414
- id,
415
- updateInput,
416
- context,
415
+ const hookResult = await runHookWithShim(
416
+ () =>
417
+ config.hooks!.onBeforeFormUpdated!(id, updateInput, context),
418
+ ctx.error,
419
+ "Update operation denied",
417
420
  );
418
- if (result === false) {
419
- throw ctx.error(403, { message: "Update operation denied" });
420
- }
421
- if (result && typeof result === "object") {
422
- updateInput = result;
421
+ if (hookResult && typeof hookResult === "object") {
422
+ updateInput = hookResult as typeof updateInput;
423
423
  }
424
424
  }
425
425
 
@@ -485,13 +485,11 @@ export const formBuilderBackendPlugin = (
485
485
 
486
486
  // Call before hook
487
487
  if (config.hooks?.onBeforeFormDeleted) {
488
- const canDelete = await config.hooks.onBeforeFormDeleted(
489
- id,
490
- context,
488
+ await runHookWithShim(
489
+ () => config.hooks!.onBeforeFormDeleted!(id, context),
490
+ ctx.error,
491
+ "Delete operation denied",
491
492
  );
492
- if (!canDelete) {
493
- throw ctx.error(403, { message: "Delete operation denied" });
494
- }
495
493
  }
496
494
 
497
495
  // Delete associated submissions first (cascade)
@@ -574,32 +572,40 @@ export const formBuilderBackendPlugin = (
574
572
  throw ctx.error(400, { message: "Invalid form data" });
575
573
  }
576
574
 
577
- // Call before submission hook - may modify data or deny
575
+ // Call before submission hook - may modify data or deny.
576
+ // We call the hook directly (not via runHookWithShim) so that
577
+ // onSubmissionError receives the original Error, not a wrapped HTTP error.
578
578
  let finalData = data as Record<string, unknown>;
579
579
  if (config.hooks?.onBeforeSubmission) {
580
+ let hookResult: unknown;
581
+ let originalError: Error | undefined;
580
582
  try {
581
- const result = await config.hooks.onBeforeSubmission(
583
+ hookResult = await config.hooks.onBeforeSubmission(
582
584
  slug,
583
585
  data as Record<string, unknown>,
584
586
  submissionContext,
585
587
  );
586
- if (result === false) {
587
- throw ctx.error(400, { message: "Submission rejected" });
588
- }
589
- if (result && typeof result === "object") {
590
- finalData = result;
588
+ // Backward-compat: explicit false return → denial
589
+ if (hookResult === false) {
590
+ originalError = new Error("Submission rejected");
591
591
  }
592
- } catch (error) {
593
- // Call error hook if submission is rejected
592
+ } catch (e) {
593
+ originalError =
594
+ e instanceof Error ? e : new Error("Submission rejected");
595
+ }
596
+ if (originalError) {
594
597
  if (config.hooks?.onSubmissionError) {
595
598
  await config.hooks.onSubmissionError(
596
- error as Error,
599
+ originalError,
597
600
  slug,
598
601
  data as Record<string, unknown>,
599
602
  submissionContext,
600
603
  );
601
604
  }
602
- throw error;
605
+ throw ctx.error(400, { message: originalError.message });
606
+ }
607
+ if (hookResult && typeof hookResult === "object") {
608
+ finalData = hookResult as Record<string, unknown>;
603
609
  }
604
610
  }
605
611
 
@@ -662,13 +668,11 @@ export const formBuilderBackendPlugin = (
662
668
 
663
669
  // Call before hook for auth check
664
670
  if (config.hooks?.onBeforeListSubmissions) {
665
- const canList = await config.hooks.onBeforeListSubmissions(
666
- formId,
667
- context,
671
+ await runHookWithShim(
672
+ () => config.hooks!.onBeforeListSubmissions!(formId, context),
673
+ ctx.error,
674
+ "Access denied",
668
675
  );
669
- if (!canList) {
670
- throw ctx.error(403, { message: "Access denied" });
671
- }
672
676
  }
673
677
 
674
678
  return getFormSubmissions(adapter, formId, { limit, offset });
@@ -687,13 +691,11 @@ export const formBuilderBackendPlugin = (
687
691
 
688
692
  // Call before hook for access check
689
693
  if (config.hooks?.onBeforeGetSubmission) {
690
- const canGet = await config.hooks.onBeforeGetSubmission(
691
- subId,
692
- context,
694
+ await runHookWithShim(
695
+ () => config.hooks!.onBeforeGetSubmission!(subId, context),
696
+ ctx.error,
697
+ "Access denied",
693
698
  );
694
- if (!canGet) {
695
- throw ctx.error(403, { message: "Access denied" });
696
- }
697
699
  }
698
700
 
699
701
  const submission = await adapter.findOne<FormSubmissionWithForm>({
@@ -731,13 +733,11 @@ export const formBuilderBackendPlugin = (
731
733
 
732
734
  // Call before hook
733
735
  if (config.hooks?.onBeforeSubmissionDeleted) {
734
- const canDelete = await config.hooks.onBeforeSubmissionDeleted(
735
- subId,
736
- context,
736
+ await runHookWithShim(
737
+ () => config.hooks!.onBeforeSubmissionDeleted!(subId, context),
738
+ ctx.error,
739
+ "Delete operation denied",
737
740
  );
738
- if (!canDelete) {
739
- throw ctx.error(403, { message: "Delete operation denied" });
740
- }
741
741
  }
742
742
 
743
743
  await adapter.delete({
@@ -4,6 +4,7 @@ import {
4
4
  defineClientPlugin,
5
5
  createApiClient,
6
6
  isConnectionError,
7
+ runClientHookWithShim,
7
8
  } from "@btst/stack/plugins/client";
8
9
  import { createRoute } from "@btst/yar";
9
10
  import type { QueryClient } from "@tanstack/react-query";
@@ -53,24 +54,24 @@ export interface LoaderContext {
53
54
  */
54
55
  export interface FormBuilderClientHooks {
55
56
  /**
56
- * Called before loading the form list page. Return false to cancel loading.
57
+ * Called before loading the form list page. Throw an error to cancel loading.
57
58
  * @param context - Loader context with path, params, etc.
58
59
  */
59
- beforeLoadFormList?: (context: LoaderContext) => Promise<boolean> | boolean;
60
+ beforeLoadFormList?: (context: LoaderContext) => Promise<void> | void;
60
61
  /**
61
62
  * Called after the form list is loaded.
62
63
  * @param context - Loader context
63
64
  */
64
65
  afterLoadFormList?: (context: LoaderContext) => Promise<void> | void;
65
66
  /**
66
- * Called before loading the form builder page. Return false to cancel loading.
67
+ * Called before loading the form builder page. Throw an error to cancel loading.
67
68
  * @param id - The form ID (undefined for new forms)
68
69
  * @param context - Loader context
69
70
  */
70
71
  beforeLoadFormBuilder?: (
71
72
  id: string | undefined,
72
73
  context: LoaderContext,
73
- ) => Promise<boolean> | boolean;
74
+ ) => Promise<void> | void;
74
75
  /**
75
76
  * Called after the form builder is loaded.
76
77
  * @param id - The form ID (undefined for new forms)
@@ -81,14 +82,14 @@ export interface FormBuilderClientHooks {
81
82
  context: LoaderContext,
82
83
  ) => Promise<void> | void;
83
84
  /**
84
- * Called before loading the submissions page. Return false to cancel loading.
85
+ * Called before loading the submissions page. Throw an error to cancel loading.
85
86
  * @param formId - The form ID
86
87
  * @param context - Loader context
87
88
  */
88
89
  beforeLoadSubmissions?: (
89
90
  formId: string,
90
91
  context: LoaderContext,
91
- ) => Promise<boolean> | boolean;
92
+ ) => Promise<void> | void;
92
93
  /**
93
94
  * Called after the submissions page is loaded.
94
95
  * @param formId - The form ID
@@ -146,10 +147,10 @@ function createFormListLoader(config: FormBuilderClientConfig) {
146
147
  try {
147
148
  // Before hook - authorization check
148
149
  if (hooks?.beforeLoadFormList) {
149
- const canLoad = await hooks.beforeLoadFormList(context);
150
- if (!canLoad) {
151
- throw new Error("Load prevented by beforeLoadFormList hook");
152
- }
150
+ await runClientHookWithShim(
151
+ () => hooks.beforeLoadFormList!(context),
152
+ "Load prevented by beforeLoadFormList hook",
153
+ );
153
154
  }
154
155
 
155
156
  const client = createApiClient<FormBuilderApiRouter>({
@@ -235,10 +236,10 @@ function createFormBuilderLoader(
235
236
  try {
236
237
  // Before hook - authorization check
237
238
  if (hooks?.beforeLoadFormBuilder) {
238
- const canLoad = await hooks.beforeLoadFormBuilder(id, context);
239
- if (!canLoad) {
240
- throw new Error("Load prevented by beforeLoadFormBuilder hook");
241
- }
239
+ await runClientHookWithShim(
240
+ () => hooks.beforeLoadFormBuilder!(id, context),
241
+ "Load prevented by beforeLoadFormBuilder hook",
242
+ );
242
243
  }
243
244
 
244
245
  const client = createApiClient<FormBuilderApiRouter>({
@@ -309,10 +310,10 @@ function createSubmissionsLoader(
309
310
  try {
310
311
  // Before hook - authorization check
311
312
  if (hooks?.beforeLoadSubmissions) {
312
- const canLoad = await hooks.beforeLoadSubmissions(formId, context);
313
- if (!canLoad) {
314
- throw new Error("Load prevented by beforeLoadSubmissions hook");
315
- }
313
+ await runClientHookWithShim(
314
+ () => hooks.beforeLoadSubmissions!(formId, context),
315
+ "Load prevented by beforeLoadSubmissions hook",
316
+ );
316
317
  }
317
318
 
318
319
  const client = createApiClient<FormBuilderApiRouter>({
@@ -167,23 +167,21 @@ export interface FormUpdate {
167
167
  * Backend hooks for Form Builder plugin
168
168
  *
169
169
  * All CRUD hooks receive ipAddress and headers for auth/rate limiting.
170
- * Return false from onBefore* hooks to reject the operation (throws 403).
170
+ * Throw an error from onBefore* hooks to reject the operation (throws 403).
171
171
  */
172
172
  export interface FormBuilderBackendHooks {
173
173
  // ============================================================================
174
174
  // FORM CRUD HOOKS (Admin operations)
175
175
  // ============================================================================
176
176
 
177
- /** Called before listing forms. Return false to deny access (403). */
178
- onBeforeListForms?: (
179
- ctx: FormBuilderHookContext,
180
- ) => Promise<boolean> | boolean;
177
+ /** Called before listing forms. Throw an error to deny access (403). */
178
+ onBeforeListForms?: (ctx: FormBuilderHookContext) => Promise<void> | void;
181
179
 
182
- /** Called before creating a form. Return false to deny, or modified data. */
180
+ /** Called before creating a form. Throw an error to deny, or return modified data. */
183
181
  onBeforeFormCreated?: (
184
182
  data: FormInput,
185
183
  ctx: FormBuilderHookContext,
186
- ) => Promise<FormInput | false> | FormInput | false;
184
+ ) => Promise<FormInput | void> | FormInput | void;
187
185
 
188
186
  /** Called after a form is created */
189
187
  onAfterFormCreated?: (
@@ -191,18 +189,18 @@ export interface FormBuilderBackendHooks {
191
189
  ctx: FormBuilderHookContext,
192
190
  ) => Promise<void> | void;
193
191
 
194
- /** Called before getting a form by ID or slug. Return false to deny access. */
192
+ /** Called before getting a form by ID or slug. Throw an error to deny access. */
195
193
  onBeforeGetForm?: (
196
194
  idOrSlug: string,
197
195
  ctx: FormBuilderHookContext,
198
- ) => Promise<boolean> | boolean;
196
+ ) => Promise<void> | void;
199
197
 
200
- /** Called before updating a form. Return false to deny, or modified data. */
198
+ /** Called before updating a form. Throw an error to deny, or return modified data. */
201
199
  onBeforeFormUpdated?: (
202
200
  id: string,
203
201
  data: FormUpdate,
204
202
  ctx: FormBuilderHookContext,
205
- ) => Promise<FormUpdate | false> | FormUpdate | false;
203
+ ) => Promise<FormUpdate | void> | FormUpdate | void;
206
204
 
207
205
  /** Called after a form is updated */
208
206
  onAfterFormUpdated?: (
@@ -210,11 +208,11 @@ export interface FormBuilderBackendHooks {
210
208
  ctx: FormBuilderHookContext,
211
209
  ) => Promise<void> | void;
212
210
 
213
- /** Called before deleting a form. Return false to deny. */
211
+ /** Called before deleting a form. Throw an error to deny. */
214
212
  onBeforeFormDeleted?: (
215
213
  id: string,
216
214
  ctx: FormBuilderHookContext,
217
- ) => Promise<boolean> | boolean;
215
+ ) => Promise<void> | void;
218
216
 
219
217
  /** Called after a form is deleted */
220
218
  onAfterFormDeleted?: (
@@ -230,16 +228,13 @@ export interface FormBuilderBackendHooks {
230
228
  * Called before processing a form submission.
231
229
  * Use for: spam protection, rate limiting, data validation/enrichment.
232
230
  *
233
- * @returns false to reject submission (400), or modified data to continue
231
+ * Throw an error to reject submission (400), or return modified data to continue.
234
232
  */
235
233
  onBeforeSubmission?: (
236
234
  formSlug: string,
237
235
  data: Record<string, unknown>,
238
236
  ctx: SubmissionHookContext,
239
- ) =>
240
- | Promise<Record<string, unknown> | false>
241
- | Record<string, unknown>
242
- | false;
237
+ ) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
243
238
 
244
239
  /**
245
240
  * Called after a submission is saved.
@@ -263,23 +258,23 @@ export interface FormBuilderBackendHooks {
263
258
  // SUBMISSIONS MANAGEMENT HOOKS (Admin viewing submissions)
264
259
  // ============================================================================
265
260
 
266
- /** Called before listing submissions. Return false to deny access (403). */
261
+ /** Called before listing submissions. Throw an error to deny access (403). */
267
262
  onBeforeListSubmissions?: (
268
263
  formId: string,
269
264
  ctx: FormBuilderHookContext,
270
- ) => Promise<boolean> | boolean;
265
+ ) => Promise<void> | void;
271
266
 
272
- /** Called before getting a submission. Return false to deny access. */
267
+ /** Called before getting a submission. Throw an error to deny access. */
273
268
  onBeforeGetSubmission?: (
274
269
  submissionId: string,
275
270
  ctx: FormBuilderHookContext,
276
- ) => Promise<boolean> | boolean;
271
+ ) => Promise<void> | void;
277
272
 
278
- /** Called before deleting a submission. Return false to deny. */
273
+ /** Called before deleting a submission. Throw an error to deny. */
279
274
  onBeforeSubmissionDeleted?: (
280
275
  submissionId: string,
281
276
  ctx: FormBuilderHookContext,
282
- ) => Promise<boolean> | boolean;
277
+ ) => Promise<void> | void;
283
278
 
284
279
  /** Called after a submission is deleted */
285
280
  onAfterSubmissionDeleted?: (
@@ -6,5 +6,11 @@ export {
6
6
  type KanbanBackendHooks,
7
7
  } from "./plugin";
8
8
  export { getAllBoards, getBoardById, type BoardListResult } from "./getters";
9
+ export {
10
+ createKanbanTask,
11
+ findOrCreateKanbanBoard,
12
+ getKanbanColumnsByBoardId,
13
+ type CreateKanbanTaskInput,
14
+ } from "./mutations";
9
15
  export { serializeBoard, serializeColumn, serializeTask } from "./serializers";
10
16
  export { KANBAN_QUERY_KEYS } from "./query-key-defs";
@@ -0,0 +1,169 @@
1
+ import type { Adapter } from "@btst/db";
2
+ import type { Board, Column, Task, Priority } from "../types";
3
+
4
+ /**
5
+ * Input for creating a new Kanban task.
6
+ */
7
+ export interface CreateKanbanTaskInput {
8
+ title: string;
9
+ columnId: string;
10
+ description?: string;
11
+ priority?: Priority;
12
+ assigneeId?: string;
13
+ }
14
+
15
+ /**
16
+ * Create a new task in a Kanban column.
17
+ * Computes the next order value from existing tasks in the column.
18
+ *
19
+ * @remarks **Security:** No authorization hooks (onBeforeCreateTask) are called.
20
+ * The caller is responsible for any access-control checks before invoking this
21
+ * function.
22
+ *
23
+ * @param adapter - The database adapter
24
+ * @param input - Task creation input
25
+ */
26
+ export async function createKanbanTask(
27
+ adapter: Adapter,
28
+ input: CreateKanbanTaskInput,
29
+ ): Promise<Task> {
30
+ const existingTasks = await adapter.findMany<Task>({
31
+ model: "kanbanTask",
32
+ where: [
33
+ {
34
+ field: "columnId",
35
+ value: input.columnId,
36
+ operator: "eq" as const,
37
+ },
38
+ ],
39
+ });
40
+
41
+ const nextOrder =
42
+ existingTasks.length > 0
43
+ ? Math.max(...existingTasks.map((t) => t.order)) + 1
44
+ : 0;
45
+
46
+ return adapter.create<Task>({
47
+ model: "kanbanTask",
48
+ data: {
49
+ title: input.title,
50
+ columnId: input.columnId,
51
+ description: input.description,
52
+ priority: input.priority ?? "MEDIUM",
53
+ order: nextOrder,
54
+ assigneeId: input.assigneeId,
55
+ isArchived: false,
56
+ createdAt: new Date(),
57
+ updatedAt: new Date(),
58
+ },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Coalesces concurrent `findOrCreateKanbanBoard` calls within the same process.
64
+ * Keyed by slug; entries are removed once the creation promise settles.
65
+ */
66
+ const _pendingBoardCreations = new Map<string, Promise<Board>>();
67
+
68
+ /**
69
+ * Find a board by slug, or create it with the given name and custom column titles.
70
+ *
71
+ * Concurrency-safe at two levels:
72
+ * - **Same process**: concurrent calls with the same slug share a single in-flight
73
+ * Promise (via `_pendingBoardCreations`), so only one DB write is attempted.
74
+ * - **Cross-instance**: the DB `unique` constraint on `slug` causes the losing
75
+ * write to throw; the catch block re-fetches and returns the winner's board.
76
+ *
77
+ * @remarks **Security:** No authorization hooks are called. The caller is
78
+ * responsible for any access-control checks before invoking this function.
79
+ *
80
+ * @param adapter - The database adapter
81
+ * @param slug - Unique URL-safe slug for the board
82
+ * @param name - Display name for the board (used only on creation)
83
+ * @param columnTitles - Ordered list of column names to create (used only on creation)
84
+ */
85
+ export async function findOrCreateKanbanBoard(
86
+ adapter: Adapter,
87
+ slug: string,
88
+ name: string,
89
+ columnTitles: string[],
90
+ ): Promise<Board> {
91
+ const existing = await adapter.findOne<Board>({
92
+ model: "kanbanBoard",
93
+ where: [{ field: "slug", value: slug, operator: "eq" as const }],
94
+ });
95
+
96
+ if (existing) return existing;
97
+
98
+ // Coalesce same-process concurrent calls for this slug
99
+ const inflight = _pendingBoardCreations.get(slug);
100
+ if (inflight) return inflight;
101
+
102
+ const creation = (async () => {
103
+ try {
104
+ const board = await adapter.create<Board>({
105
+ model: "kanbanBoard",
106
+ data: {
107
+ name,
108
+ slug,
109
+ createdAt: new Date(),
110
+ updatedAt: new Date(),
111
+ },
112
+ });
113
+
114
+ await Promise.all(
115
+ columnTitles.map((title, index) =>
116
+ adapter.create<Column>({
117
+ model: "kanbanColumn",
118
+ data: {
119
+ title,
120
+ boardId: board.id,
121
+ order: index,
122
+ createdAt: new Date(),
123
+ updatedAt: new Date(),
124
+ },
125
+ }),
126
+ ),
127
+ );
128
+
129
+ return board;
130
+ } catch (err) {
131
+ // Cross-instance race: another process won the unique-constraint race.
132
+ // Re-fetch so all callers return the same board.
133
+ const winner = await adapter.findOne<Board>({
134
+ model: "kanbanBoard",
135
+ where: [{ field: "slug", value: slug, operator: "eq" as const }],
136
+ });
137
+ if (winner) return winner;
138
+ throw err;
139
+ }
140
+ })();
141
+
142
+ _pendingBoardCreations.set(slug, creation);
143
+ try {
144
+ return await creation;
145
+ } finally {
146
+ _pendingBoardCreations.delete(slug);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Retrieve all columns for a given board, sorted by order.
152
+ * Co-located with mutations because it is primarily used alongside
153
+ * {@link createKanbanTask} to resolve column IDs before task creation.
154
+ *
155
+ * @remarks **Security:** No authorization hooks are called.
156
+ *
157
+ * @param adapter - The database adapter
158
+ * @param boardId - The board ID
159
+ */
160
+ export async function getKanbanColumnsByBoardId(
161
+ adapter: Adapter,
162
+ boardId: string,
163
+ ): Promise<Column[]> {
164
+ return adapter.findMany<Column>({
165
+ model: "kanbanColumn",
166
+ where: [{ field: "boardId", value: boardId, operator: "eq" as const }],
167
+ sortBy: { field: "order", direction: "asc" },
168
+ });
169
+ }