@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
@@ -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,9 @@
1
1
  export * from "./plugin";
2
+ export {
3
+ getAllPosts,
4
+ getPostBySlug,
5
+ getAllTags,
6
+ type PostListParams,
7
+ type PostListResult,
8
+ } from "./getters";
2
9
  export { createBlogQueryKeys } from "../query-keys";
@@ -6,6 +6,7 @@ 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";
9
10
 
10
11
  export const PostListQuerySchema = z.object({
11
12
  slug: z.string().optional(),
@@ -86,12 +87,12 @@ export interface BlogBackendHooks {
86
87
 
87
88
  /**
88
89
  * Called after posts are read successfully
89
- * @param posts - Array of posts that were read
90
+ * @param posts - The list of posts returned by the query
90
91
  * @param filter - Query parameters used for filtering
91
92
  * @param context - Request context
92
93
  */
93
94
  onPostsRead?: (
94
- posts: Post[],
95
+ posts: Array<Post & { tags: Tag[] }>,
95
96
  filter: z.infer<typeof PostListQuerySchema>,
96
97
  context: BlogApiContext,
97
98
  ) => Promise<void> | void;
@@ -168,6 +169,13 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
168
169
 
169
170
  dbPlugin: dbSchema,
170
171
 
172
+ api: (adapter) => ({
173
+ getAllPosts: (params?: Parameters<typeof getAllPosts>[1]) =>
174
+ getAllPosts(adapter, params),
175
+ getPostBySlug: (slug: string) => getPostBySlug(adapter, slug),
176
+ getAllTags: () => getAllTags(adapter),
177
+ }),
178
+
171
179
  routes: (adapter: Adapter) => {
172
180
  const findOrCreateTags = async (
173
181
  tagInputs: Array<
@@ -265,144 +273,10 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
265
273
  }
266
274
  }
267
275
 
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
- }
276
+ const result = await getAllPosts(adapter, query);
403
277
 
404
278
  if (hooks?.onPostsRead) {
405
- await hooks.onPostsRead(result, query, context);
279
+ await hooks.onPostsRead(result.items, query, context);
406
280
  }
407
281
 
408
282
  return result;
@@ -806,9 +680,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
806
680
  method: "GET",
807
681
  },
808
682
  async () => {
809
- return await adapter.findMany<Tag>({
810
- model: "tag",
811
- });
683
+ return await getAllTags(adapter);
812
684
  },
813
685
  );
814
686
 
@@ -750,7 +750,8 @@ export const blogClientPlugin = (config: BlogClientConfig) =>
750
750
  published: "true",
751
751
  },
752
752
  });
753
- const page = (res.data ?? []) as unknown as SerializedPost[];
753
+ // The /posts endpoint returns PostListResult { items, total, limit, offset }
754
+ const page = ((res.data as any)?.items ?? []) as SerializedPost[];
754
755
  posts.push(...page);
755
756
  if (page.length < limit) break;
756
757
  offset += limit;
@@ -99,13 +99,14 @@ function createPostsQueries(
99
99
  },
100
100
  headers,
101
101
  });
102
- // Check for errors (better-call returns Error$1<unknown> | Data<Post[]>)
102
+ // Check for errors (better-call returns Error$1<unknown> | Data<PostListResult>)
103
103
  if (isErrorResponse(response)) {
104
104
  const errorResponse = response as { error: unknown };
105
105
  throw toError(errorResponse.error);
106
106
  }
107
- // Type narrowed to Data<Post[]> after error check
108
- return ((response as { data?: unknown }).data ??
107
+ // Extract .items from the paginated response for infinite scroll compatibility
108
+ const dataResponse = response as { data?: { items?: unknown[] } };
109
+ return (dataResponse.data?.items ??
109
110
  []) as unknown as SerializedPost[];
110
111
  } catch (error) {
111
112
  // Re-throw errors so React Query can catch them
@@ -126,14 +127,14 @@ function createPostsQueries(
126
127
  query: { slug, limit: 1 },
127
128
  headers,
128
129
  });
129
- // Check for errors (better-call returns Error$1<unknown> | Data<Post[]>)
130
+ // Check for errors (better-call returns Error$1<unknown> | Data<PostListResult>)
130
131
  if (isErrorResponse(response)) {
131
132
  const errorResponse = response as { error: unknown };
132
133
  throw toError(errorResponse.error);
133
134
  }
134
- // Type narrowed to Data<Post[]> after error check
135
- const dataResponse = response as { data?: unknown[] };
136
- return (dataResponse.data?.[0] ??
135
+ // Type narrowed to Data<PostListResult> after error check — access .items[0]
136
+ const dataResponse = response as { data?: { items?: unknown[] } };
137
+ return (dataResponse.data?.items?.[0] ??
137
138
  null) as unknown as SerializedPost | null;
138
139
  } catch (error) {
139
140
  // Re-throw errors so React Query can catch them
@@ -181,13 +182,14 @@ function createPostsQueries(
181
182
  },
182
183
  headers,
183
184
  });
184
- // Check for errors (better-call returns Error$1<unknown> | Data<Post[]>)
185
+ // Check for errors (better-call returns Error$1<unknown> | Data<PostListResult>)
185
186
  if (isErrorResponse(response)) {
186
187
  const errorResponse = response as { error: unknown };
187
188
  throw toError(errorResponse.error);
188
189
  }
189
- // Type narrowed to Data<Post[]> after error check
190
- let posts = ((response as { data?: unknown }).data ??
190
+ // Extract .items from the paginated response
191
+ const recentResponse = response as { data?: { items?: unknown[] } };
192
+ let posts = (recentResponse.data?.items ??
191
193
  []) as unknown as SerializedPost[];
192
194
 
193
195
  // Exclude current post if specified
@@ -228,13 +230,14 @@ function createDraftsQueries(
228
230
  },
229
231
  headers,
230
232
  });
231
- // Check for errors (better-call returns Error$1<unknown> | Data<Post[]>)
233
+ // Check for errors (better-call returns Error$1<unknown> | Data<PostListResult>)
232
234
  if (isErrorResponse(response)) {
233
235
  const errorResponse = response as { error: unknown };
234
236
  throw toError(errorResponse.error);
235
237
  }
236
- // Type narrowed to Data<Post[]> after error check
237
- return ((response as { data?: unknown }).data ??
238
+ // Extract .items from the paginated response for infinite scroll compatibility
239
+ const draftsResponse = response as { data?: { items?: unknown[] } };
240
+ return (draftsResponse.data?.items ??
238
241
  []) as unknown as SerializedPost[];
239
242
  } catch (error) {
240
243
  // Re-throw errors so React Query can catch them