@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
@@ -0,0 +1,243 @@
1
+ import type { Adapter } from "@btst/db";
2
+ import type { Post, PostWithPostTag, Tag } from "../types";
3
+
4
+ /**
5
+ * Parameters for filtering/paginating posts.
6
+ * Mirrors the shape of the list API query schema.
7
+ */
8
+ export interface PostListParams {
9
+ slug?: string;
10
+ tagSlug?: string;
11
+ offset?: number;
12
+ limit?: number;
13
+ query?: string;
14
+ published?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Paginated result returned by {@link getAllPosts}.
19
+ */
20
+ export interface PostListResult {
21
+ items: Array<Post & { tags: Tag[] }>;
22
+ total: number;
23
+ limit?: number;
24
+ offset?: number;
25
+ }
26
+
27
+ /**
28
+ * Retrieve all posts matching optional filter criteria.
29
+ * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use.
30
+ *
31
+ * @remarks **Security:** Authorization hooks (e.g. `onBeforeListPosts`) are NOT
32
+ * called. The caller is responsible for any access-control checks before
33
+ * invoking this function.
34
+ *
35
+ * @param adapter - The database adapter
36
+ * @param params - Optional filter/pagination parameters (same shape as the list API query)
37
+ */
38
+ export async function getAllPosts(
39
+ adapter: Adapter,
40
+ params?: PostListParams,
41
+ ): Promise<PostListResult> {
42
+ const query = params ?? {};
43
+
44
+ const whereConditions: Array<{
45
+ field: string;
46
+ value: string | number | boolean | string[] | number[] | Date | null;
47
+ operator: "eq" | "in";
48
+ }> = [];
49
+
50
+ // Resolve tagSlug → post IDs up front, then push an `in` condition so the
51
+ // adapter can filter and paginate entirely at the DB level. The previous
52
+ // approach loaded every post into memory and filtered with a JS Set, which
53
+ // scans the whole table on every request.
54
+ if (query.tagSlug) {
55
+ const tag = await adapter.findOne<Tag>({
56
+ model: "tag",
57
+ where: [{ field: "slug", value: query.tagSlug, operator: "eq" as const }],
58
+ });
59
+
60
+ if (!tag) {
61
+ return { items: [], total: 0, limit: query.limit, offset: query.offset };
62
+ }
63
+
64
+ const postTags = await adapter.findMany<{ postId: string; tagId: string }>({
65
+ model: "postTag",
66
+ where: [{ field: "tagId", value: tag.id, operator: "eq" as const }],
67
+ });
68
+
69
+ const taggedPostIds = postTags.map((pt) => pt.postId);
70
+ if (taggedPostIds.length === 0) {
71
+ return { items: [], total: 0, limit: query.limit, offset: query.offset };
72
+ }
73
+
74
+ whereConditions.push({
75
+ field: "id",
76
+ value: taggedPostIds,
77
+ operator: "in" as const,
78
+ });
79
+ }
80
+
81
+ if (query.published !== undefined) {
82
+ whereConditions.push({
83
+ field: "published",
84
+ value: query.published,
85
+ operator: "eq" as const,
86
+ });
87
+ }
88
+
89
+ if (query.slug) {
90
+ whereConditions.push({
91
+ field: "slug",
92
+ value: query.slug,
93
+ operator: "eq" as const,
94
+ });
95
+ }
96
+
97
+ // Full-text search across title/content/excerpt must remain in-memory:
98
+ // the adapter's `contains` operator is case-sensitive and cannot be
99
+ // grouped with AND conditions using OR connectors in all adapter
100
+ // implementations. All other filters above are pushed to DB, so the
101
+ // in-memory pass only scans the already-narrowed result set.
102
+ const needsInMemoryFilter = !!query.query;
103
+
104
+ const dbWhere = whereConditions.length > 0 ? whereConditions : undefined;
105
+
106
+ const dbTotal: number | undefined = !needsInMemoryFilter
107
+ ? await adapter.count({ model: "post", where: dbWhere })
108
+ : undefined;
109
+
110
+ const posts = await adapter.findMany<PostWithPostTag>({
111
+ model: "post",
112
+ limit: !needsInMemoryFilter ? query.limit : undefined,
113
+ offset: !needsInMemoryFilter ? query.offset : undefined,
114
+ where: dbWhere,
115
+ sortBy: { field: "createdAt", direction: "desc" },
116
+ join: { postTag: true },
117
+ });
118
+
119
+ // Collect the unique tag IDs present in this page of posts, then fetch
120
+ // only those tags (not the entire tags table).
121
+ const tagIds = new Set<string>();
122
+ for (const post of posts) {
123
+ if (post.postTag) {
124
+ for (const pt of post.postTag) {
125
+ tagIds.add(pt.tagId);
126
+ }
127
+ }
128
+ }
129
+
130
+ const tags =
131
+ tagIds.size > 0
132
+ ? await adapter.findMany<Tag>({
133
+ model: "tag",
134
+ where: [
135
+ {
136
+ field: "id",
137
+ value: Array.from(tagIds),
138
+ operator: "in" as const,
139
+ },
140
+ ],
141
+ })
142
+ : [];
143
+ const tagMap = new Map<string, Tag>(tags.map((t) => [t.id, t]));
144
+
145
+ let result = posts.map((post) => {
146
+ const postTags = (post.postTag || [])
147
+ .map((pt) => {
148
+ const tag = tagMap.get(pt.tagId);
149
+ return tag ? { ...tag } : undefined;
150
+ })
151
+ .filter((tag): tag is Tag => tag !== undefined);
152
+ const { postTag: _, ...postWithoutJoin } = post;
153
+ return { ...postWithoutJoin, tags: postTags };
154
+ });
155
+
156
+ if (query.query) {
157
+ const searchLower = query.query.toLowerCase();
158
+ result = result.filter(
159
+ (post) =>
160
+ post.title?.toLowerCase().includes(searchLower) ||
161
+ post.content?.toLowerCase().includes(searchLower) ||
162
+ post.excerpt?.toLowerCase().includes(searchLower),
163
+ );
164
+ }
165
+
166
+ if (needsInMemoryFilter) {
167
+ const total = result.length;
168
+ const offset = query.offset ?? 0;
169
+ const limit = query.limit;
170
+ result = result.slice(
171
+ offset,
172
+ limit !== undefined ? offset + limit : undefined,
173
+ );
174
+ return { items: result, total, limit: query.limit, offset: query.offset };
175
+ }
176
+
177
+ return {
178
+ items: result,
179
+ total: dbTotal ?? result.length,
180
+ limit: query.limit,
181
+ offset: query.offset,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Retrieve a single post by its slug, including associated tags.
187
+ * Returns null if no post is found.
188
+ * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use.
189
+ *
190
+ * @remarks **Security:** Authorization hooks are NOT called. The caller is
191
+ * responsible for any access-control checks before invoking this function.
192
+ *
193
+ * @param adapter - The database adapter
194
+ * @param slug - The post slug
195
+ */
196
+ export async function getPostBySlug(
197
+ adapter: Adapter,
198
+ slug: string,
199
+ ): Promise<(Post & { tags: Tag[] }) | null> {
200
+ const posts = await adapter.findMany<PostWithPostTag>({
201
+ model: "post",
202
+ where: [{ field: "slug", value: slug, operator: "eq" as const }],
203
+ limit: 1,
204
+ join: { postTag: true },
205
+ });
206
+
207
+ if (posts.length === 0) return null;
208
+
209
+ const post = posts[0]!;
210
+ const tagIds = (post.postTag || []).map((pt) => pt.tagId);
211
+
212
+ const tags =
213
+ tagIds.length > 0
214
+ ? await adapter.findMany<Tag>({
215
+ model: "tag",
216
+ where: [{ field: "id", value: tagIds, operator: "in" as const }],
217
+ })
218
+ : [];
219
+
220
+ const tagMap = new Map<string, Tag>(tags.map((t) => [t.id, t]));
221
+ const resolvedTags = (post.postTag || [])
222
+ .map((pt) => tagMap.get(pt.tagId))
223
+ .filter((t): t is Tag => t !== undefined);
224
+
225
+ const { postTag: _, ...postWithoutJoin } = post;
226
+ return { ...postWithoutJoin, tags: resolvedTags };
227
+ }
228
+
229
+ /**
230
+ * Retrieve all tags, sorted alphabetically by name.
231
+ * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use.
232
+ *
233
+ * @remarks **Security:** Authorization hooks are NOT called. The caller is
234
+ * responsible for any access-control checks before invoking this function.
235
+ *
236
+ * @param adapter - The database adapter
237
+ */
238
+ export async function getAllTags(adapter: Adapter): Promise<Tag[]> {
239
+ return adapter.findMany<Tag>({
240
+ model: "tag",
241
+ sortBy: { field: "name", direction: "asc" },
242
+ });
243
+ }
@@ -1,2 +1,11 @@
1
1
  export * from "./plugin";
2
+ export {
3
+ getAllPosts,
4
+ getPostBySlug,
5
+ getAllTags,
6
+ type PostListParams,
7
+ type PostListResult,
8
+ } from "./getters";
9
+ export { serializePost, serializeTag } from "./serializers";
10
+ export { BLOG_QUERY_KEYS } from "./query-key-defs";
2
11
  export { createBlogQueryKeys } from "../query-keys";
@@ -6,6 +6,91 @@ import { blogSchema as dbSchema } from "../db";
6
6
  import type { Post, PostWithPostTag, Tag } from "../types";
7
7
  import { slugify } from "../utils";
8
8
  import { createPostSchema, updatePostSchema } from "../schemas";
9
+ import { getAllPosts, getPostBySlug, getAllTags } from "./getters";
10
+ import { BLOG_QUERY_KEYS } from "./query-key-defs";
11
+ import { serializePost, serializeTag } from "./serializers";
12
+ import type { QueryClient } from "@tanstack/react-query";
13
+
14
+ /**
15
+ * Route keys for the blog plugin — matches the keys returned by
16
+ * `stackClient.router.getRoute(path).routeKey`.
17
+ */
18
+ export type BlogRouteKey =
19
+ | "posts"
20
+ | "drafts"
21
+ | "post"
22
+ | "tag"
23
+ | "newPost"
24
+ | "editPost";
25
+
26
+ /**
27
+ * Overloaded signature for `prefetchForRoute`.
28
+ * TypeScript enforces the correct params for each routeKey at call sites.
29
+ */
30
+ interface BlogPrefetchForRoute {
31
+ (key: "posts" | "drafts" | "newPost", qc: QueryClient): Promise<void>;
32
+ (
33
+ key: "post" | "editPost",
34
+ qc: QueryClient,
35
+ params: { slug: string },
36
+ ): Promise<void>;
37
+ (key: "tag", qc: QueryClient, params: { tagSlug: string }): Promise<void>;
38
+ }
39
+
40
+ function createBlogPrefetchForRoute(adapter: Adapter): BlogPrefetchForRoute {
41
+ return async function prefetchForRoute(
42
+ key: BlogRouteKey,
43
+ qc: QueryClient,
44
+ params?: Record<string, string>,
45
+ ): Promise<void> {
46
+ switch (key) {
47
+ case "posts":
48
+ case "drafts": {
49
+ const published = key === "posts";
50
+ const [result, tags] = await Promise.all([
51
+ getAllPosts(adapter, { published, limit: 10 }),
52
+ getAllTags(adapter),
53
+ ]);
54
+ qc.setQueryData(BLOG_QUERY_KEYS.postsList({ published, limit: 10 }), {
55
+ pages: [result.items.map(serializePost)],
56
+ pageParams: [0],
57
+ });
58
+ qc.setQueryData(BLOG_QUERY_KEYS.tagsList(), tags.map(serializeTag));
59
+ break;
60
+ }
61
+ case "post":
62
+ case "editPost": {
63
+ const slug = params?.slug ?? "";
64
+ if (slug) {
65
+ const post = await getPostBySlug(adapter, slug);
66
+ qc.setQueryData(
67
+ BLOG_QUERY_KEYS.postDetail(slug),
68
+ post ? serializePost(post) : null,
69
+ );
70
+ }
71
+ break;
72
+ }
73
+ case "tag": {
74
+ const tagSlug = params?.tagSlug ?? "";
75
+ const [result, tags] = await Promise.all([
76
+ getAllPosts(adapter, { published: true, limit: 10, tagSlug }),
77
+ getAllTags(adapter),
78
+ ]);
79
+ qc.setQueryData(
80
+ BLOG_QUERY_KEYS.postsList({ published: true, limit: 10, tagSlug }),
81
+ {
82
+ pages: [result.items.map(serializePost)],
83
+ pageParams: [0],
84
+ },
85
+ );
86
+ qc.setQueryData(BLOG_QUERY_KEYS.tagsList(), tags.map(serializeTag));
87
+ break;
88
+ }
89
+ default:
90
+ break;
91
+ }
92
+ } as BlogPrefetchForRoute;
93
+ }
9
94
 
10
95
  export const PostListQuerySchema = z.object({
11
96
  slug: z.string().optional(),
@@ -86,12 +171,12 @@ export interface BlogBackendHooks {
86
171
 
87
172
  /**
88
173
  * Called after posts are read successfully
89
- * @param posts - Array of posts that were read
174
+ * @param posts - The list of posts returned by the query
90
175
  * @param filter - Query parameters used for filtering
91
176
  * @param context - Request context
92
177
  */
93
178
  onPostsRead?: (
94
- posts: Post[],
179
+ posts: Array<Post & { tags: Tag[] }>,
95
180
  filter: z.infer<typeof PostListQuerySchema>,
96
181
  context: BlogApiContext,
97
182
  ) => Promise<void> | void;
@@ -168,6 +253,14 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
168
253
 
169
254
  dbPlugin: dbSchema,
170
255
 
256
+ api: (adapter) => ({
257
+ getAllPosts: (params?: Parameters<typeof getAllPosts>[1]) =>
258
+ getAllPosts(adapter, params),
259
+ getPostBySlug: (slug: string) => getPostBySlug(adapter, slug),
260
+ getAllTags: () => getAllTags(adapter),
261
+ prefetchForRoute: createBlogPrefetchForRoute(adapter),
262
+ }),
263
+
171
264
  routes: (adapter: Adapter) => {
172
265
  const findOrCreateTags = async (
173
266
  tagInputs: Array<
@@ -265,144 +358,10 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
265
358
  }
266
359
  }
267
360
 
268
- let tagFilterPostIds: Set<string> | null = null;
269
-
270
- if (query.tagSlug) {
271
- const tag = await adapter.findOne<Tag>({
272
- model: "tag",
273
- where: [
274
- {
275
- field: "slug",
276
- value: query.tagSlug,
277
- operator: "eq" as const,
278
- },
279
- ],
280
- });
281
-
282
- if (!tag) {
283
- return [];
284
- }
285
-
286
- const postTags = await adapter.findMany<{
287
- postId: string;
288
- tagId: string;
289
- }>({
290
- model: "postTag",
291
- where: [
292
- {
293
- field: "tagId",
294
- value: tag.id,
295
- operator: "eq" as const,
296
- },
297
- ],
298
- });
299
- tagFilterPostIds = new Set(postTags.map((pt) => pt.postId));
300
- if (tagFilterPostIds.size === 0) {
301
- return [];
302
- }
303
- }
304
-
305
- const whereConditions = [];
306
-
307
- if (query.published !== undefined) {
308
- whereConditions.push({
309
- field: "published",
310
- value: query.published,
311
- operator: "eq" as const,
312
- });
313
- }
314
-
315
- if (query.slug) {
316
- whereConditions.push({
317
- field: "slug",
318
- value: query.slug,
319
- operator: "eq" as const,
320
- });
321
- }
322
-
323
- const posts = await adapter.findMany<PostWithPostTag>({
324
- model: "post",
325
- limit:
326
- query.query || query.tagSlug ? undefined : (query.limit ?? 10),
327
- offset:
328
- query.query || query.tagSlug ? undefined : (query.offset ?? 0),
329
- where: whereConditions,
330
- sortBy: {
331
- field: "createdAt",
332
- direction: "desc",
333
- },
334
- join: {
335
- postTag: true,
336
- },
337
- });
338
-
339
- // Collect unique tag IDs from joined postTag data
340
- const tagIds = new Set<string>();
341
- for (const post of posts) {
342
- if (post.postTag) {
343
- for (const pt of post.postTag) {
344
- tagIds.add(pt.tagId);
345
- }
346
- }
347
- }
348
-
349
- // Fetch all tags at once
350
- const tags =
351
- tagIds.size > 0
352
- ? await adapter.findMany<Tag>({
353
- model: "tag",
354
- })
355
- : [];
356
- const tagMap = new Map<string, Tag>();
357
- for (const tag of tags) {
358
- if (tagIds.has(tag.id)) {
359
- tagMap.set(tag.id, tag);
360
- }
361
- }
362
-
363
- // Map tags to posts (spread to avoid circular references)
364
- let result = posts.map((post) => {
365
- const postTags = (post.postTag || [])
366
- .map((pt) => {
367
- const tag = tagMap.get(pt.tagId);
368
- return tag ? { ...tag } : undefined;
369
- })
370
- .filter((tag): tag is Tag => tag !== undefined);
371
- const { postTag: _, ...postWithoutJoin } = post;
372
- return {
373
- ...postWithoutJoin,
374
- tags: postTags,
375
- };
376
- });
377
-
378
- if (tagFilterPostIds) {
379
- result = result.filter((post) => tagFilterPostIds!.has(post.id));
380
- }
381
-
382
- if (query.query) {
383
- const searchLower = query.query.toLowerCase();
384
- result = result.filter((post) => {
385
- const titleMatch = post.title
386
- ?.toLowerCase()
387
- .includes(searchLower);
388
- const contentMatch = post.content
389
- ?.toLowerCase()
390
- .includes(searchLower);
391
- const excerptMatch = post.excerpt
392
- ?.toLowerCase()
393
- .includes(searchLower);
394
- return titleMatch || contentMatch || excerptMatch;
395
- });
396
- }
397
-
398
- if (query.tagSlug || query.query) {
399
- const offset = query.offset ?? 0;
400
- const limit = query.limit ?? 10;
401
- result = result.slice(offset, offset + limit);
402
- }
361
+ const result = await getAllPosts(adapter, query);
403
362
 
404
363
  if (hooks?.onPostsRead) {
405
- await hooks.onPostsRead(result, query, context);
364
+ await hooks.onPostsRead(result.items, query, context);
406
365
  }
407
366
 
408
367
  return result;
@@ -806,9 +765,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
806
765
  method: "GET",
807
766
  },
808
767
  async () => {
809
- return await adapter.findMany<Tag>({
810
- model: "tag",
811
- });
768
+ return await getAllTags(adapter);
812
769
  },
813
770
  );
814
771
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Internal query key constants for the blog 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 PostsListDiscriminator {
8
+ query: string | undefined;
9
+ limit: number;
10
+ published: boolean;
11
+ tagSlug: string | undefined;
12
+ }
13
+
14
+ /**
15
+ * Builds the discriminator object used as the cache key for the posts list.
16
+ * Mirrors the inline object in createPostsQueries so both paths stay in sync.
17
+ */
18
+ export function postsListDiscriminator(params: {
19
+ published: boolean;
20
+ limit?: number;
21
+ tagSlug?: string;
22
+ query?: string;
23
+ }): PostsListDiscriminator {
24
+ return {
25
+ query:
26
+ params.query !== undefined && params.query.trim() === ""
27
+ ? undefined
28
+ : params.query,
29
+ limit: params.limit ?? 10,
30
+ published: params.published,
31
+ tagSlug: params.tagSlug,
32
+ };
33
+ }
34
+
35
+ /** Full query key builders — use these with queryClient.setQueryData() */
36
+ export const BLOG_QUERY_KEYS = {
37
+ postsList: (params: {
38
+ published: boolean;
39
+ limit?: number;
40
+ tagSlug?: string;
41
+ }) => ["posts", "list", postsListDiscriminator(params)] as const,
42
+
43
+ postDetail: (slug: string) => ["posts", "detail", slug] as const,
44
+
45
+ tagsList: () => ["tags", "list", "tags"] as const,
46
+ };
@@ -0,0 +1,27 @@
1
+ import type { Post, Tag, SerializedPost, SerializedTag } from "../types";
2
+
3
+ /**
4
+ * Serialize a Tag for SSR/SSG use (convert dates to strings).
5
+ * Pure function — no DB access, no hooks.
6
+ */
7
+ export function serializeTag(tag: Tag): SerializedTag {
8
+ return {
9
+ ...tag,
10
+ createdAt: tag.createdAt.toISOString(),
11
+ updatedAt: tag.updatedAt.toISOString(),
12
+ };
13
+ }
14
+
15
+ /**
16
+ * Serialize a Post (with tags) for SSR/SSG use (convert dates to strings).
17
+ * Pure function — no DB access, no hooks.
18
+ */
19
+ export function serializePost(post: Post & { tags: Tag[] }): SerializedPost {
20
+ return {
21
+ ...post,
22
+ createdAt: post.createdAt.toISOString(),
23
+ updatedAt: post.updatedAt.toISOString(),
24
+ publishedAt: post.publishedAt?.toISOString(),
25
+ tags: post.tags.map(serializeTag),
26
+ };
27
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  defineClientPlugin,
3
3
  createApiClient,
4
+ isConnectionError,
4
5
  } from "@btst/stack/plugins/client";
5
6
  import { createRoute } from "@btst/yar";
6
7
  import type { QueryClient } from "@tanstack/react-query";
@@ -226,6 +227,12 @@ function createPostsLoader(published: boolean, config: BlogClientConfig) {
226
227
  } catch (error) {
227
228
  // Error hook - log the error but don't throw during SSR
228
229
  // Let Error Boundaries handle errors when components render
230
+ if (isConnectionError(error)) {
231
+ console.warn(
232
+ "[btst/blog] route.loader() failed — no server running at build time. " +
233
+ "Use myStack.api.blog.prefetchForRoute() for SSG data prefetching.",
234
+ );
235
+ }
229
236
  if (hooks?.onLoadError) {
230
237
  await hooks.onLoadError(error as Error, context);
231
238
  }
@@ -299,6 +306,12 @@ function createPostLoader(
299
306
  } catch (error) {
300
307
  // Error hook - log the error but don't throw during SSR
301
308
  // Let Error Boundaries handle errors when components render
309
+ if (isConnectionError(error)) {
310
+ console.warn(
311
+ "[btst/blog] route.loader() failed — no server running at build time. " +
312
+ "Use myStack.api.blog.prefetchForRoute() for SSG data prefetching.",
313
+ );
314
+ }
302
315
  if (hooks?.onLoadError) {
303
316
  await hooks.onLoadError(error as Error, context);
304
317
  }
@@ -398,6 +411,12 @@ function createTagLoader(tagSlug: string, config: BlogClientConfig) {
398
411
  await hooks.onLoadError(error, context);
399
412
  }
400
413
  } catch (error) {
414
+ if (isConnectionError(error)) {
415
+ console.warn(
416
+ "[btst/blog] route.loader() failed — no server running at build time. " +
417
+ "Use myStack.api.blog.prefetchForRoute() for SSG data prefetching.",
418
+ );
419
+ }
401
420
  if (hooks?.onLoadError) {
402
421
  await hooks.onLoadError(error as Error, context);
403
422
  }
@@ -750,7 +769,8 @@ export const blogClientPlugin = (config: BlogClientConfig) =>
750
769
  published: "true",
751
770
  },
752
771
  });
753
- const page = (res.data ?? []) as unknown as SerializedPost[];
772
+ // The /posts endpoint returns PostListResult { items, total, limit, offset }
773
+ const page = ((res.data as any)?.items ?? []) as SerializedPost[];
754
774
  posts.push(...page);
755
775
  if (page.length < limit) break;
756
776
  offset += limit;