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