Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
58e176c
feat: add server-side data access patterns and getter functions for p…
olliethedev Feb 19, 2026
0215e69
refactor: remove duplicated code
olliethedev Feb 20, 2026
19c9349
refactor: update BackendPlugin type to exclude plugins without an api…
olliethedev Feb 20, 2026
bd141c9
feat: implement CRUD operations for todos with server-side getters
olliethedev Feb 20, 2026
5444d73
feat: enhance blog API with paginated results and authorization warni…
olliethedev Feb 20, 2026
221a11b
refactor: remove redundant code
olliethedev Feb 20, 2026
63d48be
refactor: improve error handling for JSON parsing in content serializ…
olliethedev Feb 20, 2026
e7bfbbf
refactor: streamline blog API response handling and improve null safe…
olliethedev Feb 20, 2026
ddc5cd3
refactor: improve error handling in content serialization by throwing…
olliethedev Feb 20, 2026
c951b07
refactor: simplify task fetching logic by introducing a helper
olliethedev Feb 20, 2026
bc4073a
feat: blog performance improvements
olliethedev Feb 20, 2026
82da42b
refactor: enhance tag handling in blog API to return empty results fo…
olliethedev Feb 20, 2026
a17840b
refactor: optimize getPostBySlug to improve tag resolution and handle…
olliethedev Feb 20, 2026
0a80c9d
refactor: update blog API tests and client to handle PostListResult s…
olliethedev Feb 20, 2026
8106495
refactor: update blog API documentation
olliethedev Feb 20, 2026
0dd81d1
refactor: make getAllPosts functionality consistent with other plugin…
olliethedev Feb 20, 2026
83bc7c9
refactor: update kanban API to return paginated results for boards wi…
olliethedev Feb 20, 2026
abcad84
refactor: update getAllBoards test to validate paginated results stru…
olliethedev Feb 20, 2026
141e597
refactor: update kanban API to ensure consistency in onBoardsRead hook
olliethedev Feb 20, 2026
72ddce7
refactor: update kanban API response handling to correctly extract bo…
olliethedev Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/content/docs/plugins/ai-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -929,3 +929,43 @@ overrides={{
#### AiChatLocalization

<AutoTypeTable path="../packages/stack/src/plugins/ai-chat/client/localization/index.ts" name="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` |
54 changes: 54 additions & 0 deletions docs/content/docs/plugins/blog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,57 @@ You can import the hooks from `"@btst/stack/plugins/blog/client/hooks"` to use i
#### PostUpdateInput

<AutoTypeTable path="../packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx" name="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`

<AutoTypeTable path="../packages/stack/src/plugins/blog/api/getters.ts" name="PostListParams" />

### `PostListResult`

<AutoTypeTable path="../packages/stack/src/plugins/blog/api/getters.ts" name="PostListResult" />
39 changes: 39 additions & 0 deletions docs/content/docs/plugins/cms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1248,3 +1248,42 @@ const result = zodSchema.safeParse(data)
<AutoTypeTable path="../packages/ui/src/lib/schema-converter.ts" name="FormStep" />

<AutoTypeTable path="../packages/ui/src/lib/schema-converter.ts" name="FormSchemaMetadata" />

## 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` |
167 changes: 162 additions & 5 deletions docs/content/docs/plugins/development.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

<Callout type="warn">
**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.
</Callout>

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<Todo>({ model: "todo", sortBy: { field: "createdAt", direction: "desc" } }),

getTodoById: (id: string) =>
adapter.findOne<Todo>({ 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<Todo>({ 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<Todo>({ 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Todo[]> {
return adapter.findMany<Todo>({
model: "todo",
sortBy: { field: "createdAt", direction: "desc" },
}) as Promise<Todo[]>
}

/** Retrieve a single todo by ID. Returns null if not found. */
export async function getTodoById(
adapter: Adapter,
id: string,
): Promise<Todo | null> {
return adapter.findOne<Todo>({
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<Todo>({ model: "todo" }) || []
Expand All @@ -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
}
})

Expand Down Expand Up @@ -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
Expand Down
Loading