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. 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..78476ab5 --- /dev/null +++ b/packages/stack/src/plugins/blog/api/mutations.ts @@ -0,0 +1,287 @@ +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; + + return adapter.transaction(async (tx) => { + const updatedPost = await tx.update({ + model: "post", + where: [{ field: "id", value: id }], + update: { + ...postData, + updatedAt: new Date(), + }, + }); + + if (!updatedPost) return null; + + if (tagInputs !== undefined) { + const existingPostTags = await tx.findMany<{ + postId: string; + tagId: string; + }>({ + model: "postTag", + where: [{ field: "postId", value: id, operator: "eq" as const }], + }); + + 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 (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 { + 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; + }); +} + +/** + * 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..274528da 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,17 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ); } - const { tags, ...postData } = ctx.body; - const tagNames = tags || []; + // 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(postData.slug || postData.title); + const slug = slugify(rawSlug || postData.title); // Validate that slugification produced a non-empty result if (!slug) { @@ -408,37 +351,14 @@ 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 ?? [], + createdAt: new Date(), + updatedAt: new Date(), }); - 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); } @@ -475,8 +395,14 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => ); } - const { tags, slug: rawSlug, ...restPostData } = ctx.body; - const tagNames = tags || []; + // 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; @@ -489,80 +415,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 +459,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) {