From 58e176c2ec419f4186dcf36d502842822e5dc708 Mon Sep 17 00:00:00 2001
From: olliethedev <3martynov@gmail.com>
Date: Thu, 19 Feb 2026 17:07:51 -0500
Subject: [PATCH 01/20] feat: add server-side data access patterns and getter
functions for plugins
---
docs/content/docs/plugins/ai-chat.mdx | 40 +++
docs/content/docs/plugins/blog.mdx | 45 +++
docs/content/docs/plugins/cms.mdx | 39 +++
docs/content/docs/plugins/development.mdx | 107 ++++++-
docs/content/docs/plugins/form-builder.mdx | 40 +++
docs/content/docs/plugins/kanban.mdx | 45 +++
packages/stack/package.json | 2 +-
.../stack/src/__tests__/stack-api.test.ts | 118 ++++++++
packages/stack/src/api/index.ts | 16 +-
.../plugins/ai-chat/__tests__/getters.test.ts | 109 +++++++
.../stack/src/plugins/ai-chat/api/getters.ts | 71 +++++
.../stack/src/plugins/ai-chat/api/index.ts | 1 +
.../stack/src/plugins/ai-chat/api/plugin.ts | 8 +
packages/stack/src/plugins/api/index.ts | 7 +-
.../plugins/blog/__tests__/getters.test.ts | 275 ++++++++++++++++++
.../stack/src/plugins/blog/api/getters.ts | 182 ++++++++++++
packages/stack/src/plugins/blog/api/index.ts | 1 +
packages/stack/src/plugins/blog/api/plugin.ts | 148 +---------
.../src/plugins/cms/__tests__/getters.test.ts | 206 +++++++++++++
packages/stack/src/plugins/cms/api/getters.ts | 231 +++++++++++++++
packages/stack/src/plugins/cms/api/index.ts | 5 +
packages/stack/src/plugins/cms/api/plugin.ts | 68 +++--
.../form-builder/__tests__/getters.test.ts | 159 ++++++++++
.../src/plugins/form-builder/api/getters.ts | 185 ++++++++++++
.../src/plugins/form-builder/api/index.ts | 1 +
.../src/plugins/form-builder/api/plugin.ts | 11 +
.../plugins/kanban/__tests__/getters.test.ts | 166 +++++++++++
.../stack/src/plugins/kanban/api/getters.ts | 168 +++++++++++
.../stack/src/plugins/kanban/api/index.ts | 1 +
.../stack/src/plugins/kanban/api/plugin.ts | 160 +---------
packages/stack/src/types.ts | 45 ++-
31 files changed, 2341 insertions(+), 319 deletions(-)
create mode 100644 packages/stack/src/__tests__/stack-api.test.ts
create mode 100644 packages/stack/src/plugins/ai-chat/__tests__/getters.test.ts
create mode 100644 packages/stack/src/plugins/ai-chat/api/getters.ts
create mode 100644 packages/stack/src/plugins/blog/__tests__/getters.test.ts
create mode 100644 packages/stack/src/plugins/blog/api/getters.ts
create mode 100644 packages/stack/src/plugins/cms/__tests__/getters.test.ts
create mode 100644 packages/stack/src/plugins/cms/api/getters.ts
create mode 100644 packages/stack/src/plugins/form-builder/__tests__/getters.test.ts
create mode 100644 packages/stack/src/plugins/form-builder/api/getters.ts
create mode 100644 packages/stack/src/plugins/kanban/__tests__/getters.test.ts
create mode 100644 packages/stack/src/plugins/kanban/api/getters.ts
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..271fdb9 100644
--- a/docs/content/docs/plugins/blog.mdx
+++ b/docs/content/docs/plugins/blog.mdx
@@ -527,3 +527,48 @@ 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 posts = await myStack.api.blog.getAllPosts({ published: true });
+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 posts = await getAllPosts(myAdapter, { published: true });
+ return posts.map((p) => ({ slug: p.slug }));
+}
+```
+
+### Available getters
+
+| Function | Description |
+|---|---|
+| `getAllPosts(adapter, params?)` | Returns all posts matching optional filter/pagination params |
+| `getPostBySlug(adapter, slug)` | Returns a single post by slug, or `null` if not found |
+| `getAllTags(adapter)` | Returns all tags |
+
+### `PostListParams`
+
+
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..b57b8d0 100644
--- a/docs/content/docs/plugins/development.mdx
+++ b/docs/content/docs/plugins/development.mdx
@@ -296,6 +296,90 @@ 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**.
+
+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,7 +693,7 @@ 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 const myStack = stack({
basePath: "/api/data",
plugins: {
todos: todosBackendPlugin,
@@ -627,7 +711,17 @@ const { handler, dbSchema } = stack({
adapter: (db) => createMemoryAdapter(db)({})
})
-export { handler, dbSchema }
+// myStack exposes:
+// .handler — HTTP route handler
+// .dbSchema — Better-db schema
+// .adapter — Raw database adapter
+// .api — Typed server-side getters per plugin
+//
+// Usage in a Server Component or generateStaticParams:
+// const todos = await myStack.api.todos.listTodos()
+// const posts = await myStack.api.blog.getAllPosts({ published: true })
+
+export const { handler, dbSchema } = myStack
```
### Client Registration
@@ -726,6 +820,15 @@ const createTodoSchema = z.object({
export const todosBackendPlugin = defineBackendPlugin({
name: "todos",
dbPlugin: dbSchema,
+
+ // Server-side getters — available as myStack.api.todos.*
+ api: (adapter) => ({
+ listTodos: () =>
+ adapter.findMany({ model: "todo", sortBy: { field: "createdAt", direction: "desc" } }) as Promise,
+ getTodoById: (id: string) =>
+ adapter.findOne({ model: "todo", where: [{ field: "id", value: id, operator: "eq" }] }),
+ }),
+
routes: (adapter: Adapter) => {
const listTodos = createEndpoint("/todos", { method: "GET" },
async () => adapter.findMany({ model: "todo" }) || []
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..6017a40 100644
--- a/docs/content/docs/plugins/kanban.mdx
+++ b/docs/content/docs/plugins/kanban.mdx
@@ -726,3 +726,48 @@ 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 boards = await myStack.api.kanban.getAllBoards({ ownerId: "user-123" });
+
+// 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 boards = await getAllBoards(myAdapter);
+ return boards.map((b) => ({ slug: b.slug }));
+}
+```
+
+### Available getters
+
+| Function | Description |
+|---|---|
+| `getAllBoards(adapter, params?)` | Returns all boards with columns and tasks; supports slug/ownerId/organizationId filters |
+| `getBoardById(adapter, id)` | Returns a single board with full column/task tree, or `null` |
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..5f8fbf0
--- /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).toHaveLength(1);
+ expect(posts[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..4ecc3b4 100644
--- a/packages/stack/src/plugins/api/index.ts
+++ b/packages/stack/src/plugins/api/index.ts
@@ -42,9 +42,14 @@ 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> = Record<
+ string,
+ (...args: any[]) => any
+ >,
+>(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..bfbdde4
--- /dev/null
+++ b/packages/stack/src/plugins/blog/__tests__/getters.test.ts
@@ -0,0 +1,275 @@
+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 array when no posts exist", async () => {
+ const posts = await getAllPosts(adapter);
+ expect(posts).toEqual([]);
+ });
+
+ 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 posts = await getAllPosts(adapter);
+ expect(posts).toHaveLength(1);
+ expect(posts[0]!.slug).toBe("hello-world");
+ expect(posts[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).toHaveLength(1);
+ expect(published[0]!.slug).toBe("published");
+
+ const drafts = await getAllPosts(adapter, { published: false });
+ expect(drafts).toHaveLength(1);
+ expect(drafts[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).toHaveLength(1);
+ expect(result[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).toHaveLength(1);
+ expect(result[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).toHaveLength(2);
+
+ const page2 = await getAllPosts(adapter, { limit: 2, offset: 2 });
+ expect(page2).toHaveLength(2);
+
+ // Pages should be different posts
+ expect(page1[0]!.slug).not.toBe(page2[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 posts = await getAllPosts(adapter);
+ expect(posts[0]!.tags).toHaveLength(1);
+ expect(posts[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).toEqual([]);
+ });
+ });
+
+ 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", 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.map((t) => t.slug).sort()).toEqual(["react", "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..313f14e
--- /dev/null
+++ b/packages/stack/src/plugins/blog/api/getters.ts
@@ -0,0 +1,182 @@
+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;
+}
+
+/**
+ * Retrieve all posts matching optional filter criteria.
+ * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use.
+ *
+ * @param adapter - The database adapter
+ * @param params - Optional filter/pagination parameters (same shape as the list API query)
+ */
+export async function getAllPosts(
+ adapter: Adapter,
+ params?: PostListParams,
+): Promise> {
+ const query = params ?? {};
+
+ let tagFilterPostIds: Set | null = null;
+
+ if (query.tagSlug) {
+ const tag = await adapter.findOne({
+ model: "tag",
+ where: [
+ {
+ field: "slug",
+ value: query.tagSlug,
+ operator: "eq" as const,
+ },
+ ],
+ });
+
+ if (!tag) {
+ return [];
+ }
+
+ const postTags = await adapter.findMany<{ postId: string; tagId: string }>({
+ model: "postTag",
+ where: [
+ {
+ field: "tagId",
+ value: tag.id,
+ operator: "eq" as const,
+ },
+ ],
+ });
+ 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
+ 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);
+ }
+ }
+
+ // Map tags to posts
+ 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);
+ }
+
+ return result;
+}
+
+/**
+ * Retrieve a single post by its slug, including associated tags.
+ * Returns null if no post is found.
+ *
+ * @param adapter - The database adapter
+ * @param slug - The post slug
+ */
+export async function getPostBySlug(
+ adapter: Adapter,
+ slug: string,
+): Promise<(Post & { tags: Tag[] }) | null> {
+ const results = await getAllPosts(adapter, { slug });
+ return results[0] ?? null;
+}
+
+/**
+ * Retrieve all tags.
+ *
+ * @param adapter - The database adapter
+ */
+export async function getAllTags(adapter: Adapter): Promise {
+ return adapter.findMany({
+ model: "tag",
+ });
+}
diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts
index f05c7f7..249a81d 100644
--- a/packages/stack/src/plugins/blog/api/index.ts
+++ b/packages/stack/src/plugins/blog/api/index.ts
@@ -1,2 +1,3 @@
export * from "./plugin";
+export { getAllPosts, getPostBySlug, getAllTags } 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..9e33217 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(),
@@ -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,141 +273,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
}
}
- let tagFilterPostIds: Set | null = null;
-
- if (query.tagSlug) {
- const tag = await adapter.findOne({
- model: "tag",
- where: [
- {
- field: "slug",
- value: query.tagSlug,
- operator: "eq" as const,
- },
- ],
- });
-
- if (!tag) {
- return [];
- }
-
- const postTags = await adapter.findMany<{
- postId: string;
- tagId: string;
- }>({
- model: "postTag",
- where: [
- {
- field: "tagId",
- value: tag.id,
- operator: "eq" as const,
- },
- ],
- });
- 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);
- }
- }
-
- // 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);
@@ -806,9 +680,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/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..0ca049d
--- /dev/null
+++ b/packages/stack/src/plugins/cms/api/getters.ts
@@ -0,0 +1,231 @@
+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).
+ */
+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(),
+ };
+}
+
+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).
+ */
+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,
+ };
+}
+
+/**
+ * Retrieve all content types.
+ * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use.
+ *
+ * @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.
+ *
+ * @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,
+ });
+ }
+
+ const allItems = await adapter.findMany({
+ model: "contentItem",
+ where: whereConditions,
+ });
+ const total = allItems.length;
+
+ 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.
+ *
+ * @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..bb97f9e 100644
--- a/packages/stack/src/plugins/cms/api/plugin.ts
+++ b/packages/stack/src/plugins/cms/api/plugin.ts
@@ -23,6 +23,11 @@ import type {
} from "../types";
import { listContentQuerySchema } from "../schemas";
import { slugify } from "../utils";
+import {
+ getAllContentTypes,
+ getAllContentItems,
+ getContentItemBySlug,
+} from "./getters";
/**
* Migrate a legacy JSON Schema (version 1) to unified format (version 2)
@@ -511,34 +516,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 +583,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
"/content-types",
{ method: "GET" },
async (ctx) => {
- await ensureSynced();
+ await ensureSynced(adapter);
const contentTypes = await adapter.findMany({
model: "contentType",
@@ -1139,7 +1162,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 +1262,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 +1340,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
};
},
});
+};
export type CMSApiRouter = ReturnType<
ReturnType["routes"]
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..1c171d1
--- /dev/null
+++ b/packages/stack/src/plugins/form-builder/api/getters.ts
@@ -0,0 +1,185 @@
+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).
+ */
+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).
+ */
+function serializeFormSubmission(
+ submission: FormSubmission,
+): SerializedFormSubmission {
+ return {
+ ...submission,
+ submittedAt: submission.submittedAt.toISOString(),
+ };
+}
+
+/**
+ * Serialize a FormSubmission with parsed data and joined Form.
+ */
+function serializeFormSubmissionWithData(
+ submission: FormSubmissionWithForm,
+): SerializedFormSubmissionWithData {
+ return {
+ ...serializeFormSubmission(submission),
+ parsedData: JSON.parse(submission.data),
+ form: submission.form ? serializeForm(submission.form) : undefined,
+ };
+}
+
+/**
+ * Retrieve all forms with optional status filter and pagination.
+ * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use.
+ *
+ * @param adapter - The database adapter
+ * @param params - Optional filter/pagination parameters
+ */
+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,
+ });
+ }
+
+ const allForms = await adapter.findMany