diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 23b37bf..2c9cbc8 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -929,3 +929,43 @@ overrides={{ #### AiChatLocalization + +## Server-side Data Access + +The AI Chat plugin exposes standalone getter functions for server-side use cases, giving you direct access to conversation history without going through HTTP. + +### Two patterns + +**Pattern 1 — via `stack().api`** + +```ts title="app/lib/stack.ts" +import { myStack } from "./stack"; + +// List all conversations (optionally scoped to a user) +const all = await myStack.api["ai-chat"].getAllConversations(); +const userConvs = await myStack.api["ai-chat"].getAllConversations("user-123"); + +// Get a conversation with its full message history +const conv = await myStack.api["ai-chat"].getConversationById("conv-456"); +if (conv) { + console.log(conv.messages); // Message[] +} +``` + +**Pattern 2 — direct import** + +```ts +import { + getAllConversations, + getConversationById, +} from "@btst/stack/plugins/ai-chat/api"; + +const conv = await getConversationById(myAdapter, conversationId); +``` + +### Available getters + +| Function | Description | +|---|---| +| `getAllConversations(adapter, userId?)` | Returns all conversations, optionally filtered by userId | +| `getConversationById(adapter, id)` | Returns a conversation with messages, or `null` | diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 4b8aaa3..d3d3cfa 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -527,3 +527,57 @@ You can import the hooks from `"@btst/stack/plugins/blog/client/hooks"` to use i #### PostUpdateInput + +## 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. + +### 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: + +```ts title="app/lib/stack.ts" +import { myStack } from "./stack"; // your stack() instance + +// In a Server Component, generateStaticParams, etc. +const result = await myStack.api.blog.getAllPosts({ published: true }); +// result.items — Post[] +// result.total — total count before pagination +// result.limit — applied limit +// result.offset — applied offset + +const post = await myStack.api.blog.getPostBySlug("hello-world"); +const tags = await myStack.api.blog.getAllTags(); +``` + +**Pattern 2 — direct import (SSG, build-time, or custom adapter)** + +Import getters directly and pass any `Adapter`: + +```ts +import { getAllPosts, getPostBySlug, getAllTags } 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 })); +} +``` + +### Available getters + +| Function | Returns | Description | +|---|---|---| +| `getAllPosts(adapter, params?)` | `PostListResult` | Paginated posts matching optional filter params | +| `getPostBySlug(adapter, slug)` | `Post \| null` | Single post by slug, or `null` if not found | +| `getAllTags(adapter)` | `Tag[]` | All tags, sorted alphabetically | + +### `PostListParams` + + + +### `PostListResult` + + diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 0c57276..cb1e51f 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -1248,3 +1248,42 @@ const result = zodSchema.safeParse(data) + +## Server-side Data Access + +The CMS plugin exposes standalone getter functions for server-side and SSG use cases. + +### Two patterns + +**Pattern 1 — via `stack().api`** + +```ts title="app/lib/stack.ts" +import { myStack } from "./stack"; + +const types = await myStack.api.cms.getAllContentTypes(); +const items = await myStack.api.cms.getAllContentItems("posts", { limit: 10 }); +const item = await myStack.api.cms.getContentItemBySlug("posts", "my-first-post"); +``` + +**Pattern 2 — direct import** + +```ts +import { + getAllContentTypes, + getAllContentItems, + getContentItemBySlug, +} from "@btst/stack/plugins/cms/api"; + +export async function generateStaticParams() { + const result = await getAllContentItems(myAdapter, "posts", { limit: 100 }); + return result.items.map((item) => ({ slug: item.slug })); +} +``` + +### Available getters + +| Function | Description | +|---|---| +| `getAllContentTypes(adapter)` | Returns all registered content types, sorted by name | +| `getAllContentItems(adapter, typeSlug, params?)` | Returns paginated items for a content type | +| `getContentItemBySlug(adapter, typeSlug, slug)` | Returns a single item by slug, or `null` | diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index 08534c5..dfb3cfd 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -296,6 +296,94 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => }) ``` +### Server-side API (Getter Functions) + +Plugins can expose a typed `api` surface that lets server code — Server Components, `generateStaticParams`, cron jobs, scripts — query the database directly, **without going through HTTP**. + + +**Getter functions bypass authorization hooks.** Plugin hooks such as `onBeforeListPosts` or `onBeforeListForms` are **not** called when you invoke getters via `myStack.api.*`. These functions are pure database calls — the caller is fully responsible for performing any access-control checks before invoking them. Do not call getters from user-facing request handlers without adding your own authorization logic first. + + +Add an `api` factory to `defineBackendPlugin`. The factory receives the shared adapter and returns an object of async functions: + +```typescript +export const todosBackendPlugin = defineBackendPlugin({ + name: "todos", + dbPlugin: dbSchema, + + // Expose server-side getters bound to the adapter + api: (adapter) => ({ + listTodos: () => + adapter.findMany({ model: "todo", sortBy: { field: "createdAt", direction: "desc" } }), + + getTodoById: (id: string) => + adapter.findOne({ model: "todo", where: [{ field: "id", value: id, operator: "eq" }] }), + }), + + routes: (adapter: Adapter) => { + // ... existing HTTP endpoints + }, +}) +``` + +After calling `stack()`, the returned object exposes the combined `api` namespace — one key per plugin — plus the raw `adapter`: + +```typescript +import { stack } from "@btst/stack" +import { todosBackendPlugin } from "./plugins/todo/api/backend" + +export const myStack = stack({ + basePath: "/api/data", + plugins: { todos: todosBackendPlugin }, + adapter: (db) => createMemoryAdapter(db)({}), +}) + +// Fully typed — no HTTP roundtrip +const todos = await myStack.api.todos.listTodos() +const todo = await myStack.api.todos.getTodoById("abc-123") + +// Or use the raw adapter directly +const raw = await myStack.adapter.findMany({ model: "todo" }) +``` + +**When to use this pattern:** + +| Use case | Approach | +|----------|----------| +| Server Component / RSC | `myStack.api.todos.listTodos()` | +| `generateStaticParams` (Next.js) | Import getters directly and pass any adapter | +| Cron job / script | `myStack.api.*` or direct getter import | +| HTTP route handler | HTTP endpoint via `routes` as normal | + +**Tip — direct getter imports for SSG/build-time:** + +If you need access to data before your `stack()` instance is available (e.g. at build time with a separate adapter), export the getter functions independently and pass an adapter yourself: + +```typescript +// api/getters.ts +import type { Adapter } from "@btst/stack/plugins/api" +import type { Todo } from "../types" + +export async function listTodos(adapter: Adapter) { + return adapter.findMany({ model: "todo" }) +} + +// api/backend.ts +import { listTodos } from "./getters" + +export const todosBackendPlugin = defineBackendPlugin({ + name: "todos", + dbPlugin: dbSchema, + api: (adapter) => ({ + listTodos: () => listTodos(adapter), + }), + routes: (adapter) => { /* ... */ }, +}) + +// In api/index.ts — re-export for consumers +export { listTodos } from "./getters" +``` + --- ## Client Plugin @@ -609,12 +697,12 @@ import { createMemoryAdapter } from "@btst/adapter-memory" import { todosBackendPlugin } from "./plugins/todo/api/backend" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" -const { handler, dbSchema } = stack({ +// Export the full stack instance so myStack.api.* is accessible anywhere +export const myStack = stack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin, blog: blogBackendPlugin({ - // Backend hooks for authorization onBeforeCreatePost: async (data, context) => { // Check authentication return true @@ -627,7 +715,17 @@ const { handler, dbSchema } = stack({ adapter: (db) => createMemoryAdapter(db)({}) }) -export { handler, dbSchema } +// Named re-exports for the HTTP route handler and DB schema +export const { handler, dbSchema } = myStack + +// myStack also exposes: +// myStack.adapter — raw database adapter +// myStack.api — typed server-side getters per plugin +// +// Usage in a Server Component or generateStaticParams: +// const todos = await myStack.api.todos.listTodos() +// const todo = await myStack.api.todos.getTodoById("abc-123") +// const posts = await myStack.api.blog.getAllPosts({ published: true }) ``` ### Client Registration @@ -711,21 +809,59 @@ export type Todo = { } ``` +**File: `lib/plugins/todo/api/getters.ts`** +```typescript +import type { Adapter } from "@btst/stack/plugins/api" +import type { Todo } from "../types" + +/** Retrieve all todos, sorted newest-first. Safe for server-side and SSG use. */ +export async function listTodos(adapter: Adapter): Promise { + return adapter.findMany({ + model: "todo", + sortBy: { field: "createdAt", direction: "desc" }, + }) as Promise +} + +/** Retrieve a single todo by ID. Returns null if not found. */ +export async function getTodoById( + adapter: Adapter, + id: string, +): Promise { + return adapter.findOne({ + model: "todo", + where: [{ field: "id", value: id, operator: "eq" }], + }) +} +``` + **File: `lib/plugins/todo/api/backend.ts`** ```typescript import { type Adapter, defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" import { z } from "zod" import { todosSchema as dbSchema } from "../schema" import type { Todo } from "../types" +import { listTodos, getTodoById } from "./getters" const createTodoSchema = z.object({ title: z.string().min(1), completed: z.boolean().optional().default(false) }) +const updateTodoSchema = z.object({ + title: z.string().min(1).optional(), + completed: z.boolean().optional() +}) + export const todosBackendPlugin = defineBackendPlugin({ name: "todos", dbPlugin: dbSchema, + + // Server-side getters — available as myStack.api.todos.* + api: (adapter) => ({ + listTodos: () => listTodos(adapter), + getTodoById: (id: string) => getTodoById(adapter, id), + }), + routes: (adapter: Adapter) => { const listTodos = createEndpoint("/todos", { method: "GET" }, async () => adapter.findMany({ model: "todo" }) || [] @@ -737,8 +873,27 @@ export const todosBackendPlugin = defineBackendPlugin({ data: { ...ctx.body, createdAt: new Date() } }) ) + + const updateTodo = createEndpoint("/todos/:id", { method: "PUT", body: updateTodoSchema }, + async (ctx) => { + const updated = await adapter.update({ + model: "todo", + where: [{ field: "id", value: ctx.params.id }], + update: ctx.body + }) + if (!updated) throw new Error("Todo not found") + return updated + } + ) + + const deleteTodo = createEndpoint("/todos/:id", { method: "DELETE" }, + async (ctx) => { + await adapter.delete({ model: "todo", where: [{ field: "id", value: ctx.params.id }] }) + return { success: true } + } + ) - return { listTodos, createTodo } as const + return { listTodos, createTodo, updateTodo, deleteTodo } as const } }) @@ -874,7 +1029,9 @@ A basic CRUD plugin demonstrating core concepts: Features: - Basic CRUD operations - Database schema -- API endpoints +- API endpoints (list, create, update, delete) +- Server-side getter functions (`getters.ts`) +- `stack().api.todos.*` surface for direct server-side access - Client components and hooks ### Full-Featured Plugin: Blog diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index af2744a..751afa1 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -704,3 +704,43 @@ const result = zodSchema.safeParse(submissionData) +## Server-side Data Access + +The Form Builder plugin exposes standalone getter functions for server-side use cases. + +### Two patterns + +**Pattern 1 — via `stack().api`** + +```ts title="app/lib/stack.ts" +import { myStack } from "./stack"; + +const forms = await myStack.api["form-builder"].getAllForms({ status: "active" }); +const form = await myStack.api["form-builder"].getFormBySlug("contact"); +const submissions = await myStack.api["form-builder"].getFormSubmissions(form!.id); +``` + +**Pattern 2 — direct import** + +```ts +import { + getAllForms, + getFormBySlug, + getFormSubmissions, +} from "@btst/stack/plugins/form-builder/api"; + +const form = await getFormBySlug(myAdapter, "contact"); +if (form) { + const result = await getFormSubmissions(myAdapter, form.id, { limit: 50 }); + console.log(result.total, "submissions"); +} +``` + +### Available getters + +| Function | Description | +|---|---| +| `getAllForms(adapter, params?)` | Returns paginated forms with optional status filter | +| `getFormBySlug(adapter, slug)` | Returns a single form by slug, or `null` | +| `getFormSubmissions(adapter, formId, params?)` | Returns paginated submissions for a form | + diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 42d6335..657f217 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -726,3 +726,56 @@ import type { KanbanPluginOverrides, } from "@btst/stack/plugins/kanban/client" ``` + +## Server-side Data Access + +The Kanban plugin exposes standalone getter functions for server-side and SSG use cases. + +### Two patterns + +**Pattern 1 — via `stack().api`** + +```ts title="app/lib/stack.ts" +import { myStack } from "./stack"; + +// List all boards (with columns and tasks) +const result = await myStack.api.kanban.getAllBoards({ ownerId: "user-123" }); +// result.items — BoardWithColumns[] +// result.total — total count before pagination +// result.limit — applied limit +// result.offset — applied offset + +// Get a single board with full column/task tree +const board = await myStack.api.kanban.getBoardById("board-456"); +if (board) { + board.columns.forEach((col) => { + console.log(col.title, col.tasks.length, "tasks"); + }); +} +``` + +**Pattern 2 — direct import** + +```ts +import { + getAllBoards, + getBoardById, +} from "@btst/stack/plugins/kanban/api"; + +// In Next.js generateStaticParams +export async function generateStaticParams() { + const { items } = await getAllBoards(myAdapter); + return items.map((b) => ({ slug: b.slug })); +} +``` + +### Available getters + +| Function | Returns | Description | +|---|---|---| +| `getAllBoards(adapter, params?)` | `BoardListResult` | Paginated boards with columns and tasks; supports slug/ownerId/organizationId filters | +| `getBoardById(adapter, id)` | `BoardWithColumns \| null` | Single board with full column/task tree, or `null` | + +### `BoardListResult` + + diff --git a/e2e/tests/smoke.auth-blog.spec.ts b/e2e/tests/smoke.auth-blog.spec.ts index 457de09..e8db13a 100644 --- a/e2e/tests/smoke.auth-blog.spec.ts +++ b/e2e/tests/smoke.auth-blog.spec.ts @@ -88,7 +88,8 @@ async function cleanupAuthTestPosts(request: any) { if (draftsResponse.status() === 200) { const drafts = await draftsResponse.json(); - for (const post of drafts) { + // /posts returns PostListResult { items, total, … } + for (const post of drafts.items ?? []) { // Only delete if it matches auth test patterns const isAuthTestPost = authTestPatterns.some((pattern) => pattern.test(post.title), @@ -109,7 +110,7 @@ async function cleanupAuthTestPosts(request: any) { if (publishedResponse.status() === 200) { const published = await publishedResponse.json(); - for (const post of published) { + for (const post of published.items ?? []) { const isAuthTestPost = authTestPatterns.some((pattern) => pattern.test(post.title), ); @@ -134,11 +135,11 @@ test.describe("Blog Authentication - API Level", () => { headers, }); - // Should successfully return drafts + // Should successfully return drafts — response is PostListResult { items, total, … } expect(response.status()).toBe(200); const data = await response.json(); - expect(Array.isArray(data)).toBe(true); - console.log("[Test] Drafts retrieved:", data.length); + expect(Array.isArray(data.items)).toBe(true); + console.log("[Test] Drafts retrieved:", data.items.length); }); test("API: unauthenticated user is blocked from listing drafts", async ({ @@ -327,7 +328,10 @@ test.describe("Blog Authentication - API Level", () => { ); expect(listResponse.status()).toBe(200); const drafts = await listResponse.json(); - const postStillExists = drafts.some((p: any) => p.id === post.id); + // /posts returns PostListResult { items, total, … } + const postStillExists = (drafts.items ?? []).some( + (p: any) => p.id === post.id, + ); expect(postStillExists).toBe(false); }); @@ -337,11 +341,14 @@ test.describe("Blog Authentication - API Level", () => { // Request public posts (should work without auth) const response = await request.get(`${API_BASE}/posts?published=true`); - // Should successfully return public posts + // Should successfully return public posts — response is PostListResult { items, total, … } expect(response.status()).toBe(200); const data = await response.json(); - expect(Array.isArray(data)).toBe(true); - console.log("[Test] Public posts retrieved without auth:", data.length); + expect(Array.isArray(data.items)).toBe(true); + console.log( + "[Test] Public posts retrieved without auth:", + data.items.length, + ); }); test("API: cookies are properly forwarded in requests", async ({ diff --git a/examples/nextjs/lib/plugins/todo/api/backend.ts b/examples/nextjs/lib/plugins/todo/api/backend.ts index e16bfb6..f64520d 100644 --- a/examples/nextjs/lib/plugins/todo/api/backend.ts +++ b/examples/nextjs/lib/plugins/todo/api/backend.ts @@ -2,6 +2,7 @@ import { type Adapter, defineBackendPlugin, createEndpoint } from "@btst/stack/p import { z } from "zod" import { todosSchema as dbSchema } from "../schema" import type { Todo } from "../types" +import { listTodos, getTodoById } from "./getters" const createTodoSchema = z.object({ title: z.string().min(1, "Title is required"), @@ -23,6 +24,12 @@ export const todosBackendPlugin = defineBackendPlugin({ dbPlugin: dbSchema, + // Server-side getters — available as myStack.api.todos.* + api: (adapter) => ({ + listTodos: () => listTodos(adapter), + getTodoById: (id: string) => getTodoById(adapter, id), + }), + routes: (adapter: Adapter) => { const listTodos = createEndpoint( "/todos", diff --git a/examples/nextjs/lib/plugins/todo/api/getters.ts b/examples/nextjs/lib/plugins/todo/api/getters.ts new file mode 100644 index 0000000..fc5d613 --- /dev/null +++ b/examples/nextjs/lib/plugins/todo/api/getters.ts @@ -0,0 +1,27 @@ +import type { Adapter } from "@btst/stack/plugins/api" +import type { Todo } from "../types" + +/** + * Retrieve all todos, sorted newest-first. + * Pure DB function — no HTTP context. Safe for server-side and SSG use. + */ +export async function listTodos(adapter: Adapter): Promise { + return adapter.findMany({ + model: "todo", + sortBy: { field: "createdAt", direction: "desc" }, + }) as Promise +} + +/** + * Retrieve a single todo by ID. + * Returns null if the todo does not exist. + */ +export async function getTodoById( + adapter: Adapter, + id: string, +): Promise { + return adapter.findOne({ + model: "todo", + where: [{ field: "id", value: id, operator: "eq" }], + }) +} diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 9c61362..3260f57 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -97,7 +97,7 @@ const blogHooks: BlogBackendHooks = { }, }; -const { handler, dbSchema } = stack({ +export const myStack = stack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin, @@ -218,4 +218,4 @@ const { handler, dbSchema } = stack({ adapter: (db) => createMemoryAdapter(db)({}) }) -export { handler, dbSchema } +export const { handler, dbSchema } = myStack diff --git a/packages/stack/package.json b/packages/stack/package.json index 3ea037c..fe28169 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.1.0", + "version": "2.2.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", diff --git a/packages/stack/src/__tests__/stack-api.test.ts b/packages/stack/src/__tests__/stack-api.test.ts new file mode 100644 index 0000000..794a2a6 --- /dev/null +++ b/packages/stack/src/__tests__/stack-api.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { stack } from "../api"; +import { defineBackendPlugin } from "../plugins/api"; +import { createDbPlugin } from "@btst/db"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import type { Adapter, DatabaseDefinition } from "@btst/db"; +import { blogBackendPlugin } from "../plugins/blog/api"; +import { kanbanBackendPlugin } from "../plugins/kanban/api"; + +const testAdapter = (db: DatabaseDefinition): Adapter => + createMemoryAdapter(db)({}); + +/** + * A minimal plugin with no `api` factory, to verify backward compatibility. + */ +const noApiPlugin = defineBackendPlugin({ + name: "no-api", + dbPlugin: createDbPlugin("no-api", {}), + routes: () => ({}), +}); + +describe("stack.api surface", () => { + it("exposes adapter on the returned backend", () => { + const backend = stack({ + basePath: "/api", + plugins: { blog: blogBackendPlugin() }, + adapter: testAdapter, + }); + + expect(backend.adapter).toBeDefined(); + expect(typeof backend.adapter.findMany).toBe("function"); + expect(typeof backend.adapter.findOne).toBe("function"); + expect(typeof backend.adapter.create).toBe("function"); + }); + + it("exposes typed api namespace for plugins with api factory", () => { + const backend = stack({ + basePath: "/api", + plugins: { blog: blogBackendPlugin() }, + adapter: testAdapter, + }); + + expect(backend.api).toBeDefined(); + expect(backend.api.blog).toBeDefined(); + expect(typeof backend.api.blog.getAllPosts).toBe("function"); + expect(typeof backend.api.blog.getPostBySlug).toBe("function"); + expect(typeof backend.api.blog.getAllTags).toBe("function"); + }); + + it("exposes kanban api namespace", () => { + const backend = stack({ + basePath: "/api", + plugins: { kanban: kanbanBackendPlugin() }, + adapter: testAdapter, + }); + + expect(backend.api.kanban).toBeDefined(); + expect(typeof backend.api.kanban.getAllBoards).toBe("function"); + expect(typeof backend.api.kanban.getBoardById).toBe("function"); + }); + + it("plugins without api factory are not present in api", () => { + const backend = stack({ + basePath: "/api", + plugins: { noApi: noApiPlugin }, + adapter: testAdapter, + }); + + expect((backend.api as any).noApi).toBeUndefined(); + }); + + it("api functions are bound to the shared adapter and return real data", async () => { + const backend = stack({ + basePath: "/api", + plugins: { blog: blogBackendPlugin() }, + adapter: testAdapter, + }); + + // Seed data via adapter directly + await backend.adapter.create({ + model: "post", + data: { + title: "Hello World", + slug: "hello-world", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + // Retrieve via stack.api + const posts = await backend.api.blog.getAllPosts(); + expect(posts.items).toHaveLength(1); + expect(posts.items[0]!.slug).toBe("hello-world"); + + // Verify same adapter - data is shared + const bySlug = await backend.api.blog.getPostBySlug("hello-world"); + expect(bySlug).not.toBeNull(); + expect(bySlug!.title).toBe("Hello World"); + }); + + it("combines multiple plugins in a single stack call", () => { + const backend = stack({ + basePath: "/api", + plugins: { + blog: blogBackendPlugin(), + kanban: kanbanBackendPlugin(), + }, + adapter: testAdapter, + }); + + expect(typeof backend.api.blog.getAllPosts).toBe("function"); + expect(typeof backend.api.kanban.getAllBoards).toBe("function"); + }); +}); diff --git a/packages/stack/src/api/index.ts b/packages/stack/src/api/index.ts index e4a5bd9..1934dba 100644 --- a/packages/stack/src/api/index.ts +++ b/packages/stack/src/api/index.ts @@ -3,6 +3,7 @@ import type { BackendLibConfig, BackendLib, PrefixedPluginRoutes, + PluginApis, StackContext, } from "../types"; import { defineDb } from "@btst/db"; @@ -33,7 +34,9 @@ export function stack< TPlugins extends Record, TRoutes extends PrefixedPluginRoutes = PrefixedPluginRoutes, ->(config: BackendLibConfig): BackendLib { +>( + config: BackendLibConfig, +): BackendLib> { const { plugins, adapter, dbSchema, basePath } = config; // Collect all routes from all plugins with type-safe prefixed keys @@ -67,6 +70,14 @@ export function stack< } } + // Build the typed api surface by calling each plugin's api factory + const pluginApis = {} as PluginApis; + for (const [pluginKey, plugin] of Object.entries(plugins)) { + if (plugin.api) { + (pluginApis as any)[pluginKey] = plugin.api(adapterInstance); + } + } + // Create the composed router const router = createRouter(allRoutes, { basePath: basePath, @@ -76,6 +87,8 @@ export function stack< handler: router.handler, router, dbSchema: betterDbSchema, + adapter: adapterInstance, + api: pluginApis, }; } @@ -83,5 +96,6 @@ export type { BackendPlugin, BackendLibConfig, BackendLib, + PluginApis, StackContext, } from "../types"; diff --git a/packages/stack/src/plugins/ai-chat/__tests__/getters.test.ts b/packages/stack/src/plugins/ai-chat/__tests__/getters.test.ts new file mode 100644 index 0000000..f704473 --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/__tests__/getters.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { Adapter } from "@btst/db"; +import { aiChatSchema } from "../db"; +import { getAllConversations, getConversationById } from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(aiChatSchema); + return createMemoryAdapter(db)({}); +}; + +async function createConversation( + adapter: Adapter, + title: string, + userId?: string, +): Promise { + return adapter.create({ + model: "conversation", + data: { + title, + ...(userId ? { userId } : {}), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +describe("ai-chat getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + describe("getAllConversations", () => { + it("returns empty array when no conversations exist", async () => { + const convs = await getAllConversations(adapter); + expect(convs).toEqual([]); + }); + + it("returns all conversations sorted by updatedAt desc", async () => { + await createConversation(adapter, "First"); + await createConversation(adapter, "Second"); + + const convs = await getAllConversations(adapter); + expect(convs).toHaveLength(2); + }); + + it("filters conversations by userId", async () => { + await createConversation(adapter, "Alice Conv", "user-alice"); + await createConversation(adapter, "Bob Conv", "user-bob"); + await createConversation(adapter, "No User Conv"); + + const aliceConvs = await getAllConversations(adapter, "user-alice"); + expect(aliceConvs).toHaveLength(1); + expect(aliceConvs[0]!.title).toBe("Alice Conv"); + + const allConvs = await getAllConversations(adapter); + expect(allConvs).toHaveLength(3); + }); + }); + + describe("getConversationById", () => { + it("returns null when conversation does not exist", async () => { + const conv = await getConversationById(adapter, "nonexistent"); + expect(conv).toBeNull(); + }); + + it("returns conversation with messages", async () => { + const conv = (await createConversation(adapter, "My Chat")) as any; + + await adapter.create({ + model: "message", + data: { + conversationId: conv.id, + role: "user", + content: JSON.stringify([{ type: "text", text: "Hello!" }]), + createdAt: new Date(Date.now() - 1000), + }, + }); + await adapter.create({ + model: "message", + data: { + conversationId: conv.id, + role: "assistant", + content: JSON.stringify([{ type: "text", text: "Hi there!" }]), + createdAt: new Date(), + }, + }); + + const result = await getConversationById(adapter, conv.id); + expect(result).not.toBeNull(); + expect(result!.id).toBe(conv.id); + expect(result!.title).toBe("My Chat"); + expect(result!.messages).toHaveLength(2); + expect(result!.messages[0]!.role).toBe("user"); + expect(result!.messages[1]!.role).toBe("assistant"); + }); + + it("returns conversation with empty messages array if none exist", async () => { + const conv = (await createConversation(adapter, "Empty Chat")) as any; + + const result = await getConversationById(adapter, conv.id); + expect(result).not.toBeNull(); + expect(result!.messages).toEqual([]); + }); + }); +}); diff --git a/packages/stack/src/plugins/ai-chat/api/getters.ts b/packages/stack/src/plugins/ai-chat/api/getters.ts new file mode 100644 index 0000000..d75b8b3 --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/api/getters.ts @@ -0,0 +1,71 @@ +import type { Adapter } from "@btst/db"; +import type { Conversation, ConversationWithMessages, Message } from "../types"; + +/** + * Retrieve all conversations, optionally filtered by userId. + * Pure DB function - no hooks, no HTTP context. Safe for server-side use. + * + * @param adapter - The database adapter + * @param userId - Optional user ID to filter conversations by owner + */ +export async function getAllConversations( + adapter: Adapter, + userId?: string, +): Promise { + const whereConditions: Array<{ + field: string; + value: string; + operator: "eq"; + }> = []; + + if (userId) { + whereConditions.push({ + field: "userId", + value: userId, + operator: "eq" as const, + }); + } + + return adapter.findMany({ + model: "conversation", + where: whereConditions.length > 0 ? whereConditions : undefined, + sortBy: { field: "updatedAt", direction: "desc" }, + }); +} + +/** + * Retrieve a single conversation by its ID, including all messages. + * Returns null if the conversation is not found. + * Pure DB function - no hooks, no HTTP context. Safe for server-side use. + * + * @param adapter - The database adapter + * @param id - The conversation ID + */ +export async function getConversationById( + adapter: Adapter, + id: string, +): Promise<(Conversation & { messages: Message[] }) | null> { + const conversations = await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: id, operator: "eq" as const }], + limit: 1, + join: { + message: true, + }, + }); + + if (!conversations.length) { + return null; + } + + const conversation = conversations[0]!; + const messages = (conversation.message || []).sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + + const { message: _, ...conversationWithoutJoin } = conversation; + return { + ...conversationWithoutJoin, + messages, + }; +} diff --git a/packages/stack/src/plugins/ai-chat/api/index.ts b/packages/stack/src/plugins/ai-chat/api/index.ts index 957a114..c41d081 100644 --- a/packages/stack/src/plugins/ai-chat/api/index.ts +++ b/packages/stack/src/plugins/ai-chat/api/index.ts @@ -1,2 +1,3 @@ export * from "./plugin"; +export { getAllConversations, getConversationById } from "./getters"; export { createAiChatQueryKeys } from "../query-keys"; diff --git a/packages/stack/src/plugins/ai-chat/api/plugin.ts b/packages/stack/src/plugins/ai-chat/api/plugin.ts index 92ba513..439001d 100644 --- a/packages/stack/src/plugins/ai-chat/api/plugin.ts +++ b/packages/stack/src/plugins/ai-chat/api/plugin.ts @@ -16,6 +16,7 @@ import { updateConversationSchema, } from "../schemas"; import type { Conversation, ConversationWithMessages, Message } from "../types"; +import { getAllConversations, getConversationById } from "./getters"; /** * Context passed to AI Chat API hooks @@ -286,6 +287,13 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) => name: "ai-chat", // Always include db schema - in public mode we just don't use it dbPlugin: dbSchema, + + api: (adapter) => ({ + getAllConversations: (userId?: string) => + getAllConversations(adapter, userId), + getConversationById: (id: string) => getConversationById(adapter, id), + }), + routes: (adapter: Adapter) => { const mode = config.mode ?? "authenticated"; const isPublicMode = mode === "public"; diff --git a/packages/stack/src/plugins/api/index.ts b/packages/stack/src/plugins/api/index.ts index 39fba9c..6752b76 100644 --- a/packages/stack/src/plugins/api/index.ts +++ b/packages/stack/src/plugins/api/index.ts @@ -42,9 +42,11 @@ export { createDbPlugin } from "@btst/db"; * ``` * * @template TRoutes - The exact shape of routes (auto-inferred from routes function) + * @template TApi - The shape of the server-side api surface (auto-inferred from api factory) */ export function defineBackendPlugin< TRoutes extends Record = Record, ->(plugin: BackendPlugin): BackendPlugin { + TApi extends Record any> = never, +>(plugin: BackendPlugin): BackendPlugin { return plugin; } diff --git a/packages/stack/src/plugins/blog/__tests__/getters.test.ts b/packages/stack/src/plugins/blog/__tests__/getters.test.ts new file mode 100644 index 0000000..aca0a53 --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/getters.test.ts @@ -0,0 +1,540 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { Adapter } from "@btst/db"; +import { blogSchema } from "../db"; +import { getAllPosts, getPostBySlug, getAllTags } from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(blogSchema); + return createMemoryAdapter(db)({}); +}; + +describe("blog getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + describe("getAllPosts", () => { + it("returns empty result when no posts exist", async () => { + const result = await getAllPosts(adapter); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("returns all posts with empty tags array", async () => { + await adapter.create({ + model: "post", + data: { + title: "Hello World", + slug: "hello-world", + content: "Content here", + excerpt: "Excerpt", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const result = await getAllPosts(adapter); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("hello-world"); + expect(result.items[0]!.tags).toEqual([]); + }); + + it("filters posts by published status", async () => { + await adapter.create({ + model: "post", + data: { + title: "Published Post", + slug: "published", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "post", + data: { + title: "Draft Post", + slug: "draft", + content: "Content", + excerpt: "", + published: false, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const published = await getAllPosts(adapter, { published: true }); + expect(published.items).toHaveLength(1); + expect(published.total).toBe(1); + expect(published.items[0]!.slug).toBe("published"); + + const drafts = await getAllPosts(adapter, { published: false }); + expect(drafts.items).toHaveLength(1); + expect(drafts.total).toBe(1); + expect(drafts.items[0]!.slug).toBe("draft"); + }); + + it("filters posts by slug", async () => { + await adapter.create({ + model: "post", + data: { + title: "Post A", + slug: "post-a", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "post", + data: { + title: "Post B", + slug: "post-b", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const result = await getAllPosts(adapter, { slug: "post-a" }); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("post-a"); + }); + + it("searches posts by query string", async () => { + await adapter.create({ + model: "post", + data: { + title: "TypeScript Tips", + slug: "ts-tips", + content: "Using generics", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "post", + data: { + title: "React Hooks", + slug: "react-hooks", + content: "Using hooks", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const result = await getAllPosts(adapter, { query: "typescript" }); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("ts-tips"); + }); + + it("respects limit and offset", async () => { + for (let i = 1; i <= 5; i++) { + await adapter.create({ + model: "post", + data: { + title: `Post ${i}`, + slug: `post-${i}`, + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(Date.now() + i * 1000), + updatedAt: new Date(), + }, + }); + } + + const page1 = await getAllPosts(adapter, { limit: 2, offset: 0 }); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(5); + + const page2 = await getAllPosts(adapter, { limit: 2, offset: 2 }); + expect(page2.items).toHaveLength(2); + expect(page2.total).toBe(5); + + // Pages should be different posts + expect(page1.items[0]!.slug).not.toBe(page2.items[0]!.slug); + }); + + it("attaches tags to posts", async () => { + const post = await adapter.create({ + model: "post", + data: { + title: "Tagged Post", + slug: "tagged", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const tag = await adapter.create({ + model: "tag", + data: { + name: "JavaScript", + slug: "javascript", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "postTag", + data: { postId: (post as any).id, tagId: (tag as any).id }, + }); + + const result = await getAllPosts(adapter); + expect(result.items[0]!.tags).toHaveLength(1); + expect(result.items[0]!.tags[0]!.slug).toBe("javascript"); + }); + + it("filters posts by tagSlug and returns empty for missing tag", async () => { + const result = await getAllPosts(adapter, { tagSlug: "nonexistent" }); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("filters posts by tagSlug - returns only tagged posts", async () => { + const taggedPost = await adapter.create({ + model: "post", + data: { + title: "Tagged Post", + slug: "tagged-post", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + for (let i = 1; i <= 3; i++) { + await adapter.create({ + model: "post", + data: { + title: `Untagged Post ${i}`, + slug: `untagged-${i}`, + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } + const tag = await adapter.create({ + model: "tag", + data: { + name: "TypeScript", + slug: "typescript", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "postTag", + data: { postId: (taggedPost as any).id, tagId: (tag as any).id }, + }); + + const result = await getAllPosts(adapter, { tagSlug: "typescript" }); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("tagged-post"); + }); + + it("paginates tagSlug results at the DB level - limit/offset respected", async () => { + // Create a tag and 5 posts tagged with it, plus 10 untagged posts. + // With DB-level filtering the findMany should only receive 5 rows, + // never loading the 10 untagged posts into memory. + const tag = await adapter.create({ + model: "tag", + data: { + name: "JS", + slug: "js", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + for (let i = 1; i <= 5; i++) { + const post = await adapter.create({ + model: "post", + data: { + title: `JS Post ${i}`, + slug: `js-post-${i}`, + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(Date.now() + i * 1000), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "postTag", + data: { postId: (post as any).id, tagId: (tag as any).id }, + }); + } + for (let i = 1; i <= 10; i++) { + await adapter.create({ + model: "post", + data: { + title: `Noise Post ${i}`, + slug: `noise-${i}`, + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + const page1 = await getAllPosts(adapter, { + tagSlug: "js", + limit: 2, + offset: 0, + }); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(5); + expect(page1.items.every((p) => p.slug.startsWith("js-post"))).toBe(true); + + const page2 = await getAllPosts(adapter, { + tagSlug: "js", + limit: 2, + offset: 2, + }); + expect(page2.items).toHaveLength(2); + expect(page2.total).toBe(5); + + const page3 = await getAllPosts(adapter, { + tagSlug: "js", + limit: 2, + offset: 4, + }); + expect(page3.items).toHaveLength(1); + expect(page3.total).toBe(5); + + // Pages must be disjoint + const allSlugs = [...page1.items, ...page2.items, ...page3.items].map( + (p) => p.slug, + ); + expect(new Set(allSlugs).size).toBe(5); + }); + + it("tagSlug combined with published filter only returns published tagged posts", async () => { + const tag = await adapter.create({ + model: "tag", + data: { + name: "CSS", + slug: "css", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const published = await adapter.create({ + model: "post", + data: { + title: "Published CSS Post", + slug: "pub-css", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + const draft = await adapter.create({ + model: "post", + data: { + title: "Draft CSS Post", + slug: "draft-css", + content: "Content", + excerpt: "", + published: false, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "postTag", + data: { postId: (published as any).id, tagId: (tag as any).id }, + }); + await adapter.create({ + model: "postTag", + data: { postId: (draft as any).id, tagId: (tag as any).id }, + }); + + const result = await getAllPosts(adapter, { + tagSlug: "css", + published: true, + }); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("pub-css"); + }); + + it("returns all posts when no limit is specified (DB path - more than 10)", async () => { + for (let i = 1; i <= 15; i++) { + await adapter.create({ + model: "post", + data: { + title: `Post ${i}`, + slug: `post-${i}`, + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(Date.now() + i * 1000), + updatedAt: new Date(), + }, + }); + } + + const result = await getAllPosts(adapter); + expect(result.items).toHaveLength(15); + expect(result.total).toBe(15); + expect(result.limit).toBeUndefined(); + }); + + it("returns all matching posts when no limit is specified (in-memory query path - more than 10)", async () => { + for (let i = 1; i <= 15; i++) { + await adapter.create({ + model: "post", + data: { + title: `TypeScript Post ${i}`, + slug: `ts-post-${i}`, + content: "TypeScript content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(Date.now() + i * 1000), + updatedAt: new Date(), + }, + }); + } + + const result = await getAllPosts(adapter, { query: "TypeScript" }); + expect(result.items).toHaveLength(15); + expect(result.total).toBe(15); + expect(result.limit).toBeUndefined(); + }); + + it("total reflects count before pagination slice for in-memory filters", async () => { + for (let i = 1; i <= 4; i++) { + await adapter.create({ + model: "post", + data: { + title: `TypeScript Post ${i}`, + slug: `ts-post-${i}`, + content: "TypeScript content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(Date.now() + i * 1000), + updatedAt: new Date(), + }, + }); + } + + const result = await getAllPosts(adapter, { + query: "TypeScript", + limit: 2, + offset: 0, + }); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(4); + }); + }); + + describe("getPostBySlug", () => { + it("returns null when post does not exist", async () => { + const post = await getPostBySlug(adapter, "nonexistent"); + expect(post).toBeNull(); + }); + + it("returns the post when it exists", async () => { + await adapter.create({ + model: "post", + data: { + title: "My Post", + slug: "my-post", + content: "Content", + excerpt: "", + published: true, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const post = await getPostBySlug(adapter, "my-post"); + expect(post).not.toBeNull(); + expect(post!.slug).toBe("my-post"); + expect(post!.title).toBe("My Post"); + }); + }); + + describe("getAllTags", () => { + it("returns empty array when no tags exist", async () => { + const tags = await getAllTags(adapter); + expect(tags).toEqual([]); + }); + + it("returns all tags sorted alphabetically by name", async () => { + await adapter.create({ + model: "tag", + data: { + name: "TypeScript", + slug: "typescript", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "tag", + data: { + name: "React", + slug: "react", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const tags = await getAllTags(adapter); + expect(tags).toHaveLength(2); + expect(tags[0]!.name).toBe("React"); + expect(tags[1]!.name).toBe("TypeScript"); + }); + }); +}); diff --git a/packages/stack/src/plugins/blog/api/getters.ts b/packages/stack/src/plugins/blog/api/getters.ts new file mode 100644 index 0000000..9ee039c --- /dev/null +++ b/packages/stack/src/plugins/blog/api/getters.ts @@ -0,0 +1,243 @@ +import type { Adapter } from "@btst/db"; +import type { Post, PostWithPostTag, Tag } from "../types"; + +/** + * Parameters for filtering/paginating posts. + * Mirrors the shape of the list API query schema. + */ +export interface PostListParams { + slug?: string; + tagSlug?: string; + offset?: number; + limit?: number; + query?: string; + published?: boolean; +} + +/** + * Paginated result returned by {@link getAllPosts}. + */ +export interface PostListResult { + items: Array; + total: number; + limit?: number; + offset?: number; +} + +/** + * Retrieve all posts matching optional filter criteria. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks (e.g. `onBeforeListPosts`) are NOT + * called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param params - Optional filter/pagination parameters (same shape as the list API query) + */ +export async function getAllPosts( + adapter: Adapter, + params?: PostListParams, +): Promise { + const query = params ?? {}; + + const whereConditions: Array<{ + field: string; + value: string | number | boolean | string[] | number[] | Date | null; + operator: "eq" | "in"; + }> = []; + + // Resolve tagSlug → post IDs up front, then push an `in` condition so the + // adapter can filter and paginate entirely at the DB level. The previous + // approach loaded every post into memory and filtered with a JS Set, which + // scans the whole table on every request. + if (query.tagSlug) { + const tag = await adapter.findOne({ + model: "tag", + where: [{ field: "slug", value: query.tagSlug, operator: "eq" as const }], + }); + + if (!tag) { + return { items: [], total: 0, limit: query.limit, offset: query.offset }; + } + + const postTags = await adapter.findMany<{ postId: string; tagId: string }>({ + model: "postTag", + where: [{ field: "tagId", value: tag.id, operator: "eq" as const }], + }); + + const taggedPostIds = postTags.map((pt) => pt.postId); + if (taggedPostIds.length === 0) { + return { items: [], total: 0, limit: query.limit, offset: query.offset }; + } + + whereConditions.push({ + field: "id", + value: taggedPostIds, + operator: "in" as const, + }); + } + + if (query.published !== undefined) { + whereConditions.push({ + field: "published", + value: query.published, + operator: "eq" as const, + }); + } + + if (query.slug) { + whereConditions.push({ + field: "slug", + value: query.slug, + operator: "eq" as const, + }); + } + + // Full-text search across title/content/excerpt must remain in-memory: + // the adapter's `contains` operator is case-sensitive and cannot be + // grouped with AND conditions using OR connectors in all adapter + // implementations. All other filters above are pushed to DB, so the + // in-memory pass only scans the already-narrowed result set. + const needsInMemoryFilter = !!query.query; + + const dbWhere = whereConditions.length > 0 ? whereConditions : undefined; + + const dbTotal: number | undefined = !needsInMemoryFilter + ? await adapter.count({ model: "post", where: dbWhere }) + : undefined; + + const posts = await adapter.findMany({ + model: "post", + limit: !needsInMemoryFilter ? query.limit : undefined, + offset: !needsInMemoryFilter ? query.offset : undefined, + where: whereConditions, + sortBy: { field: "createdAt", direction: "desc" }, + join: { postTag: true }, + }); + + // Collect the unique tag IDs present in this page of posts, then fetch + // only those tags (not the entire tags table). + const tagIds = new Set(); + for (const post of posts) { + if (post.postTag) { + for (const pt of post.postTag) { + tagIds.add(pt.tagId); + } + } + } + + const tags = + tagIds.size > 0 + ? await adapter.findMany({ + model: "tag", + where: [ + { + field: "id", + value: Array.from(tagIds), + operator: "in" as const, + }, + ], + }) + : []; + const tagMap = new Map(tags.map((t) => [t.id, t])); + + let result = posts.map((post) => { + const postTags = (post.postTag || []) + .map((pt) => { + const tag = tagMap.get(pt.tagId); + return tag ? { ...tag } : undefined; + }) + .filter((tag): tag is Tag => tag !== undefined); + const { postTag: _, ...postWithoutJoin } = post; + return { ...postWithoutJoin, tags: postTags }; + }); + + if (query.query) { + const searchLower = query.query.toLowerCase(); + result = result.filter( + (post) => + post.title?.toLowerCase().includes(searchLower) || + post.content?.toLowerCase().includes(searchLower) || + post.excerpt?.toLowerCase().includes(searchLower), + ); + } + + if (needsInMemoryFilter) { + const total = result.length; + const offset = query.offset ?? 0; + const limit = query.limit; + result = result.slice( + offset, + limit !== undefined ? offset + limit : undefined, + ); + return { items: result, total, limit: query.limit, offset: query.offset }; + } + + return { + items: result, + total: dbTotal ?? result.length, + limit: query.limit, + offset: query.offset, + }; +} + +/** + * Retrieve a single post by its slug, including associated tags. + * Returns null if no post is found. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param slug - The post slug + */ +export async function getPostBySlug( + adapter: Adapter, + slug: string, +): Promise<(Post & { tags: Tag[] }) | null> { + const posts = await adapter.findMany({ + model: "post", + where: [{ field: "slug", value: slug, operator: "eq" as const }], + limit: 1, + join: { postTag: true }, + }); + + if (posts.length === 0) return null; + + const post = posts[0]!; + const tagIds = (post.postTag || []).map((pt) => pt.tagId); + + const tags = + tagIds.length > 0 + ? await adapter.findMany({ + model: "tag", + where: [{ field: "id", value: tagIds, operator: "in" as const }], + }) + : []; + + const tagMap = new Map(tags.map((t) => [t.id, t])); + const resolvedTags = (post.postTag || []) + .map((pt) => tagMap.get(pt.tagId)) + .filter((t): t is Tag => t !== undefined); + + const { postTag: _, ...postWithoutJoin } = post; + return { ...postWithoutJoin, tags: resolvedTags }; +} + +/** + * Retrieve all tags, sorted alphabetically by name. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + */ +export async function getAllTags(adapter: Adapter): Promise { + return adapter.findMany({ + model: "tag", + sortBy: { field: "name", direction: "asc" }, + }); +} diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts index f05c7f7..7a4187b 100644 --- a/packages/stack/src/plugins/blog/api/index.ts +++ b/packages/stack/src/plugins/blog/api/index.ts @@ -1,2 +1,9 @@ export * from "./plugin"; +export { + getAllPosts, + getPostBySlug, + getAllTags, + type PostListParams, + type PostListResult, +} from "./getters"; export { createBlogQueryKeys } from "../query-keys"; diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts index c1dc974..7fdc9d4 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -6,6 +6,7 @@ import { blogSchema as dbSchema } from "../db"; import type { Post, PostWithPostTag, Tag } from "../types"; import { slugify } from "../utils"; import { createPostSchema, updatePostSchema } from "../schemas"; +import { getAllPosts, getPostBySlug, getAllTags } from "./getters"; export const PostListQuerySchema = z.object({ slug: z.string().optional(), @@ -86,12 +87,12 @@ export interface BlogBackendHooks { /** * Called after posts are read successfully - * @param posts - Array of posts that were read + * @param posts - The list of posts returned by the query * @param filter - Query parameters used for filtering * @param context - Request context */ onPostsRead?: ( - posts: Post[], + posts: Array, filter: z.infer, context: BlogApiContext, ) => Promise | void; @@ -168,6 +169,13 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => dbPlugin: dbSchema, + api: (adapter) => ({ + getAllPosts: (params?: Parameters[1]) => + getAllPosts(adapter, params), + getPostBySlug: (slug: string) => getPostBySlug(adapter, slug), + getAllTags: () => getAllTags(adapter), + }), + routes: (adapter: Adapter) => { const findOrCreateTags = async ( tagInputs: Array< @@ -265,8 +273,11 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => } } - let tagFilterPostIds: Set | null = null; - + // Preserve pre-refactor behaviour: when a tagSlug is provided + // but the tag doesn't exist or has no associated posts, return an + // empty result without invoking onPostsRead. The getAllPosts getter + // handles these cases with early returns of its own, but the hook + // call lives here in the handler and must be guarded the same way. if (query.tagSlug) { const tag = await adapter.findOne({ model: "tag", @@ -280,7 +291,12 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => }); if (!tag) { - return []; + return { + items: [], + total: 0, + limit: query.limit, + offset: query.offset, + }; } const postTags = await adapter.findMany<{ @@ -296,113 +312,21 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => }, ], }); - tagFilterPostIds = new Set(postTags.map((pt) => pt.postId)); - if (tagFilterPostIds.size === 0) { - return []; - } - } - - const whereConditions = []; - - if (query.published !== undefined) { - whereConditions.push({ - field: "published", - value: query.published, - operator: "eq" as const, - }); - } - - if (query.slug) { - whereConditions.push({ - field: "slug", - value: query.slug, - operator: "eq" as const, - }); - } - - const posts = await adapter.findMany({ - model: "post", - limit: - query.query || query.tagSlug ? undefined : (query.limit ?? 10), - offset: - query.query || query.tagSlug ? undefined : (query.offset ?? 0), - where: whereConditions, - sortBy: { - field: "createdAt", - direction: "desc", - }, - join: { - postTag: true, - }, - }); - - // Collect unique tag IDs from joined postTag data - const tagIds = new Set(); - for (const post of posts) { - if (post.postTag) { - for (const pt of post.postTag) { - tagIds.add(pt.tagId); - } - } - } - // Fetch all tags at once - const tags = - tagIds.size > 0 - ? await adapter.findMany({ - model: "tag", - }) - : []; - const tagMap = new Map(); - for (const tag of tags) { - if (tagIds.has(tag.id)) { - tagMap.set(tag.id, tag); + if (postTags.length === 0) { + return { + items: [], + total: 0, + limit: query.limit, + offset: query.offset, + }; } } - // Map tags to posts (spread to avoid circular references) - let result = posts.map((post) => { - const postTags = (post.postTag || []) - .map((pt) => { - const tag = tagMap.get(pt.tagId); - return tag ? { ...tag } : undefined; - }) - .filter((tag): tag is Tag => tag !== undefined); - const { postTag: _, ...postWithoutJoin } = post; - return { - ...postWithoutJoin, - tags: postTags, - }; - }); - - if (tagFilterPostIds) { - result = result.filter((post) => tagFilterPostIds!.has(post.id)); - } - - if (query.query) { - const searchLower = query.query.toLowerCase(); - result = result.filter((post) => { - const titleMatch = post.title - ?.toLowerCase() - .includes(searchLower); - const contentMatch = post.content - ?.toLowerCase() - .includes(searchLower); - const excerptMatch = post.excerpt - ?.toLowerCase() - .includes(searchLower); - return titleMatch || contentMatch || excerptMatch; - }); - } - - if (query.tagSlug || query.query) { - const offset = query.offset ?? 0; - const limit = query.limit ?? 10; - result = result.slice(offset, offset + limit); - } + const result = await getAllPosts(adapter, query); if (hooks?.onPostsRead) { - await hooks.onPostsRead(result, query, context); + await hooks.onPostsRead(result.items, query, context); } return result; @@ -806,9 +730,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => method: "GET", }, async () => { - return await adapter.findMany({ - model: "tag", - }); + return await getAllTags(adapter); }, ); diff --git a/packages/stack/src/plugins/blog/client/plugin.tsx b/packages/stack/src/plugins/blog/client/plugin.tsx index 1e30949..aa5da65 100644 --- a/packages/stack/src/plugins/blog/client/plugin.tsx +++ b/packages/stack/src/plugins/blog/client/plugin.tsx @@ -750,7 +750,8 @@ export const blogClientPlugin = (config: BlogClientConfig) => published: "true", }, }); - const page = (res.data ?? []) as unknown as SerializedPost[]; + // The /posts endpoint returns PostListResult { items, total, limit, offset } + const page = ((res.data as any)?.items ?? []) as SerializedPost[]; posts.push(...page); if (page.length < limit) break; offset += limit; diff --git a/packages/stack/src/plugins/blog/query-keys.ts b/packages/stack/src/plugins/blog/query-keys.ts index 489962f..1fcbbf3 100644 --- a/packages/stack/src/plugins/blog/query-keys.ts +++ b/packages/stack/src/plugins/blog/query-keys.ts @@ -99,13 +99,14 @@ function createPostsQueries( }, headers, }); - // Check for errors (better-call returns Error$1 | Data) + // Check for errors (better-call returns Error$1 | Data) if (isErrorResponse(response)) { const errorResponse = response as { error: unknown }; throw toError(errorResponse.error); } - // Type narrowed to Data after error check - return ((response as { data?: unknown }).data ?? + // Extract .items from the paginated response for infinite scroll compatibility + const dataResponse = response as { data?: { items?: unknown[] } }; + return (dataResponse.data?.items ?? []) as unknown as SerializedPost[]; } catch (error) { // Re-throw errors so React Query can catch them @@ -126,14 +127,14 @@ function createPostsQueries( query: { slug, limit: 1 }, headers, }); - // Check for errors (better-call returns Error$1 | Data) + // Check for errors (better-call returns Error$1 | Data) if (isErrorResponse(response)) { const errorResponse = response as { error: unknown }; throw toError(errorResponse.error); } - // Type narrowed to Data after error check - const dataResponse = response as { data?: unknown[] }; - return (dataResponse.data?.[0] ?? + // Type narrowed to Data after error check — access .items[0] + const dataResponse = response as { data?: { items?: unknown[] } }; + return (dataResponse.data?.items?.[0] ?? null) as unknown as SerializedPost | null; } catch (error) { // Re-throw errors so React Query can catch them @@ -181,13 +182,14 @@ function createPostsQueries( }, headers, }); - // Check for errors (better-call returns Error$1 | Data) + // Check for errors (better-call returns Error$1 | Data) if (isErrorResponse(response)) { const errorResponse = response as { error: unknown }; throw toError(errorResponse.error); } - // Type narrowed to Data after error check - let posts = ((response as { data?: unknown }).data ?? + // Extract .items from the paginated response + const recentResponse = response as { data?: { items?: unknown[] } }; + let posts = (recentResponse.data?.items ?? []) as unknown as SerializedPost[]; // Exclude current post if specified @@ -228,13 +230,14 @@ function createDraftsQueries( }, headers, }); - // Check for errors (better-call returns Error$1 | Data) + // Check for errors (better-call returns Error$1 | Data) if (isErrorResponse(response)) { const errorResponse = response as { error: unknown }; throw toError(errorResponse.error); } - // Type narrowed to Data after error check - return ((response as { data?: unknown }).data ?? + // Extract .items from the paginated response for infinite scroll compatibility + const draftsResponse = response as { data?: { items?: unknown[] } }; + return (draftsResponse.data?.items ?? []) as unknown as SerializedPost[]; } catch (error) { // Re-throw errors so React Query can catch them diff --git a/packages/stack/src/plugins/cms/__tests__/getters.test.ts b/packages/stack/src/plugins/cms/__tests__/getters.test.ts new file mode 100644 index 0000000..07c3b15 --- /dev/null +++ b/packages/stack/src/plugins/cms/__tests__/getters.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { Adapter } from "@btst/db"; +import { cmsSchema } from "../db"; +import { + getAllContentTypes, + getAllContentItems, + getContentItemBySlug, +} from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(cmsSchema); + return createMemoryAdapter(db)({}); +}; + +const SIMPLE_SCHEMA = JSON.stringify({ + type: "object", + properties: { + title: { type: "string" }, + }, + autoFormVersion: 2, +}); + +describe("cms getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + describe("getAllContentTypes", () => { + it("returns empty array when no content types exist", async () => { + const types = await getAllContentTypes(adapter); + expect(types).toEqual([]); + }); + + it("returns serialized content types sorted by name", async () => { + await adapter.create({ + model: "contentType", + data: { + name: "Post", + slug: "post", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "contentType", + data: { + name: "Article", + slug: "article", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const types = await getAllContentTypes(adapter); + expect(types).toHaveLength(2); + // Sorted by name + expect(types[0]!.slug).toBe("article"); + expect(types[1]!.slug).toBe("post"); + // Dates are serialized as strings + expect(typeof types[0]!.createdAt).toBe("string"); + }); + }); + + describe("getAllContentItems", () => { + it("returns empty result when content type does not exist", async () => { + const result = await getAllContentItems(adapter, "nonexistent"); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("returns items for a content type", async () => { + const ct = (await adapter.create({ + model: "contentType", + data: { + name: "Post", + slug: "post", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + })) as any; + + await adapter.create({ + model: "contentItem", + data: { + contentTypeId: ct.id, + slug: "my-post", + data: JSON.stringify({ title: "My Post" }), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const result = await getAllContentItems(adapter, "post"); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("my-post"); + expect(result.items[0]!.parsedData).toEqual({ title: "My Post" }); + }); + + it("filters items by slug", async () => { + const ct = (await adapter.create({ + model: "contentType", + data: { + name: "Post", + slug: "post", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + })) as any; + + await adapter.create({ + model: "contentItem", + data: { + contentTypeId: ct.id, + slug: "first", + data: JSON.stringify({ title: "First" }), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + await adapter.create({ + model: "contentItem", + data: { + contentTypeId: ct.id, + slug: "second", + data: JSON.stringify({ title: "Second" }), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const result = await getAllContentItems(adapter, "post", { + slug: "first", + }); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.slug).toBe("first"); + }); + }); + + describe("getContentItemBySlug", () => { + it("returns null when content type does not exist", async () => { + const item = await getContentItemBySlug(adapter, "nonexistent", "item"); + expect(item).toBeNull(); + }); + + it("returns null when item does not exist", async () => { + await adapter.create({ + model: "contentType", + data: { + name: "Post", + slug: "post", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const item = await getContentItemBySlug(adapter, "post", "nonexistent"); + expect(item).toBeNull(); + }); + + it("returns the serialized item when it exists", async () => { + const ct = (await adapter.create({ + model: "contentType", + data: { + name: "Post", + slug: "post", + jsonSchema: SIMPLE_SCHEMA, + autoFormVersion: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + })) as any; + + await adapter.create({ + model: "contentItem", + data: { + contentTypeId: ct.id, + slug: "hello", + data: JSON.stringify({ title: "Hello" }), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const item = await getContentItemBySlug(adapter, "post", "hello"); + expect(item).not.toBeNull(); + expect(item!.slug).toBe("hello"); + expect(item!.parsedData).toEqual({ title: "Hello" }); + expect(typeof item!.createdAt).toBe("string"); + }); + }); +}); diff --git a/packages/stack/src/plugins/cms/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts new file mode 100644 index 0000000..98ec1a3 --- /dev/null +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -0,0 +1,244 @@ +import type { Adapter } from "@btst/db"; +import type { + ContentType, + ContentItem, + ContentItemWithType, + SerializedContentType, + SerializedContentItem, + SerializedContentItemWithType, +} from "../types"; + +/** + * Serialize a ContentType for SSR/SSG use (convert dates to strings). + * Applies lazy migration for legacy schemas (version 1 → 2). + */ +export function serializeContentType(ct: ContentType): SerializedContentType { + const needsMigration = !ct.autoFormVersion || ct.autoFormVersion < 2; + const migratedJsonSchema = needsMigration + ? migrateToUnifiedSchema(ct.jsonSchema, ct.fieldConfig) + : ct.jsonSchema; + + return { + id: ct.id, + name: ct.name, + slug: ct.slug, + description: ct.description, + jsonSchema: migratedJsonSchema, + createdAt: ct.createdAt.toISOString(), + updatedAt: ct.updatedAt.toISOString(), + }; +} + +export function migrateToUnifiedSchema( + jsonSchemaStr: string, + fieldConfigStr: string | null | undefined, +): string { + if (!fieldConfigStr) return jsonSchemaStr; + try { + const jsonSchema = JSON.parse(jsonSchemaStr); + const fieldConfig = JSON.parse(fieldConfigStr); + if (!jsonSchema.properties || typeof fieldConfig !== "object") { + return jsonSchemaStr; + } + for (const [key, config] of Object.entries(fieldConfig)) { + if ( + jsonSchema.properties[key] && + typeof config === "object" && + config !== null && + "fieldType" in config + ) { + jsonSchema.properties[key].fieldType = ( + config as { fieldType: string } + ).fieldType; + } + } + return JSON.stringify(jsonSchema); + } catch { + return jsonSchemaStr; + } +} + +/** + * Serialize a ContentItem for SSR/SSG use (convert dates to strings). + */ +export function serializeContentItem(item: ContentItem): SerializedContentItem { + return { + ...item, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }; +} + +/** + * Serialize a ContentItem with parsed data and joined ContentType. + * Throws a SyntaxError if `item.data` is not valid JSON, so corrupted rows + * produce a visible, debuggable error rather than silently returning null. + */ +export function serializeContentItemWithType( + item: ContentItemWithType, +): SerializedContentItemWithType { + const parsedData = JSON.parse(item.data) as Record; + return { + ...serializeContentItem(item), + parsedData, + contentType: item.contentType + ? serializeContentType(item.contentType) + : undefined, + }; +} + +/** + * Retrieve all content types. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + */ +export async function getAllContentTypes( + adapter: Adapter, +): Promise { + const contentTypes = await adapter.findMany({ + model: "contentType", + sortBy: { field: "name", direction: "asc" }, + }); + return contentTypes.map(serializeContentType); +} + +/** + * Retrieve all content items for a given content type, with optional pagination. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks (e.g. `onBeforeListItems`) are NOT + * called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param contentTypeSlug - The slug of the content type to query + * @param params - Optional filter/pagination parameters + */ +export async function getAllContentItems( + adapter: Adapter, + contentTypeSlug: string, + params?: { slug?: string; limit?: number; offset?: number }, +): Promise<{ + items: SerializedContentItemWithType[]; + total: number; + limit?: number; + offset?: number; +}> { + const contentType = await adapter.findOne({ + model: "contentType", + where: [ + { + field: "slug", + value: contentTypeSlug, + operator: "eq" as const, + }, + ], + }); + + if (!contentType) { + return { + items: [], + total: 0, + limit: params?.limit, + offset: params?.offset, + }; + } + + const whereConditions: Array<{ + field: string; + value: string; + operator: "eq"; + }> = [ + { + field: "contentTypeId", + value: contentType.id, + operator: "eq" as const, + }, + ]; + + if (params?.slug) { + whereConditions.push({ + field: "slug", + value: params.slug, + operator: "eq" as const, + }); + } + + // TODO: remove cast once @btst/db types expose adapter.count() + const total: number = await adapter.count({ + model: "contentItem", + where: whereConditions, + }); + + const items = await adapter.findMany({ + model: "contentItem", + where: whereConditions, + limit: params?.limit, + offset: params?.offset, + sortBy: { field: "createdAt", direction: "desc" }, + join: { contentType: true }, + }); + + return { + items: items.map(serializeContentItemWithType), + total, + limit: params?.limit, + offset: params?.offset, + }; +} + +/** + * Retrieve a single content item by its slug within a content type. + * Returns null if the content type or item is not found. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param contentTypeSlug - The slug of the content type + * @param slug - The slug of the content item + */ +export async function getContentItemBySlug( + adapter: Adapter, + contentTypeSlug: string, + slug: string, +): Promise { + const contentType = await adapter.findOne({ + model: "contentType", + where: [ + { + field: "slug", + value: contentTypeSlug, + operator: "eq" as const, + }, + ], + }); + + if (!contentType) { + return null; + } + + const item = await adapter.findOne({ + model: "contentItem", + where: [ + { + field: "contentTypeId", + value: contentType.id, + operator: "eq" as const, + }, + { field: "slug", value: slug, operator: "eq" as const }, + ], + join: { contentType: true }, + }); + + if (!item) { + return null; + } + + return serializeContentItemWithType(item); +} diff --git a/packages/stack/src/plugins/cms/api/index.ts b/packages/stack/src/plugins/cms/api/index.ts index 863e05e..d6b7f12 100644 --- a/packages/stack/src/plugins/cms/api/index.ts +++ b/packages/stack/src/plugins/cms/api/index.ts @@ -1 +1,6 @@ export { cmsBackendPlugin, type CMSApiRouter } from "./plugin"; +export { + getAllContentTypes, + getAllContentItems, + getContentItemBySlug, +} from "./getters"; diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index b85c70b..e0f770a 100644 --- a/packages/stack/src/plugins/cms/api/plugin.ts +++ b/packages/stack/src/plugins/cms/api/plugin.ts @@ -14,8 +14,6 @@ import type { ContentRelation, CMSBackendConfig, CMSHookContext, - SerializedContentType, - SerializedContentItem, SerializedContentItemWithType, RelationConfig, RelationValue, @@ -23,97 +21,14 @@ import type { } from "../types"; import { listContentQuerySchema } from "../schemas"; import { slugify } from "../utils"; - -/** - * Migrate a legacy JSON Schema (version 1) to unified format (version 2) - * by merging fieldConfig values into the JSON Schema properties - */ -function migrateToUnifiedSchema( - jsonSchemaStr: string, - fieldConfigStr: string | null | undefined, -): string { - if (!fieldConfigStr) { - return jsonSchemaStr; - } - - try { - const jsonSchema = JSON.parse(jsonSchemaStr); - const fieldConfig = JSON.parse(fieldConfigStr); - - if (!jsonSchema.properties || typeof fieldConfig !== "object") { - return jsonSchemaStr; - } - - // Merge fieldType from fieldConfig into each property - for (const [key, config] of Object.entries(fieldConfig)) { - if ( - jsonSchema.properties[key] && - typeof config === "object" && - config !== null && - "fieldType" in config - ) { - jsonSchema.properties[key].fieldType = ( - config as { fieldType: string } - ).fieldType; - } - } - - return JSON.stringify(jsonSchema); - } catch { - // If parsing fails, return original - return jsonSchemaStr; - } -} - -/** - * Serialize a ContentType for API response (convert dates to strings) - * Also applies lazy migration for legacy schemas (version 1 → 2) - */ -function serializeContentType(ct: ContentType): SerializedContentType { - // Check if this is a legacy schema that needs migration - const needsMigration = !ct.autoFormVersion || ct.autoFormVersion < 2; - - // Apply lazy migration: merge fieldConfig into jsonSchema on read - const migratedJsonSchema = needsMigration - ? migrateToUnifiedSchema(ct.jsonSchema, ct.fieldConfig) - : ct.jsonSchema; - - return { - id: ct.id, - name: ct.name, - slug: ct.slug, - description: ct.description, - jsonSchema: migratedJsonSchema, - createdAt: ct.createdAt.toISOString(), - updatedAt: ct.updatedAt.toISOString(), - }; -} - -/** - * Serialize a ContentItem for API response (convert dates to strings) - */ -function serializeContentItem(item: ContentItem): SerializedContentItem { - return { - ...item, - createdAt: item.createdAt.toISOString(), - updatedAt: item.updatedAt.toISOString(), - }; -} - -/** - * Serialize a ContentItem with parsed data and joined ContentType - */ -function serializeContentItemWithType( - item: ContentItemWithType, -): SerializedContentItemWithType { - return { - ...serializeContentItem(item), - parsedData: JSON.parse(item.data), - contentType: item.contentType - ? serializeContentType(item.contentType) - : undefined, - }; -} +import { + getAllContentTypes, + getAllContentItems, + getContentItemBySlug, + serializeContentType, + serializeContentItem, + serializeContentItemWithType, +} from "./getters"; /** * Sync content types from config to database @@ -511,34 +426,52 @@ async function populateRelations( * * @param config - Configuration with content types and optional hooks */ -export const cmsBackendPlugin = (config: CMSBackendConfig) => - defineBackendPlugin({ +export const cmsBackendPlugin = (config: CMSBackendConfig) => { + // Shared sync state — used by both the api factory and routes handlers so + // that calling a getter before any HTTP request has been made still + // triggers the one-time content-type sync. + let syncPromise: Promise | null = null; + + const ensureSynced = (adapter: Adapter) => { + if (!syncPromise) { + syncPromise = syncContentTypes(adapter, config).catch((err) => { + // Allow retry on next call if sync fails + syncPromise = null; + throw err; + }); + } + return syncPromise; + }; + + return defineBackendPlugin({ name: "cms", dbPlugin: dbSchema, - routes: (adapter: Adapter) => { - // Sync content types on first request using promise-based lock - // This prevents race conditions when multiple concurrent requests arrive - // on cold start within the same instance - let syncPromise: Promise | null = null; - - const ensureSynced = async () => { - if (!syncPromise) { - syncPromise = syncContentTypes(adapter, config).catch((err) => { - // If sync fails, allow retry on next request - syncPromise = null; - throw err; - }); - } - await syncPromise; - }; + api: (adapter) => ({ + getAllContentTypes: async () => { + await ensureSynced(adapter); + return getAllContentTypes(adapter); + }, + getAllContentItems: async ( + contentTypeSlug: string, + params?: Parameters[2], + ) => { + await ensureSynced(adapter); + return getAllContentItems(adapter, contentTypeSlug, params); + }, + getContentItemBySlug: async (contentTypeSlug: string, slug: string) => { + await ensureSynced(adapter); + return getContentItemBySlug(adapter, contentTypeSlug, slug); + }, + }), + routes: (adapter: Adapter) => { // Helper to get content type by slug const getContentType = async ( slug: string, ): Promise => { - await ensureSynced(); + await ensureSynced(adapter); return adapter.findOne({ model: "contentType", where: [{ field: "slug", value: slug, operator: "eq" as const }], @@ -560,7 +493,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => "/content-types", { method: "GET" }, async (ctx) => { - await ensureSynced(); + await ensureSynced(adapter); const contentTypes = await adapter.findMany({ model: "contentType", @@ -627,45 +560,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => throw ctx.error(404, { message: "Content type not found" }); } - const whereConditions = [ - { - field: "contentTypeId", - value: contentType.id, - operator: "eq" as const, - }, - ]; - - if (slug) { - whereConditions.push({ - field: "slug", - value: slug, - operator: "eq" as const, - }); - } - - // Get total count - const allItems = await adapter.findMany({ - model: "contentItem", - where: whereConditions, - }); - const total = allItems.length; - - // Get paginated items - const items = await adapter.findMany({ - model: "contentItem", - where: whereConditions, - limit, - offset, - sortBy: { field: "createdAt", direction: "desc" }, - join: { contentType: true }, - }); - - return { - items: items.map(serializeContentItemWithType), - total, - limit, - offset, - }; + return getAllContentItems(adapter, typeSlug, { slug, limit, offset }); }, ); @@ -1139,7 +1034,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => const { slug } = ctx.params; const { itemId } = ctx.query; - await ensureSynced(); + await ensureSynced(adapter); // Get the target content type const targetContentType = await getContentType(slug); @@ -1239,7 +1134,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => const { slug, sourceType } = ctx.params; const { itemId, fieldName, limit, offset } = ctx.query; - await ensureSynced(); + await ensureSynced(adapter); // Verify target content type exists const targetContentType = await getContentType(slug); @@ -1317,6 +1212,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => }; }, }); +}; export type CMSApiRouter = ReturnType< ReturnType["routes"] diff --git a/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx b/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx index a52dc35..8684728 100644 --- a/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +++ b/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx @@ -241,7 +241,7 @@ export function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) { contentType={contentType} initialData={ isEditing - ? item?.parsedData + ? (item?.parsedData ?? undefined) : Object.keys(prefillParams).length > 0 ? convertPrefillToFormData( prefillParams, diff --git a/packages/stack/src/plugins/cms/types.ts b/packages/stack/src/plugins/cms/types.ts index f916a9f..9ad0159 100644 --- a/packages/stack/src/plugins/cms/types.ts +++ b/packages/stack/src/plugins/cms/types.ts @@ -178,7 +178,7 @@ export interface SerializedContentItem */ export interface SerializedContentItemWithType> extends SerializedContentItem { - /** Parsed data object (JSON.parse of data field) */ + /** Parsed data object (JSON.parse of data field). */ parsedData: TData; /** Joined content type */ contentType?: SerializedContentType; diff --git a/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts b/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts new file mode 100644 index 0000000..b49a790 --- /dev/null +++ b/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { Adapter } from "@btst/db"; +import { formBuilderSchema } from "../db"; +import { getAllForms, getFormBySlug, getFormSubmissions } from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(formBuilderSchema); + return createMemoryAdapter(db)({}); +}; + +const SIMPLE_SCHEMA = JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, +}); + +async function createForm( + adapter: Adapter, + slug: string, + status = "active", +): Promise { + return adapter.create({ + model: "form", + data: { + name: `Form ${slug}`, + slug, + schema: SIMPLE_SCHEMA, + status, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +describe("form-builder getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + describe("getAllForms", () => { + it("returns empty result when no forms exist", async () => { + const result = await getAllForms(adapter); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("returns all forms serialized", async () => { + await createForm(adapter, "contact"); + await createForm(adapter, "feedback"); + + const result = await getAllForms(adapter); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(2); + expect(typeof result.items[0]!.createdAt).toBe("string"); + }); + + it("filters forms by status", async () => { + await createForm(adapter, "active-form", "active"); + await createForm(adapter, "inactive-form", "inactive"); + + const active = await getAllForms(adapter, { status: "active" }); + expect(active.items).toHaveLength(1); + expect(active.items[0]!.slug).toBe("active-form"); + + const inactive = await getAllForms(adapter, { status: "inactive" }); + expect(inactive.items).toHaveLength(1); + expect(inactive.items[0]!.slug).toBe("inactive-form"); + }); + + it("respects limit and offset", async () => { + for (let i = 1; i <= 4; i++) { + await createForm(adapter, `form-${i}`); + } + + const page1 = await getAllForms(adapter, { limit: 2, offset: 0 }); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(4); + + const page2 = await getAllForms(adapter, { limit: 2, offset: 2 }); + expect(page2.items).toHaveLength(2); + }); + }); + + describe("getFormBySlug", () => { + it("returns null when form does not exist", async () => { + const form = await getFormBySlug(adapter, "nonexistent"); + expect(form).toBeNull(); + }); + + it("returns the form when it exists", async () => { + await createForm(adapter, "contact"); + + const form = await getFormBySlug(adapter, "contact"); + expect(form).not.toBeNull(); + expect(form!.slug).toBe("contact"); + expect(typeof form!.createdAt).toBe("string"); + }); + }); + + describe("getFormSubmissions", () => { + it("returns empty result when form does not exist", async () => { + const result = await getFormSubmissions(adapter, "nonexistent-id"); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("returns submissions for a form", async () => { + const form = (await createForm(adapter, "contact")) as any; + + await adapter.create({ + model: "formSubmission", + data: { + formId: form.id, + data: JSON.stringify({ name: "Alice" }), + submittedAt: new Date(), + }, + }); + await adapter.create({ + model: "formSubmission", + data: { + formId: form.id, + data: JSON.stringify({ name: "Bob" }), + submittedAt: new Date(), + }, + }); + + const result = await getFormSubmissions(adapter, form.id); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(2); + expect(typeof result.items[0]!.submittedAt).toBe("string"); + expect(result.items[0]!.parsedData).toBeDefined(); + }); + + it("respects pagination", async () => { + const form = (await createForm(adapter, "survey")) as any; + + for (let i = 1; i <= 5; i++) { + await adapter.create({ + model: "formSubmission", + data: { + formId: form.id, + data: JSON.stringify({ name: `User ${i}` }), + submittedAt: new Date(Date.now() + i * 1000), + }, + }); + } + + const page1 = await getFormSubmissions(adapter, form.id, { + limit: 2, + offset: 0, + }); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(5); + }); + }); +}); diff --git a/packages/stack/src/plugins/form-builder/api/getters.ts b/packages/stack/src/plugins/form-builder/api/getters.ts new file mode 100644 index 0000000..130e1b8 --- /dev/null +++ b/packages/stack/src/plugins/form-builder/api/getters.ts @@ -0,0 +1,203 @@ +import type { Adapter } from "@btst/db"; +import type { + Form, + FormSubmission, + FormSubmissionWithForm, + SerializedForm, + SerializedFormSubmission, + SerializedFormSubmissionWithData, +} from "../types"; + +/** + * Serialize a Form for SSR/SSG use (convert dates to strings). + */ +export function serializeForm(form: Form): SerializedForm { + return { + id: form.id, + name: form.name, + slug: form.slug, + description: form.description, + schema: form.schema, + successMessage: form.successMessage, + redirectUrl: form.redirectUrl, + status: form.status, + createdBy: form.createdBy, + createdAt: form.createdAt.toISOString(), + updatedAt: form.updatedAt.toISOString(), + }; +} + +/** + * Serialize a FormSubmission for SSR/SSG use (convert dates to strings). + */ +export function serializeFormSubmission( + submission: FormSubmission, +): SerializedFormSubmission { + return { + ...submission, + submittedAt: submission.submittedAt.toISOString(), + }; +} + +/** + * Serialize a FormSubmission with parsed data and joined Form. + * If `submission.data` is corrupted JSON, `parsedData` is set to `null` rather + * than throwing, so one bad row cannot crash the entire list or SSG build. + */ +export function serializeFormSubmissionWithData( + submission: FormSubmissionWithForm, +): SerializedFormSubmissionWithData { + let parsedData: Record | null = null; + try { + parsedData = JSON.parse(submission.data); + } catch { + // Corrupted JSON — leave parsedData as null so callers can handle it + } + return { + ...serializeFormSubmission(submission), + parsedData, + form: submission.form ? serializeForm(submission.form) : undefined, + }; +} + +/** + * Retrieve all forms with optional status filter and pagination. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks (e.g. `onBeforeListForms`) are NOT + * called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param params - Optional filter/pagination parameters + */ +export async function getAllForms( + adapter: Adapter, + params?: { status?: string; limit?: number; offset?: number }, +): Promise<{ + items: SerializedForm[]; + total: number; + limit?: number; + offset?: number; +}> { + const whereConditions: Array<{ + field: string; + value: string; + operator: "eq"; + }> = []; + + if (params?.status) { + whereConditions.push({ + field: "status", + value: params.status, + operator: "eq" as const, + }); + } + + // TODO: remove cast once @btst/db types expose adapter.count() + const total: number = await adapter.count({ + model: "form", + where: whereConditions.length > 0 ? whereConditions : undefined, + }); + + const forms = await adapter.findMany
({ + model: "form", + where: whereConditions.length > 0 ? whereConditions : undefined, + limit: params?.limit, + offset: params?.offset, + sortBy: { field: "createdAt", direction: "desc" }, + }); + + return { + items: forms.map(serializeForm), + total, + limit: params?.limit, + offset: params?.offset, + }; +} + +/** + * Retrieve a single form by its slug. + * Returns null if the form is not found. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param slug - The form slug + */ +export async function getFormBySlug( + adapter: Adapter, + slug: string, +): Promise { + const form = await adapter.findOne({ + model: "form", + where: [{ field: "slug", value: slug, operator: "eq" as const }], + }); + + if (!form) { + return null; + } + + return serializeForm(form); +} + +/** + * Retrieve submissions for a form by form ID, with optional pagination. + * Returns an empty result if the form does not exist. + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param formId - The form ID + * @param params - Optional pagination parameters + */ +export async function getFormSubmissions( + adapter: Adapter, + formId: string, + params?: { limit?: number; offset?: number }, +): Promise<{ + items: SerializedFormSubmissionWithData[]; + total: number; + limit?: number; + offset?: number; +}> { + const form = await adapter.findOne({ + model: "form", + where: [{ field: "id", value: formId, operator: "eq" as const }], + }); + + if (!form) { + return { + items: [], + total: 0, + limit: params?.limit, + offset: params?.offset, + }; + } + + // TODO: remove cast once @btst/db types expose adapter.count() + const total: number = await adapter.count({ + model: "formSubmission", + where: [{ field: "formId", value: formId, operator: "eq" as const }], + }); + + const submissions = await adapter.findMany({ + model: "formSubmission", + where: [{ field: "formId", value: formId, operator: "eq" as const }], + limit: params?.limit, + offset: params?.offset, + sortBy: { field: "submittedAt", direction: "desc" }, + join: { form: true }, + }); + + return { + items: submissions.map(serializeFormSubmissionWithData), + total, + limit: params?.limit, + offset: params?.offset, + }; +} diff --git a/packages/stack/src/plugins/form-builder/api/index.ts b/packages/stack/src/plugins/form-builder/api/index.ts index 00d5917..717ea1f 100644 --- a/packages/stack/src/plugins/form-builder/api/index.ts +++ b/packages/stack/src/plugins/form-builder/api/index.ts @@ -1 +1,2 @@ export { formBuilderBackendPlugin, type FormBuilderApiRouter } from "./plugin"; +export { getAllForms, getFormBySlug, getFormSubmissions } from "./getters"; diff --git a/packages/stack/src/plugins/form-builder/api/plugin.ts b/packages/stack/src/plugins/form-builder/api/plugin.ts index d01657a..e5ab431 100644 --- a/packages/stack/src/plugins/form-builder/api/plugin.ts +++ b/packages/stack/src/plugins/form-builder/api/plugin.ts @@ -11,9 +11,6 @@ import type { FormBuilderBackendConfig, FormBuilderHookContext, SubmissionHookContext, - SerializedForm, - SerializedFormSubmission, - SerializedFormSubmissionWithData, FormInput, FormUpdate, } from "../types"; @@ -24,50 +21,14 @@ import { listSubmissionsQuerySchema, } from "../schemas"; import { slugify, extractIpAddress, extractUserAgent } from "../utils"; - -/** - * Serialize a Form for API response (convert dates to strings) - */ -function serializeForm(form: Form): SerializedForm { - return { - id: form.id, - name: form.name, - slug: form.slug, - description: form.description, - schema: form.schema, - successMessage: form.successMessage, - redirectUrl: form.redirectUrl, - status: form.status, - createdBy: form.createdBy, - createdAt: form.createdAt.toISOString(), - updatedAt: form.updatedAt.toISOString(), - }; -} - -/** - * Serialize a FormSubmission for API response (convert dates to strings) - */ -function serializeFormSubmission( - submission: FormSubmission, -): SerializedFormSubmission { - return { - ...submission, - submittedAt: submission.submittedAt.toISOString(), - }; -} - -/** - * Serialize a FormSubmission with parsed data and joined Form - */ -function serializeFormSubmissionWithData( - submission: FormSubmissionWithForm, -): SerializedFormSubmissionWithData { - return { - ...serializeFormSubmission(submission), - parsedData: JSON.parse(submission.data), - form: submission.form ? serializeForm(submission.form) : undefined, - }; -} +import { + getAllForms, + getFormBySlug as getFormBySlugFromDb, + getFormSubmissions, + serializeForm, + serializeFormSubmission, + serializeFormSubmissionWithData, +} from "./getters"; /** * Form Builder backend plugin @@ -83,6 +44,16 @@ export const formBuilderBackendPlugin = ( dbPlugin: dbSchema, + api: (adapter) => ({ + getAllForms: (params?: Parameters[1]) => + getAllForms(adapter, params), + getFormBySlug: (slug: string) => getFormBySlugFromDb(adapter, slug), + getFormSubmissions: ( + formId: string, + params?: Parameters[2], + ) => getFormSubmissions(adapter, formId, params), + }), + routes: (adapter: Adapter) => { // Helper to create hook context from request const createContext = (headers?: Headers): FormBuilderHookContext => ({ @@ -114,7 +85,6 @@ export const formBuilderBackendPlugin = ( const { status, limit, offset } = ctx.query; const context = createContext(ctx.headers); - // Call before hook for auth check if (config.hooks?.onBeforeListForms) { const canList = await config.hooks.onBeforeListForms(context); if (!canList) { @@ -122,41 +92,7 @@ export const formBuilderBackendPlugin = ( } } - const whereConditions: Array<{ - field: string; - value: string; - operator: "eq"; - }> = []; - if (status) { - whereConditions.push({ - field: "status", - value: status, - operator: "eq" as const, - }); - } - - // Get total count - const allForms = await adapter.findMany({ - model: "form", - where: whereConditions.length > 0 ? whereConditions : undefined, - }); - const total = allForms.length; - - // Get paginated forms - const forms = await adapter.findMany({ - model: "form", - where: whereConditions.length > 0 ? whereConditions : undefined, - limit, - offset, - sortBy: { field: "createdAt", direction: "desc" }, - }); - - return { - items: forms.map(serializeForm), - total, - limit, - offset, - }; + return getAllForms(adapter, { status, limit, offset }); }, ); @@ -178,16 +114,13 @@ export const formBuilderBackendPlugin = ( } } - const form = await adapter.findOne({ - model: "form", - where: [{ field: "slug", value: slug, operator: "eq" as const }], - }); + const form = await getFormBySlugFromDb(adapter, slug); if (!form) { throw ctx.error(404, { message: "Form not found" }); } - return serializeForm(form); + return form; }, ); @@ -647,33 +580,7 @@ export const formBuilderBackendPlugin = ( } } - // Get total count - const allSubmissions = await adapter.findMany({ - model: "formSubmission", - where: [ - { field: "formId", value: formId, operator: "eq" as const }, - ], - }); - const total = allSubmissions.length; - - // Get paginated submissions - const submissions = await adapter.findMany({ - model: "formSubmission", - where: [ - { field: "formId", value: formId, operator: "eq" as const }, - ], - limit, - offset, - sortBy: { field: "submittedAt", direction: "desc" }, - join: { form: true }, - }); - - return { - items: submissions.map(serializeFormSubmissionWithData), - total, - limit, - offset, - }; + return getFormSubmissions(adapter, formId, { limit, offset }); }, ); diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx index aae797e..acf1b0d 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx @@ -147,7 +147,7 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { {sub.id.slice(0, 8)}... - {formatSubmissionData(sub.parsedData)} + {formatSubmissionData(sub.parsedData ?? {})} {new Date(sub.submittedAt).toLocaleString()} diff --git a/packages/stack/src/plugins/form-builder/types.ts b/packages/stack/src/plugins/form-builder/types.ts index 593da41..9ccf70e 100644 --- a/packages/stack/src/plugins/form-builder/types.ts +++ b/packages/stack/src/plugins/form-builder/types.ts @@ -82,8 +82,8 @@ export interface SerializedFormSubmission export interface SerializedFormSubmissionWithData< TData = Record, > extends SerializedFormSubmission { - /** Parsed data object (JSON.parse of data field) */ - parsedData: TData; + /** Parsed data object (JSON.parse of data field). Null when the stored JSON is corrupted. */ + parsedData: TData | null; /** Joined form */ form?: SerializedForm; } diff --git a/packages/stack/src/plugins/kanban/__tests__/getters.test.ts b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts new file mode 100644 index 0000000..0f61dab --- /dev/null +++ b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { Adapter } from "@btst/db"; +import { kanbanSchema } from "../db"; +import { getAllBoards, getBoardById } from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(kanbanSchema); + return createMemoryAdapter(db)({}); +}; + +async function createBoard( + adapter: Adapter, + name: string, + slug: string, + ownerId?: string, +): Promise { + return adapter.create({ + model: "kanbanBoard", + data: { + name, + slug, + ...(ownerId ? { ownerId } : {}), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function createColumn( + adapter: Adapter, + boardId: string, + title: string, + order: number, +): Promise { + return adapter.create({ + model: "kanbanColumn", + data: { + boardId, + title, + order, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function createTask( + adapter: Adapter, + columnId: string, + title: string, + order: number, +): Promise { + return adapter.create({ + model: "kanbanTask", + data: { + columnId, + title, + priority: "MEDIUM", + order, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +describe("kanban getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + describe("getAllBoards", () => { + it("returns empty array when no boards exist", async () => { + const { items, total } = await getAllBoards(adapter); + expect(items).toEqual([]); + expect(total).toBe(0); + }); + + it("returns all boards with columns and tasks", async () => { + const board = (await createBoard(adapter, "My Board", "my-board")) as any; + const col = (await createColumn(adapter, board.id, "To Do", 0)) as any; + await createTask(adapter, col.id, "Task 1", 0); + + const { items: boards, total } = await getAllBoards(adapter); + expect(boards).toHaveLength(1); + expect(total).toBe(1); + expect(boards[0]!.slug).toBe("my-board"); + expect(boards[0]!.columns).toHaveLength(1); + expect(boards[0]!.columns[0]!.title).toBe("To Do"); + expect(boards[0]!.columns[0]!.tasks).toHaveLength(1); + expect(boards[0]!.columns[0]!.tasks[0]!.title).toBe("Task 1"); + }); + + it("returns boards with empty columns array when no columns exist", async () => { + await createBoard(adapter, "Empty Board", "empty-board"); + + const { items: boards } = await getAllBoards(adapter); + expect(boards).toHaveLength(1); + expect(boards[0]!.columns).toEqual([]); + }); + + it("filters boards by slug", async () => { + await createBoard(adapter, "Board A", "board-a"); + await createBoard(adapter, "Board B", "board-b"); + + const { items, total } = await getAllBoards(adapter, { slug: "board-a" }); + expect(items).toHaveLength(1); + expect(total).toBe(1); + expect(items[0]!.slug).toBe("board-a"); + }); + + it("filters boards by ownerId", async () => { + await createBoard(adapter, "Alice Board", "alice-board", "user-alice"); + await createBoard(adapter, "Bob Board", "bob-board", "user-bob"); + + const { items, total } = await getAllBoards(adapter, { + ownerId: "user-alice", + }); + expect(items).toHaveLength(1); + expect(total).toBe(1); + expect(items[0]!.slug).toBe("alice-board"); + }); + + it("sorts columns by order", async () => { + const board = (await createBoard(adapter, "Board", "board")) as any; + // Create columns out of order + await createColumn(adapter, board.id, "Done", 2); + await createColumn(adapter, board.id, "To Do", 0); + await createColumn(adapter, board.id, "In Progress", 1); + + const { items: boards } = await getAllBoards(adapter); + expect(boards[0]!.columns[0]!.title).toBe("To Do"); + expect(boards[0]!.columns[1]!.title).toBe("In Progress"); + expect(boards[0]!.columns[2]!.title).toBe("Done"); + }); + }); + + describe("getBoardById", () => { + it("returns null when board does not exist", async () => { + const board = await getBoardById(adapter, "nonexistent"); + expect(board).toBeNull(); + }); + + it("returns the board with columns and tasks", async () => { + const board = (await createBoard(adapter, "My Board", "my-board")) as any; + const col1 = (await createColumn(adapter, board.id, "To Do", 0)) as any; + const col2 = (await createColumn(adapter, board.id, "Done", 1)) as any; + await createTask(adapter, col1.id, "Task A", 0); + await createTask(adapter, col1.id, "Task B", 1); + await createTask(adapter, col2.id, "Task C", 0); + + const result = await getBoardById(adapter, board.id); + expect(result).not.toBeNull(); + expect(result!.id).toBe(board.id); + expect(result!.columns).toHaveLength(2); + expect(result!.columns[0]!.tasks).toHaveLength(2); + expect(result!.columns[1]!.tasks).toHaveLength(1); + }); + + it("returns board with empty columns when no columns exist", async () => { + const board = (await createBoard(adapter, "Empty Board", "empty")) as any; + + const result = await getBoardById(adapter, board.id); + expect(result).not.toBeNull(); + expect(result!.columns).toEqual([]); + }); + }); +}); diff --git a/packages/stack/src/plugins/kanban/api/getters.ts b/packages/stack/src/plugins/kanban/api/getters.ts new file mode 100644 index 0000000..26ebcbe --- /dev/null +++ b/packages/stack/src/plugins/kanban/api/getters.ts @@ -0,0 +1,149 @@ +import type { Adapter } from "@btst/db"; +import type { + BoardWithKanbanColumn, + BoardWithColumns, + ColumnWithTasks, + Task, +} from "../types"; +import type { z } from "zod"; +import type { BoardListQuerySchema } from "../schemas"; + +/** + * Paginated result returned by {@link getAllBoards}. + */ +export interface BoardListResult { + items: BoardWithColumns[]; + total: number; + limit?: number; + offset?: number; +} + +/** + * Given a raw board record (with a `column` join), fetches tasks for every + * column in parallel and returns the sorted columns with their tasks attached. + * Strips the raw `column` join field from the returned board object. + */ +async function hydrateColumnsWithTasks( + adapter: Adapter, + board: BoardWithKanbanColumn, +): Promise { + const columnIds = (board.column || []).map((c) => c.id); + const tasksByColumn = new Map(); + + if (columnIds.length > 0) { + const taskResults = await Promise.all( + columnIds.map((columnId) => + adapter.findMany({ + model: "kanbanTask", + where: [ + { field: "columnId", value: columnId, operator: "eq" as const }, + ], + sortBy: { field: "order", direction: "asc" }, + }), + ), + ); + for (let i = 0; i < columnIds.length; i++) { + const columnId = columnIds[i]; + const tasks = taskResults[i]; + if (columnId && tasks) { + tasksByColumn.set(columnId, tasks); + } + } + } + + const columns: ColumnWithTasks[] = (board.column || []) + .sort((a, b) => a.order - b.order) + .map((col) => ({ ...col, tasks: tasksByColumn.get(col.id) || [] })); + + const { column: _, ...boardWithoutJoin } = board; + return { ...boardWithoutJoin, columns }; +} + +/** + * Retrieve all boards matching optional filter criteria, with columns and tasks. + * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @param adapter - The database adapter + * @param params - Optional filter/pagination parameters (same shape as the list API query) + */ +export async function getAllBoards( + adapter: Adapter, + params?: z.infer, +): Promise { + const query = params ?? {}; + + const whereConditions: Array<{ + field: string; + value: string; + operator: "eq"; + }> = []; + + if (query.slug) { + whereConditions.push({ + field: "slug", + value: query.slug, + operator: "eq" as const, + }); + } + + if (query.ownerId) { + whereConditions.push({ + field: "ownerId", + value: query.ownerId, + operator: "eq" as const, + }); + } + + if (query.organizationId) { + whereConditions.push({ + field: "organizationId", + value: query.organizationId, + operator: "eq" as const, + }); + } + + const where = whereConditions.length > 0 ? whereConditions : undefined; + + const [boards, total] = await Promise.all([ + adapter.findMany({ + model: "kanbanBoard", + limit: query.limit ?? 50, + offset: query.offset ?? 0, + where, + sortBy: { field: "createdAt", direction: "desc" }, + join: { kanbanColumn: true }, + }), + adapter.count({ model: "kanbanBoard", where }), + ]); + + const items = await Promise.all( + boards.map((board) => hydrateColumnsWithTasks(adapter, board)), + ); + + return { items, total, limit: query.limit, offset: query.offset }; +} + +/** + * Retrieve a single board by its ID, with all columns and tasks. + * Returns null if the board is not found. + * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @param adapter - The database adapter + * @param id - The board ID + */ +export async function getBoardById( + adapter: Adapter, + id: string, +): Promise { + const board = await adapter.findOne({ + model: "kanbanBoard", + where: [{ field: "id", value: id, operator: "eq" as const }], + join: { kanbanColumn: true }, + }); + + if (!board) { + return null; + } + + return hydrateColumnsWithTasks(adapter, board); +} diff --git a/packages/stack/src/plugins/kanban/api/index.ts b/packages/stack/src/plugins/kanban/api/index.ts index 9cce8d2..7da737f 100644 --- a/packages/stack/src/plugins/kanban/api/index.ts +++ b/packages/stack/src/plugins/kanban/api/index.ts @@ -4,3 +4,4 @@ export { type KanbanApiContext, type KanbanBackendHooks, } from "./plugin"; +export { getAllBoards, getBoardById, type BoardListResult } from "./getters"; diff --git a/packages/stack/src/plugins/kanban/api/plugin.ts b/packages/stack/src/plugins/kanban/api/plugin.ts index 15e1f42..0e0674d 100644 --- a/packages/stack/src/plugins/kanban/api/plugin.ts +++ b/packages/stack/src/plugins/kanban/api/plugin.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { kanbanSchema as dbSchema } from "../db"; import type { Board, - BoardWithKanbanColumn, + BoardWithColumns, Column, ColumnWithTasks, Task, @@ -23,6 +23,7 @@ import { updateColumnSchema, updateTaskSchema, } from "../schemas"; +import { getAllBoards, getBoardById } from "./getters"; /** * Context passed to kanban API hooks @@ -84,10 +85,12 @@ export interface KanbanBackendHooks { ) => Promise | boolean; /** - * Called after boards are listed successfully + * Called after boards are listed successfully. + * Receives the items array (same shape as `board[]`) for consistency + * with analogous hooks in other plugins (e.g. `onPostsRead`). */ onBoardsRead?: ( - boards: Board[], + boards: BoardWithColumns[], filter: z.infer, context: KanbanApiContext, ) => Promise | void; @@ -261,6 +264,12 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => dbPlugin: dbSchema, + api: (adapter) => ({ + getAllBoards: (params?: Parameters[1]) => + getAllBoards(adapter, params), + getBoardById: (id: string) => getBoardById(adapter, id), + }), + routes: (adapter: Adapter) => { // ============ Board Endpoints ============ @@ -284,100 +293,10 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => } } - const whereConditions = []; - - if (query.slug) { - whereConditions.push({ - field: "slug", - value: query.slug, - operator: "eq" as const, - }); - } - - if (query.ownerId) { - whereConditions.push({ - field: "ownerId", - value: query.ownerId, - operator: "eq" as const, - }); - } - - if (query.organizationId) { - whereConditions.push({ - field: "organizationId", - value: query.organizationId, - operator: "eq" as const, - }); - } - - const boards = await adapter.findMany({ - model: "kanbanBoard", - limit: query.limit ?? 50, - offset: query.offset ?? 0, - where: whereConditions, - sortBy: { - field: "createdAt", - direction: "desc", - }, - join: { - kanbanColumn: true, - }, - }); - - // Get all column IDs to fetch tasks - // Note: adapter returns joined data under schema key name ("column"), not model name - const columnIds: string[] = []; - for (const board of boards) { - if (board.column) { - for (const col of board.column) { - columnIds.push(col.id); - } - } - } - - // Fetch tasks for each column in parallel (avoids loading all tasks from DB) - const tasksByColumn = new Map(); - if (columnIds.length > 0) { - const taskQueries = columnIds.map((columnId) => - adapter.findMany({ - model: "kanbanTask", - where: [ - { - field: "columnId", - value: columnId, - operator: "eq" as const, - }, - ], - sortBy: { field: "order", direction: "asc" }, - }), - ); - const taskResults = await Promise.all(taskQueries); - for (let i = 0; i < columnIds.length; i++) { - const columnId = columnIds[i]; - const tasks = taskResults[i]; - if (columnId && tasks) { - tasksByColumn.set(columnId, tasks); - } - } - } - - // Map boards with columns and tasks - const result = boards.map((board) => { - const columns = (board.column || []) - .sort((a, b) => a.order - b.order) - .map((col) => ({ - ...col, - tasks: tasksByColumn.get(col.id) || [], - })); - const { column: _, ...boardWithoutJoin } = board; - return { - ...boardWithoutJoin, - columns, - }; - }); + const result = await getAllBoards(adapter, query); if (hooks?.onBoardsRead) { - await hooks.onBoardsRead(result, query, context); + await hooks.onBoardsRead(result.items, query, context); } return result; @@ -409,61 +328,12 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => } } - const board = await adapter.findOne({ - model: "kanbanBoard", - where: [ - { field: "id", value: params.id, operator: "eq" as const }, - ], - join: { - kanbanColumn: true, - }, - }); + const result = await getBoardById(adapter, params.id); - if (!board) { + if (!result) { throw ctx.error(404, { message: "Board not found" }); } - // Fetch tasks for each column in parallel (avoids loading all tasks from DB) - // Note: adapter returns joined data under schema key name ("column"), not model name - const columnIds = (board.column || []).map((c) => c.id); - const tasksByColumn = new Map(); - if (columnIds.length > 0) { - const taskQueries = columnIds.map((columnId) => - adapter.findMany({ - model: "kanbanTask", - where: [ - { - field: "columnId", - value: columnId, - operator: "eq" as const, - }, - ], - sortBy: { field: "order", direction: "asc" }, - }), - ); - const taskResults = await Promise.all(taskQueries); - for (let i = 0; i < columnIds.length; i++) { - const columnId = columnIds[i]; - const tasks = taskResults[i]; - if (columnId && tasks) { - tasksByColumn.set(columnId, tasks); - } - } - } - - const columns = (board.column || []) - .sort((a, b) => a.order - b.order) - .map((col) => ({ - ...col, - tasks: tasksByColumn.get(col.id) || [], - })); - - const { column: _, ...boardWithoutJoin } = board; - const result = { - ...boardWithoutJoin, - columns, - }; - if (hooks?.onBoardRead) { await hooks.onBoardRead(result, context); } diff --git a/packages/stack/src/plugins/kanban/client/plugin.tsx b/packages/stack/src/plugins/kanban/client/plugin.tsx index f176145..27416bc 100644 --- a/packages/stack/src/plugins/kanban/client/plugin.tsx +++ b/packages/stack/src/plugins/kanban/client/plugin.tsx @@ -437,7 +437,8 @@ export const kanbanClientPlugin = (config: KanbanClientConfig) => method: "GET", query: { limit: 100 }, }); - boards = ((res as { data?: unknown }).data ?? + // /boards returns BoardListResult { items, total, limit, offset } + boards = ((res.data as any)?.items ?? []) as SerializedBoardWithColumns[]; } catch { // Ignore errors for sitemap diff --git a/packages/stack/src/plugins/kanban/query-keys.ts b/packages/stack/src/plugins/kanban/query-keys.ts index ae2a04f..e21feac 100644 --- a/packages/stack/src/plugins/kanban/query-keys.ts +++ b/packages/stack/src/plugins/kanban/query-keys.ts @@ -91,8 +91,10 @@ function createBoardsQueries( throw toError(errorResponse.error); } - return ((response as { data?: unknown }).data ?? - []) as unknown as SerializedBoardWithColumns[]; + const envelope = (response as { data?: unknown }).data as + | { items?: SerializedBoardWithColumns[] } + | undefined; + return envelope?.items ?? ([] as SerializedBoardWithColumns[]); } catch (error) { throw error; } @@ -142,9 +144,10 @@ function createBoardsQueries( throw toError(errorResponse.error); } - const boards = ((response as { data?: unknown }).data ?? - []) as unknown as SerializedBoardWithColumns[]; - return boards[0] ?? null; + const envelope = (response as { data?: unknown }).data as + | { items?: SerializedBoardWithColumns[] } + | undefined; + return envelope?.items?.[0] ?? null; } catch (error) { throw error; } diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 08f03d2..673f791 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -8,7 +8,7 @@ import type { Endpoint, Router } from "better-call"; */ export interface StackContext { /** All registered backend plugins */ - plugins: Record>; + plugins: Record>; /** The API base path (e.g., "/api/data") */ basePath: string; /** The database adapter */ @@ -40,9 +40,13 @@ export interface ClientStackContext< * You can optionally provide a base schema via the dbSchema config option. * * @template TRoutes - The exact shape of routes this plugin provides (preserves keys and endpoint types) + * @template TApi - The shape of the server-side API surface exposed via `stack().api`. + * Defaults to `never` so that plugins without an `api` factory are excluded from the + * `stack().api` namespace entirely, preventing accidental access of `undefined` at runtime. */ export interface BackendPlugin< TRoutes extends Record = Record, + TApi extends Record any> = never, > { name: string; @@ -56,6 +60,15 @@ export interface BackendPlugin< */ routes: (adapter: Adapter, context?: StackContext) => TRoutes; dbPlugin: DbPlugin; + + /** + * Optional factory that returns server-side getter functions bound to the adapter. + * The returned object is merged into `stack().api..*` for direct + * server-side or SSG data access without going through HTTP. + * + * @param adapter - The adapter instance shared with `routes` + */ + api?: (adapter: Adapter) => TApi; } /** @@ -87,13 +100,30 @@ export interface ClientPlugin< sitemap?: () => Promise | Sitemap; } +/** + * Utility type that maps each plugin key to the return type of its `api` factory. + * Plugin keys whose `TApi` resolves to `never` (i.e. plugins with no `api` factory) + * are excluded from the resulting type via key remapping, preventing TypeScript from + * suggesting callable functions on what is actually `undefined` at runtime. + */ +export type PluginApis< + TPlugins extends Record>, +> = { + [K in keyof TPlugins as _ApiOf extends never + ? never + : K]: _ApiOf; +}; + +/** @internal Extract the TApi parameter from a BackendPlugin type. */ +type _ApiOf = T extends BackendPlugin ? TApi : never; + /** * Configuration for creating the backend library */ export interface BackendLibConfig< - TPlugins extends Record> = Record< + TPlugins extends Record> = Record< string, - BackendPlugin + BackendPlugin >, > { basePath: string; @@ -150,11 +180,12 @@ export type PluginRoutes< * Example: { messages: { list: Endpoint } } => { messages_list: Endpoint } */ export type PrefixedPluginRoutes< - TPlugins extends Record>, + TPlugins extends Record>, > = UnionToIntersection< { [PluginKey in keyof TPlugins]: TPlugins[PluginKey] extends BackendPlugin< - infer TRoutes + infer TRoutes, + any > ? { [RouteKey in keyof TRoutes as `${PluginKey & string}_${RouteKey & string}`]: TRoutes[RouteKey]; @@ -172,10 +203,18 @@ export type PrefixedPluginRoutes< */ export interface BackendLib< TRoutes extends Record = Record, + TApis extends Record< + string, + Record any> + > = Record any>>, > { handler: (request: Request) => Promise; // API route handler router: Router; // Better-call router dbSchema: DatabaseDefinition; // Better-db schema + /** The database adapter shared across all plugins */ + adapter: Adapter; + /** Fully-typed server-side getter functions, namespaced per plugin */ + api: TApis; } /**