From 1ad6001ff4d9e05086d591ee2af39397a1af51fc Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 9 Apr 2026 16:54:17 -0400 Subject: [PATCH 1/6] feat: add CRUD operations for blog posts with tag management --- packages/stack/package.json | 2 +- packages/stack/src/plugins/blog/api/index.ts | 7 + .../stack/src/plugins/blog/api/mutations.ts | 266 ++++++++++++++++++ packages/stack/src/plugins/blog/api/plugin.ts | 209 ++------------ 4 files changed, 300 insertions(+), 184 deletions(-) create mode 100644 packages/stack/src/plugins/blog/api/mutations.ts diff --git a/packages/stack/package.json b/packages/stack/package.json index 05e69551..2311d16b 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.11.1", + "version": "2.11.2", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts index f4c96743..1416f7a0 100644 --- a/packages/stack/src/plugins/blog/api/index.ts +++ b/packages/stack/src/plugins/blog/api/index.ts @@ -6,6 +6,13 @@ export { type PostListParams, type PostListResult, } from "./getters"; +export { + createPost, + updatePost, + deletePost, + type CreatePostInput, + type UpdatePostInput, +} from "./mutations"; export { serializePost, serializeTag } from "./serializers"; export { BLOG_QUERY_KEYS } from "./query-key-defs"; export { createBlogQueryKeys } from "../query-keys"; diff --git a/packages/stack/src/plugins/blog/api/mutations.ts b/packages/stack/src/plugins/blog/api/mutations.ts new file mode 100644 index 00000000..b317dc66 --- /dev/null +++ b/packages/stack/src/plugins/blog/api/mutations.ts @@ -0,0 +1,266 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { Post, Tag } from "../types"; +import { slugify } from "../utils"; + +type TagInput = { name: string } | { id: string; name: string; slug: string }; + +/** + * Find existing tags by slug or create missing ones, then return the resolved Tag records. + * Tags that already carry an `id` are returned as-is (after name normalisation). + */ +async function findOrCreateTags( + adapter: Adapter, + tagInputs: TagInput[], +): Promise { + if (tagInputs.length === 0) return []; + + const normalizeTagName = (name: string): string => name.trim(); + + const tagsWithIds: Tag[] = []; + const tagsToFindOrCreate: Array<{ name: string }> = []; + + for (const tagInput of tagInputs) { + if ("id" in tagInput && tagInput.id) { + tagsWithIds.push({ + id: tagInput.id, + name: normalizeTagName(tagInput.name), + slug: tagInput.slug, + createdAt: new Date(), + updatedAt: new Date(), + } as Tag); + } else { + tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) }); + } + } + + if (tagsToFindOrCreate.length === 0) { + return tagsWithIds; + } + + const allTags = await adapter.findMany({ model: "tag" }); + const tagMapBySlug = new Map(); + for (const tag of allTags) { + tagMapBySlug.set(tag.slug, tag); + } + + const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name)); + const foundTags: Tag[] = []; + for (const slug of tagSlugs) { + const tag = tagMapBySlug.get(slug); + if (tag) { + foundTags.push(tag); + } + } + + const existingSlugs = new Set([ + ...tagsWithIds.map((tag) => tag.slug), + ...foundTags.map((tag) => tag.slug), + ]); + const tagsToCreate = tagsToFindOrCreate.filter( + (tag) => !existingSlugs.has(slugify(tag.name)), + ); + + const createdTags: Tag[] = []; + for (const tag of tagsToCreate) { + const normalizedName = normalizeTagName(tag.name); + const newTag = await adapter.create({ + model: "tag", + data: { + name: normalizedName, + slug: slugify(normalizedName), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + createdTags.push(newTag); + } + + return [...tagsWithIds, ...foundTags, ...createdTags]; +} + +/** + * Input for creating a new blog post. + * `slug` must already be slugified by the caller. + */ +export interface CreatePostInput { + title: string; + content: string; + excerpt: string; + /** Pre-slugified URL slug — use {@link slugify} before passing. */ + slug: string; + image?: string; + published?: boolean; + publishedAt?: Date; + createdAt?: Date; + updatedAt?: Date; + tags?: TagInput[]; +} + +/** + * Input for updating an existing blog post. + * If `slug` is provided it must already be slugified by the caller. + */ +export interface UpdatePostInput { + title?: string; + content?: string; + excerpt?: string; + /** Pre-slugified URL slug — use {@link slugify} before passing. */ + slug?: string; + image?: string; + published?: boolean; + publishedAt?: Date; + createdAt?: Date; + updatedAt?: Date; + tags?: TagInput[]; +} + +/** + * Create a new blog post with optional tag associations. + * Pure DB function — no hooks, no HTTP context. Safe for server-side and SSG use. + * + * @remarks **Security:** Authorization hooks (e.g. `onBeforeCreatePost`) are NOT + * called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param input - Post data; `slug` must be pre-slugified + */ +export async function createPost( + adapter: Adapter, + input: CreatePostInput, +): Promise { + const { tags: tagInputs, ...postData } = input; + const tagList = tagInputs ?? []; + + const newPost = await adapter.create({ + model: "post", + data: { + ...postData, + published: postData.published ?? false, + tags: [] as Tag[], + createdAt: postData.createdAt ?? new Date(), + updatedAt: postData.updatedAt ?? new Date(), + }, + }); + + if (tagList.length > 0) { + const resolvedTags = await findOrCreateTags(adapter, tagList); + + await adapter.transaction(async (tx) => { + for (const tag of resolvedTags) { + await tx.create<{ postId: string; tagId: string }>({ + model: "postTag", + data: { + postId: newPost.id, + tagId: tag.id, + }, + }); + } + }); + + newPost.tags = resolvedTags.map((tag) => ({ ...tag })); + } else { + newPost.tags = []; + } + + return newPost; +} + +/** + * Update an existing blog post and reconcile its tag associations. + * Returns `null` if no post with the given `id` exists. + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @remarks **Security:** Authorization hooks (e.g. `onBeforeUpdatePost`) are NOT + * called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param id - The post ID to update + * @param input - Partial post data to apply; `slug` must be pre-slugified if provided + */ +export async function updatePost( + adapter: Adapter, + id: string, + input: UpdatePostInput, +): Promise { + const { tags: tagInputs, ...postData } = input; + const tagList = tagInputs ?? []; + + return adapter.transaction(async (tx) => { + const existingPostTags = await tx.findMany<{ + postId: string; + tagId: string; + }>({ + model: "postTag", + where: [{ field: "postId", value: id, operator: "eq" as const }], + }); + + const updatedPost = await tx.update({ + model: "post", + where: [{ field: "id", value: id }], + update: { + ...postData, + updatedAt: new Date(), + }, + }); + + if (!updatedPost) return null; + + for (const postTag of existingPostTags) { + await tx.delete<{ postId: string; tagId: string }>({ + model: "postTag", + where: [ + { + field: "postId", + value: postTag.postId, + operator: "eq" as const, + }, + { + field: "tagId", + value: postTag.tagId, + operator: "eq" as const, + }, + ], + }); + } + + if (tagList.length > 0) { + const resolvedTags = await findOrCreateTags(adapter, tagList); + + for (const tag of resolvedTags) { + await tx.create<{ postId: string; tagId: string }>({ + model: "postTag", + data: { + postId: id, + tagId: tag.id, + }, + }); + } + + updatedPost.tags = resolvedTags.map((tag) => ({ ...tag })); + } else { + updatedPost.tags = []; + } + + return updatedPost; + }); +} + +/** + * Delete a blog post by ID. + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @remarks **Security:** Authorization hooks (e.g. `onBeforeDeletePost`) are NOT + * called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param id - The post ID to delete + */ +export async function deletePost(adapter: Adapter, id: string): Promise { + await adapter.delete({ + model: "post", + where: [{ field: "id", value: id }], + }); +} diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts index 31e08c5c..0e0edb4c 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -7,6 +7,13 @@ import type { Post, PostWithPostTag, Tag } from "../types"; import { slugify } from "../utils"; import { createPostSchema, updatePostSchema } from "../schemas"; import { getAllPosts, getPostBySlug, getAllTags } from "./getters"; +import { + createPost as createPostMutation, + updatePost as updatePostMutation, + deletePost as deletePostMutation, + type CreatePostInput, + type UpdatePostInput, +} from "./mutations"; import { BLOG_QUERY_KEYS } from "./query-key-defs"; import { serializePost, serializeTag } from "./serializers"; import type { QueryClient } from "@tanstack/react-query"; @@ -260,85 +267,15 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => getPostBySlug: (slug: string) => getPostBySlug(adapter, slug), getAllTags: () => getAllTags(adapter), prefetchForRoute: createBlogPrefetchForRoute(adapter), + // Mutations + createPost: (input: CreatePostInput) => + createPostMutation(adapter, input), + updatePost: (id: string, input: UpdatePostInput) => + updatePostMutation(adapter, id, input), + deletePost: (id: string) => deletePostMutation(adapter, id), }), routes: (adapter: Adapter) => { - const findOrCreateTags = async ( - tagInputs: Array< - { name: string } | { id: string; name: string; slug: string } - >, - ): Promise => { - if (tagInputs.length === 0) return []; - - const normalizeTagName = (name: string): string => { - return name.trim(); - }; - - const tagsWithIds: Tag[] = []; - const tagsToFindOrCreate: Array<{ name: string }> = []; - - for (const tagInput of tagInputs) { - if ("id" in tagInput && tagInput.id) { - tagsWithIds.push({ - id: tagInput.id, - name: normalizeTagName(tagInput.name), - slug: tagInput.slug, - createdAt: new Date(), - updatedAt: new Date(), - } as Tag); - } else { - tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) }); - } - } - - if (tagsToFindOrCreate.length === 0) { - return tagsWithIds; - } - - const allTags = await adapter.findMany({ - model: "tag", - }); - const tagMapBySlug = new Map(); - for (const tag of allTags) { - tagMapBySlug.set(tag.slug, tag); - } - - const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name)); - const foundTags: Tag[] = []; - - for (const slug of tagSlugs) { - const tag = tagMapBySlug.get(slug); - if (tag) { - foundTags.push(tag); - } - } - - const existingSlugs = new Set([ - ...tagsWithIds.map((tag) => tag.slug), - ...foundTags.map((tag) => tag.slug), - ]); - const tagsToCreate = tagsToFindOrCreate.filter( - (tag) => !existingSlugs.has(slugify(tag.name)), - ); - - const createdTags: Tag[] = []; - for (const tag of tagsToCreate) { - const normalizedName = normalizeTagName(tag.name); - const newTag = await adapter.create({ - model: "tag", - data: { - name: normalizedName, - slug: slugify(normalizedName), - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - createdTags.push(newTag); - } - - return [...tagsWithIds, ...foundTags, ...createdTags]; - }; - const listPosts = createEndpoint( "/posts", { @@ -394,11 +331,10 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ); } - const { tags, ...postData } = ctx.body; - const tagNames = tags || []; + const { tags, slug: rawSlug, ...postData } = ctx.body; // Always slugify to ensure URL-safe slug, whether provided or generated from title - const slug = slugify(postData.slug || postData.title); + const slug = slugify(rawSlug || postData.title); // Validate that slugification produced a non-empty result if (!slug) { @@ -408,37 +344,12 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => }); } - const newPost = await adapter.create({ - model: "post", - data: { - ...postData, - slug, - tags: [], - createdAt: new Date(), - updatedAt: new Date(), - }, + const newPost = await createPostMutation(adapter, { + ...postData, + slug, + tags: tags ?? [], }); - if (tagNames.length > 0) { - const createdTags = await findOrCreateTags(tagNames); - - await adapter.transaction(async (tx) => { - for (const tag of createdTags) { - await tx.create<{ postId: string; tagId: string }>({ - model: "postTag", - data: { - postId: newPost.id, - tagId: tag.id, - }, - }); - } - }); - - newPost.tags = createdTags.map((tag) => ({ ...tag })); - } else { - newPost.tags = []; - } - if (hooks?.onPostCreated) { await hooks.onPostCreated(newPost, context); } @@ -476,7 +387,6 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => } const { tags, slug: rawSlug, ...restPostData } = ctx.body; - const tagNames = tags || []; // Sanitize slug if provided to ensure it's URL-safe const slugified = rawSlug ? slugify(rawSlug) : undefined; @@ -489,80 +399,16 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => }); } - const postData = { + const updated = await updatePostMutation(adapter, ctx.params.id, { ...restPostData, ...(slugified ? { slug: slugified } : {}), - }; - - const updated = await adapter.transaction(async (tx) => { - const existingPostTags = await tx.findMany<{ - postId: string; - tagId: string; - }>({ - model: "postTag", - where: [ - { - field: "postId", - value: ctx.params.id, - operator: "eq" as const, - }, - ], - }); - - const updatedPost = await tx.update({ - model: "post", - where: [{ field: "id", value: ctx.params.id }], - update: { - ...postData, - updatedAt: new Date(), - }, - }); - - if (!updatedPost) { - throw ctx.error(404, { - message: "Post not found", - }); - } - - for (const postTag of existingPostTags) { - await tx.delete<{ postId: string; tagId: string }>({ - model: "postTag", - where: [ - { - field: "postId", - value: postTag.postId, - operator: "eq" as const, - }, - { - field: "tagId", - value: postTag.tagId, - operator: "eq" as const, - }, - ], - }); - } - - if (tagNames.length > 0) { - const createdTags = await findOrCreateTags(tagNames); - - for (const tag of createdTags) { - await tx.create<{ postId: string; tagId: string }>({ - model: "postTag", - data: { - postId: ctx.params.id, - tagId: tag.id, - }, - }); - } - - updatedPost.tags = createdTags.map((tag) => ({ ...tag })); - } else { - updatedPost.tags = []; - } - - return updatedPost; + tags: tags ?? [], }); + if (!updated) { + throw ctx.error(404, { message: "Post not found" }); + } + if (hooks?.onPostUpdated) { await hooks.onPostUpdated(updated, context); } @@ -597,10 +443,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ); } - await adapter.delete({ - model: "post", - where: [{ field: "id", value: ctx.params.id }], - }); + await deletePostMutation(adapter, ctx.params.id); // Lifecycle hook if (hooks?.onPostDeleted) { From 30b49326258313e908d8dd01eff591f38505984d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 21:00:56 +0000 Subject: [PATCH 2/6] fix: guard tag deletion in updatePost to prevent silent data loss on partial updates When tags field is omitted from UpdatePostInput, preserve existing tag associations instead of clearing them. Only delete and recreate tags when tags field is explicitly provided (including empty array). --- .../stack/src/plugins/blog/api/mutations.ts | 91 ++++++++++++------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/packages/stack/src/plugins/blog/api/mutations.ts b/packages/stack/src/plugins/blog/api/mutations.ts index b317dc66..78476ab5 100644 --- a/packages/stack/src/plugins/blog/api/mutations.ts +++ b/packages/stack/src/plugins/blog/api/mutations.ts @@ -185,17 +185,8 @@ export async function updatePost( input: UpdatePostInput, ): Promise { const { tags: tagInputs, ...postData } = input; - const tagList = tagInputs ?? []; return adapter.transaction(async (tx) => { - const existingPostTags = await tx.findMany<{ - postId: string; - tagId: string; - }>({ - model: "postTag", - where: [{ field: "postId", value: id, operator: "eq" as const }], - }); - const updatedPost = await tx.update({ model: "post", where: [{ field: "id", value: id }], @@ -207,40 +198,70 @@ export async function updatePost( if (!updatedPost) return null; - for (const postTag of existingPostTags) { - await tx.delete<{ postId: string; tagId: string }>({ + if (tagInputs !== undefined) { + const existingPostTags = await tx.findMany<{ + postId: string; + tagId: string; + }>({ model: "postTag", - where: [ - { - field: "postId", - value: postTag.postId, - operator: "eq" as const, - }, - { - field: "tagId", - value: postTag.tagId, - operator: "eq" as const, - }, - ], + where: [{ field: "postId", value: id, operator: "eq" as const }], }); - } - - if (tagList.length > 0) { - const resolvedTags = await findOrCreateTags(adapter, tagList); - for (const tag of resolvedTags) { - await tx.create<{ postId: string; tagId: string }>({ + for (const postTag of existingPostTags) { + await tx.delete<{ postId: string; tagId: string }>({ model: "postTag", - data: { - postId: id, - tagId: tag.id, - }, + where: [ + { + field: "postId", + value: postTag.postId, + operator: "eq" as const, + }, + { + field: "tagId", + value: postTag.tagId, + operator: "eq" as const, + }, + ], }); } - updatedPost.tags = resolvedTags.map((tag) => ({ ...tag })); + if (tagInputs.length > 0) { + const resolvedTags = await findOrCreateTags(adapter, tagInputs); + + for (const tag of resolvedTags) { + await tx.create<{ postId: string; tagId: string }>({ + model: "postTag", + data: { + postId: id, + tagId: tag.id, + }, + }); + } + + updatedPost.tags = resolvedTags.map((tag) => ({ ...tag })); + } else { + updatedPost.tags = []; + } } else { - updatedPost.tags = []; + const existingPostTags = await tx.findMany<{ + postId: string; + tagId: string; + }>({ + model: "postTag", + where: [{ field: "postId", value: id, operator: "eq" as const }], + }); + + if (existingPostTags.length > 0) { + const tagIds = existingPostTags.map((pt) => pt.tagId); + const tags = await tx.findMany({ + model: "tag", + }); + updatedPost.tags = tags + .filter((tag) => tagIds.includes(tag.id)) + .map((tag) => ({ ...tag })); + } else { + updatedPost.tags = []; + } } return updatedPost; From 4665fc0f1eb7a1ede3e11c3c226180314b25da7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 21:10:00 +0000 Subject: [PATCH 3/6] fix: pass transaction to findOrCreateTags in updatePost to prevent orphan tags --- packages/stack/src/plugins/blog/api/mutations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack/src/plugins/blog/api/mutations.ts b/packages/stack/src/plugins/blog/api/mutations.ts index 78476ab5..f5083837 100644 --- a/packages/stack/src/plugins/blog/api/mutations.ts +++ b/packages/stack/src/plugins/blog/api/mutations.ts @@ -226,7 +226,7 @@ export async function updatePost( } if (tagInputs.length > 0) { - const resolvedTags = await findOrCreateTags(adapter, tagInputs); + const resolvedTags = await findOrCreateTags(tx, tagInputs); for (const tag of resolvedTags) { await tx.create<{ postId: string; tagId: string }>({ From 03cea0aceb8a5bac80c3705b3fe92af73ac31023 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 9 Apr 2026 17:24:14 -0400 Subject: [PATCH 4/6] fix: update findOrCreateTags call to use adapter instead of transaction in updatePost --- packages/stack/src/plugins/blog/api/mutations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack/src/plugins/blog/api/mutations.ts b/packages/stack/src/plugins/blog/api/mutations.ts index f5083837..78476ab5 100644 --- a/packages/stack/src/plugins/blog/api/mutations.ts +++ b/packages/stack/src/plugins/blog/api/mutations.ts @@ -226,7 +226,7 @@ export async function updatePost( } if (tagInputs.length > 0) { - const resolvedTags = await findOrCreateTags(tx, tagInputs); + const resolvedTags = await findOrCreateTags(adapter, tagInputs); for (const tag of resolvedTags) { await tx.create<{ postId: string; tagId: string }>({ From e62125c77bdb7fa11e2e42aacfbf07525fbae8f6 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 9 Apr 2026 17:26:17 -0400 Subject: [PATCH 5/6] fix: fix server-generated timestamps in post data and ensure correct slug handling --- packages/stack/src/plugins/blog/api/plugin.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts index 0e0edb4c..274528da 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -331,7 +331,14 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ); } - const { tags, slug: rawSlug, ...postData } = ctx.body; + // Destructure and discard createdAt/updatedAt — timestamps are always server-generated + const { + tags, + slug: rawSlug, + createdAt: _ca, + updatedAt: _ua, + ...postData + } = ctx.body; // Always slugify to ensure URL-safe slug, whether provided or generated from title const slug = slugify(rawSlug || postData.title); @@ -348,6 +355,8 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ...postData, slug, tags: tags ?? [], + createdAt: new Date(), + updatedAt: new Date(), }); if (hooks?.onPostCreated) { @@ -386,7 +395,14 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ); } - const { tags, slug: rawSlug, ...restPostData } = ctx.body; + // Destructure and discard createdAt/updatedAt — timestamps are always server-generated + const { + tags, + slug: rawSlug, + createdAt: _ca, + updatedAt: _ua, + ...restPostData + } = ctx.body; // Sanitize slug if provided to ensure it's URL-safe const slugified = rawSlug ? slugify(rawSlug) : undefined; From e5d3f7f2f8180f5d3b13846afb4e2a0843c168f9 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 9 Apr 2026 17:32:36 -0400 Subject: [PATCH 6/6] docs: enhance blog plugin documentation to include mutation functions --- docs/content/docs/plugins/blog.mdx | 49 +++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 0a531725..99e0037a 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -585,18 +585,18 @@ You can import the hooks from `"@btst/stack/plugins/blog/client/hooks"` to use i ## Server-side Data Access -The blog plugin exposes standalone getter functions for server-side and SSG use cases. These bypass the HTTP layer entirely and query the database directly. +The blog plugin exposes standalone getter and mutation functions for server-side use cases. These bypass the HTTP layer entirely and query the database directly — no authorization hooks are called, so the caller is responsible for any access-control checks. ### Two patterns **Pattern 1 — via `stack().api` (recommended for runtime server code)** -After calling `stack()`, the returned object includes a fully-typed `api` namespace. Getters are pre-bound to the adapter: +After calling `stack()`, the returned object includes a fully-typed `api` namespace. Getters and mutations are pre-bound to the adapter: ```ts title="app/lib/stack.ts" import { myStack } from "./stack"; // your stack() instance -// In a Server Component, generateStaticParams, etc. +// Getters — read-only const result = await myStack.api.blog.getAllPosts({ published: true }); // result.items — Post[] // result.total — total count before pagination @@ -605,22 +605,45 @@ const result = await myStack.api.blog.getAllPosts({ published: true }); const post = await myStack.api.blog.getPostBySlug("hello-world"); const tags = await myStack.api.blog.getAllTags(); + +// Mutations — write operations (no auth hooks are called) +const newPost = await myStack.api.blog.createPost({ + title: "Hello World", + slug: "hello-world", + content: "...", + excerpt: "...", +}); +await myStack.api.blog.updatePost(newPost.id, { published: true }); +await myStack.api.blog.deletePost(newPost.id); ``` **Pattern 2 — direct import (SSG, build-time, or custom adapter)** -Import getters directly and pass any `Adapter`: +Import getters and mutations directly and pass any `Adapter`: ```ts -import { getAllPosts, getPostBySlug, getAllTags } from "@btst/stack/plugins/blog/api"; +import { getAllPosts, createPost, updatePost, deletePost } from "@btst/stack/plugins/blog/api"; // e.g. in Next.js generateStaticParams export async function generateStaticParams() { const { items } = await getAllPosts(myAdapter, { published: true }); return items.map((p) => ({ slug: p.slug })); } + +// e.g. seeding or scripting +const post = await createPost(myAdapter, { + title: "Seeded Post", + slug: "seeded-post", + content: "Content here", + excerpt: "Short excerpt", +}); +await updatePost(myAdapter, post.id, { published: true }); ``` + +**No authorization hooks are called** when using `stack().api.*` or direct imports. These functions hit the database directly. Always perform your own access-control checks before calling them from user-facing code. + + ### Available getters | Function | Returns | Description | @@ -637,6 +660,22 @@ export async function generateStaticParams() { +### Available mutations + +| Function | Returns | Description | +|---|---|---| +| `createPost(adapter, input)` | `Post` | Create a new post with optional tag associations | +| `updatePost(adapter, id, input)` | `Post \| null` | Update a post and reconcile its tags; `null` if not found | +| `deletePost(adapter, id)` | `void` | Delete a post by ID | + +### `CreatePostInput` + + + +### `UpdatePostInput` + + + ## Static Site Generation (SSG) `route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering.