@btst/stack 1.2.2 → 1.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.
@@ -3,7 +3,7 @@ import { defineBackendPlugin } from "@btst/stack/plugins/api";
3
3
  import { createEndpoint } from "@btst/stack/plugins/api";
4
4
  import { z } from "zod";
5
5
  import { blogSchema as dbSchema } from "../db";
6
- import type { Post, Tag } from "../types";
6
+ import type { Post, PostWithPostTag, Tag } from "../types";
7
7
  import { slugify } from "../utils";
8
8
  import { createPostSchema, updatePostSchema } from "../schemas";
9
9
 
@@ -169,68 +169,10 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
169
169
  dbPlugin: dbSchema,
170
170
 
171
171
  routes: (adapter: Adapter) => {
172
- const createTagCache = () => {
173
- let cache: Tag[] | null = null;
174
- return {
175
- getAllTags: async (): Promise<Tag[]> => {
176
- if (!cache) {
177
- cache = await adapter.findMany<Tag>({
178
- model: "tag",
179
- });
180
- }
181
- return cache;
182
- },
183
- invalidate: () => {
184
- cache = null;
185
- },
186
- addTag: (tag: Tag) => {
187
- if (cache) {
188
- cache.push(tag);
189
- }
190
- },
191
- };
192
- };
193
-
194
- const createPostTagCache = () => {
195
- let cache: Array<{ postId: string; tagId: string }> | null = null;
196
- const getAllPostTags = async (): Promise<
197
- Array<{ postId: string; tagId: string }>
198
- > => {
199
- if (!cache) {
200
- cache = await adapter.findMany<{
201
- postId: string;
202
- tagId: string;
203
- }>({
204
- model: "postTag",
205
- });
206
- }
207
- return cache;
208
- };
209
- return {
210
- getAllPostTags,
211
- invalidate: () => {
212
- cache = null;
213
- },
214
- getByTagId: async (
215
- tagId: string,
216
- ): Promise<Array<{ postId: string; tagId: string }>> => {
217
- const allPostTags = await getAllPostTags();
218
- return allPostTags.filter((pt) => pt.tagId === tagId);
219
- },
220
- getByPostId: async (
221
- postId: string,
222
- ): Promise<Array<{ postId: string; tagId: string }>> => {
223
- const allPostTags = await getAllPostTags();
224
- return allPostTags.filter((pt) => pt.postId === postId);
225
- },
226
- };
227
- };
228
-
229
172
  const findOrCreateTags = async (
230
173
  tagInputs: Array<
231
174
  { name: string } | { id: string; name: string; slug: string }
232
175
  >,
233
- tagCache: ReturnType<typeof createTagCache>,
234
176
  ): Promise<Tag[]> => {
235
177
  if (tagInputs.length === 0) return [];
236
178
 
@@ -259,7 +201,9 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
259
201
  return tagsWithIds;
260
202
  }
261
203
 
262
- const allTags = await tagCache.getAllTags();
204
+ const allTags = await adapter.findMany<Tag>({
205
+ model: "tag",
206
+ });
263
207
  const tagMapBySlug = new Map<string, Tag>();
264
208
  for (const tag of allTags) {
265
209
  tagMapBySlug.set(tag.slug, tag);
@@ -296,45 +240,11 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
296
240
  },
297
241
  });
298
242
  createdTags.push(newTag);
299
- tagCache.addTag(newTag);
300
243
  }
301
244
 
302
245
  return [...tagsWithIds, ...foundTags, ...createdTags];
303
246
  };
304
247
 
305
- const loadTagsForPosts = async (
306
- postIds: string[],
307
- tagCache: ReturnType<typeof createTagCache>,
308
- postTagCache: ReturnType<typeof createPostTagCache>,
309
- ): Promise<Map<string, Tag[]>> => {
310
- if (postIds.length === 0) return new Map();
311
-
312
- const allPostTags = await postTagCache.getAllPostTags();
313
- const relevantPostTags = allPostTags.filter((pt) =>
314
- postIds.includes(pt.postId),
315
- );
316
-
317
- const tagIds = [...new Set(relevantPostTags.map((pt) => pt.tagId))];
318
- if (tagIds.length === 0) return new Map();
319
-
320
- const allTags = await tagCache.getAllTags();
321
- const tagMap = new Map<string, Tag>();
322
- for (const tag of allTags) {
323
- tagMap.set(tag.id, tag);
324
- }
325
-
326
- const postTagsMap = new Map<string, Tag[]>();
327
- for (const postTag of relevantPostTags) {
328
- const tag = tagMap.get(postTag.tagId);
329
- if (tag) {
330
- const existing = postTagsMap.get(postTag.postId) || [];
331
- postTagsMap.set(postTag.postId, [...existing, { ...tag }]);
332
- }
333
- }
334
-
335
- return postTagsMap;
336
- };
337
-
338
248
  const listPosts = createEndpoint(
339
249
  "/posts",
340
250
  {
@@ -344,8 +254,6 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
344
254
  async (ctx) => {
345
255
  const { query, headers } = ctx;
346
256
  const context: BlogApiContext = { query, headers };
347
- const tagCache = createTagCache();
348
- const postTagCache = createPostTagCache();
349
257
 
350
258
  try {
351
259
  if (hooks?.onBeforeListPosts) {
@@ -360,14 +268,34 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
360
268
  let tagFilterPostIds: Set<string> | null = null;
361
269
 
362
270
  if (query.tagSlug) {
363
- const allTags = await tagCache.getAllTags();
364
- const tag = allTags.find((t) => t.slug === 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
+ });
365
281
 
366
282
  if (!tag) {
367
283
  return [];
368
284
  }
369
285
 
370
- const postTags = await postTagCache.getByTagId(tag.id);
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
+ });
371
299
  tagFilterPostIds = new Set(postTags.map((pt) => pt.postId));
372
300
  if (tagFilterPostIds.size === 0) {
373
301
  return [];
@@ -392,7 +320,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
392
320
  });
393
321
  }
394
322
 
395
- const posts = await adapter.findMany<Post>({
323
+ const posts = await adapter.findMany<PostWithPostTag>({
396
324
  model: "post",
397
325
  limit:
398
326
  query.query || query.tagSlug ? undefined : (query.limit ?? 10),
@@ -403,18 +331,46 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
403
331
  field: "createdAt",
404
332
  direction: "desc",
405
333
  },
334
+ join: {
335
+ postTag: true,
336
+ },
406
337
  });
407
338
 
408
- const postTagsMap = await loadTagsForPosts(
409
- posts.map((post) => post.id),
410
- tagCache,
411
- postTagCache,
412
- );
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
+ }
413
362
 
414
- let result = posts.map((post) => ({
415
- ...post,
416
- tags: postTagsMap.get(post.id) || [],
417
- }));
363
+ // Map tags to posts
364
+ let result = posts.map((post) => {
365
+ const postTags = (post.postTag || [])
366
+ .map((pt) => tagMap.get(pt.tagId))
367
+ .filter((tag): tag is Tag => tag !== undefined);
368
+ const { postTag: _, ...postWithoutJoin } = post;
369
+ return {
370
+ ...postWithoutJoin,
371
+ tags: postTags,
372
+ };
373
+ });
418
374
 
419
375
  if (tagFilterPostIds) {
420
376
  result = result.filter((post) => tagFilterPostIds!.has(post.id));
@@ -466,7 +422,6 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
466
422
  body: ctx.body,
467
423
  headers: ctx.headers,
468
424
  };
469
- const tagCache = createTagCache();
470
425
 
471
426
  try {
472
427
  if (hooks?.onBeforeCreatePost) {
@@ -496,7 +451,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
496
451
  });
497
452
 
498
453
  if (tagNames.length > 0) {
499
- const createdTags = await findOrCreateTags(tagNames, tagCache);
454
+ const createdTags = await findOrCreateTags(tagNames);
500
455
 
501
456
  await adapter.transaction(async (tx) => {
502
457
  for (const tag of createdTags) {
@@ -540,7 +495,6 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
540
495
  params: ctx.params,
541
496
  headers: ctx.headers,
542
497
  };
543
- const tagCache = createTagCache();
544
498
 
545
499
  try {
546
500
  if (hooks?.onBeforeUpdatePost) {
@@ -608,7 +562,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
608
562
  }
609
563
 
610
564
  if (tagNames.length > 0) {
611
- const createdTags = await findOrCreateTags(tagNames, tagCache);
565
+ const createdTags = await findOrCreateTags(tagNames);
612
566
 
613
567
  for (const tag of createdTags) {
614
568
  await tx.create<{ postId: string; tagId: string }>({
@@ -666,16 +620,9 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
666
620
  }
667
621
  }
668
622
 
669
- await adapter.transaction(async (tx) => {
670
- await tx.delete({
671
- model: "postTag",
672
- where: [{ field: "postId", value: ctx.params.id }],
673
- });
674
-
675
- await tx.delete<Post>({
676
- model: "post",
677
- where: [{ field: "id", value: ctx.params.id }],
678
- });
623
+ await adapter.delete<Post>({
624
+ model: "post",
625
+ where: [{ field: "id", value: ctx.params.id }],
679
626
  });
680
627
 
681
628
  // Lifecycle hook
@@ -703,8 +650,6 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
703
650
  async (ctx) => {
704
651
  const { query, headers } = ctx;
705
652
  const context: BlogApiContext = { query, headers };
706
- const tagCache = createTagCache();
707
- const postTagCache = createPostTagCache();
708
653
 
709
654
  try {
710
655
  if (hooks?.onBeforeListPosts) {
@@ -722,7 +667,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
722
667
  const date = query.date;
723
668
 
724
669
  // Get previous post (createdAt < date, newest first)
725
- const previousPost = await adapter.findMany<Post>({
670
+ const previousPosts = await adapter.findMany<PostWithPostTag>({
726
671
  model: "post",
727
672
  limit: 1,
728
673
  where: [
@@ -741,9 +686,12 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
741
686
  field: "createdAt",
742
687
  direction: "desc",
743
688
  },
689
+ join: {
690
+ postTag: true,
691
+ },
744
692
  });
745
693
 
746
- const nextPost = await adapter.findMany<Post>({
694
+ const nextPosts = await adapter.findMany<PostWithPostTag>({
747
695
  model: "post",
748
696
  limit: 1,
749
697
  where: [
@@ -762,31 +710,52 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
762
710
  field: "createdAt",
763
711
  direction: "asc",
764
712
  },
713
+ join: {
714
+ postTag: true,
715
+ },
765
716
  });
766
717
 
767
- const postIds = [
768
- ...(previousPost?.[0] ? [previousPost[0].id] : []),
769
- ...(nextPost?.[0] ? [nextPost[0].id] : []),
770
- ];
771
- const postTagsMap = await loadTagsForPosts(
772
- postIds,
773
- tagCache,
774
- postTagCache,
775
- );
718
+ // Collect unique tag IDs from joined data
719
+ const tagIds = new Set<string>();
720
+ const allPosts = [...previousPosts, ...nextPosts];
721
+ for (const post of allPosts) {
722
+ if (post.postTag) {
723
+ for (const pt of post.postTag) {
724
+ tagIds.add(pt.tagId);
725
+ }
726
+ }
727
+ }
728
+
729
+ // Fetch tags if needed
730
+ const tagMap = new Map<string, Tag>();
731
+ if (tagIds.size > 0) {
732
+ const tags = await adapter.findMany<Tag>({
733
+ model: "tag",
734
+ });
735
+ for (const tag of tags) {
736
+ if (tagIds.has(tag.id)) {
737
+ tagMap.set(tag.id, tag);
738
+ }
739
+ }
740
+ }
741
+
742
+ // Helper to map post with tags
743
+ const mapPostWithTags = (post: PostWithPostTag) => {
744
+ const tags = (post.postTag || [])
745
+ .map((pt) => tagMap.get(pt.tagId))
746
+ .filter((tag): tag is Tag => tag !== undefined);
747
+ const { postTag: _, ...postWithoutJoin } = post;
748
+ return {
749
+ ...postWithoutJoin,
750
+ tags,
751
+ };
752
+ };
776
753
 
777
754
  return {
778
- previous: previousPost?.[0]
779
- ? {
780
- ...previousPost[0],
781
- tags: postTagsMap.get(previousPost[0].id) || [],
782
- }
783
- : null,
784
- next: nextPost?.[0]
785
- ? {
786
- ...nextPost[0],
787
- tags: postTagsMap.get(nextPost[0].id) || [],
788
- }
755
+ previous: previousPosts[0]
756
+ ? mapPostWithTags(previousPosts[0])
789
757
  : null,
758
+ next: nextPosts[0] ? mapPostWithTags(nextPosts[0]) : null,
790
759
  };
791
760
  } catch (error) {
792
761
  // Error hook
@@ -80,10 +80,20 @@ export const blogSchema = createDbPlugin("blog", {
80
80
  postId: {
81
81
  type: "string",
82
82
  required: true,
83
+ references: {
84
+ model: "post",
85
+ field: "id",
86
+ onDelete: "cascade",
87
+ },
83
88
  },
84
89
  tagId: {
85
90
  type: "string",
86
91
  required: true,
92
+ references: {
93
+ model: "tag",
94
+ field: "id",
95
+ onDelete: "cascade",
96
+ },
87
97
  },
88
98
  },
89
99
  },
@@ -23,6 +23,13 @@ export type Tag = {
23
23
  updatedAt: Date;
24
24
  };
25
25
 
26
+ /**
27
+ * Post with joined postTag relationships from the database
28
+ */
29
+ export type PostWithPostTag = Post & {
30
+ postTag?: Array<{ postId: string; tagId: string }>;
31
+ };
32
+
26
33
  export interface SerializedPost
27
34
  extends Omit<Post, "createdAt" | "updatedAt" | "publishedAt" | "tags"> {
28
35
  tags: SerializedTag[];
@@ -39,8 +39,6 @@ declare const createPostSchema: z.ZodObject<{
39
39
  slug: z.ZodOptional<z.ZodString>;
40
40
  published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
41
41
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
42
- publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
43
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
44
42
  content: z.ZodString;
45
43
  excerpt: z.ZodString;
46
44
  image: z.ZodOptional<z.ZodString>;
@@ -51,6 +49,8 @@ declare const createPostSchema: z.ZodObject<{
51
49
  name: z.ZodString;
52
50
  slug: z.ZodString;
53
51
  }, z.core.$strip>]>>>>;
52
+ publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
53
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
54
54
  }, z.core.$strip>;
55
55
  declare const updatePostSchema: z.ZodObject<{
56
56
  publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
@@ -39,8 +39,6 @@ declare const createPostSchema: z.ZodObject<{
39
39
  slug: z.ZodOptional<z.ZodString>;
40
40
  published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
41
41
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
42
- publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
43
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
44
42
  content: z.ZodString;
45
43
  excerpt: z.ZodString;
46
44
  image: z.ZodOptional<z.ZodString>;
@@ -51,6 +49,8 @@ declare const createPostSchema: z.ZodObject<{
51
49
  name: z.ZodString;
52
50
  slug: z.ZodString;
53
51
  }, z.core.$strip>]>>>>;
52
+ publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
53
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
54
54
  }, z.core.$strip>;
55
55
  declare const updatePostSchema: z.ZodObject<{
56
56
  publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
@@ -39,8 +39,6 @@ declare const createPostSchema: z.ZodObject<{
39
39
  slug: z.ZodOptional<z.ZodString>;
40
40
  published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
41
41
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
42
- publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
43
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
44
42
  content: z.ZodString;
45
43
  excerpt: z.ZodString;
46
44
  image: z.ZodOptional<z.ZodString>;
@@ -51,6 +49,8 @@ declare const createPostSchema: z.ZodObject<{
51
49
  name: z.ZodString;
52
50
  slug: z.ZodString;
53
51
  }, z.core.$strip>]>>>>;
52
+ publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
53
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
54
54
  }, z.core.$strip>;
55
55
  declare const updatePostSchema: z.ZodObject<{
56
56
  publishedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;