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
({ + model: "form", + where: whereConditions.length > 0 ? whereConditions : undefined, + }); + const total = allForms.length; + + const forms = await adapter.findMany({ + model: "form", + where: whereConditions.length > 0 ? whereConditions : undefined, + limit: params?.limit, + offset: params?.offset, + sortBy: { field: "createdAt", direction: "desc" }, + }); + + return { + items: forms.map(serializeForm), + total, + limit: params?.limit, + offset: params?.offset, + }; +} + +/** + * Retrieve a single form by its slug. + * Returns null if the form is not found. + * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @param adapter - The database adapter + * @param slug - The form slug + */ +export async function getFormBySlug( + adapter: Adapter, + slug: string, +): Promise { + const form = await adapter.findOne({ + model: "form", + where: [{ field: "slug", value: slug, operator: "eq" as const }], + }); + + if (!form) { + return null; + } + + return serializeForm(form); +} + +/** + * Retrieve submissions for a form by form ID, with optional pagination. + * Returns an empty result if the form does not exist. + * Pure DB function - no hooks, no HTTP context. Safe for server-side use. + * + * @param adapter - The database adapter + * @param formId - The form ID + * @param params - Optional pagination parameters + */ +export async function getFormSubmissions( + adapter: Adapter, + formId: string, + params?: { limit?: number; offset?: number }, +): Promise<{ + items: SerializedFormSubmissionWithData[]; + total: number; + limit?: number; + offset?: number; +}> { + const form = await adapter.findOne({ + model: "form", + where: [{ field: "id", value: formId, operator: "eq" as const }], + }); + + if (!form) { + return { + items: [], + total: 0, + limit: params?.limit, + offset: params?.offset, + }; + } + + const allSubmissions = await adapter.findMany({ + model: "formSubmission", + where: [{ field: "formId", value: formId, operator: "eq" as const }], + }); + const total = allSubmissions.length; + + const submissions = await adapter.findMany({ + model: "formSubmission", + where: [{ field: "formId", value: formId, operator: "eq" as const }], + limit: params?.limit, + offset: params?.offset, + sortBy: { field: "submittedAt", direction: "desc" }, + join: { form: true }, + }); + + return { + items: submissions.map(serializeFormSubmissionWithData), + total, + limit: params?.limit, + offset: params?.offset, + }; +} diff --git a/packages/stack/src/plugins/form-builder/api/index.ts b/packages/stack/src/plugins/form-builder/api/index.ts index 00d5917..717ea1f 100644 --- a/packages/stack/src/plugins/form-builder/api/index.ts +++ b/packages/stack/src/plugins/form-builder/api/index.ts @@ -1 +1,2 @@ export { formBuilderBackendPlugin, type FormBuilderApiRouter } from "./plugin"; +export { getAllForms, getFormBySlug, getFormSubmissions } from "./getters"; diff --git a/packages/stack/src/plugins/form-builder/api/plugin.ts b/packages/stack/src/plugins/form-builder/api/plugin.ts index d01657a..394a10b 100644 --- a/packages/stack/src/plugins/form-builder/api/plugin.ts +++ b/packages/stack/src/plugins/form-builder/api/plugin.ts @@ -24,6 +24,7 @@ import { listSubmissionsQuerySchema, } from "../schemas"; import { slugify, extractIpAddress, extractUserAgent } from "../utils"; +import { getAllForms, getFormBySlug, getFormSubmissions } from "./getters"; /** * Serialize a Form for API response (convert dates to strings) @@ -83,6 +84,16 @@ export const formBuilderBackendPlugin = ( dbPlugin: dbSchema, + api: (adapter) => ({ + getAllForms: (params?: Parameters[1]) => + getAllForms(adapter, params), + getFormBySlug: (slug: string) => getFormBySlug(adapter, slug), + getFormSubmissions: ( + formId: string, + params?: Parameters[2], + ) => getFormSubmissions(adapter, formId, params), + }), + routes: (adapter: Adapter) => { // Helper to create hook context from request const createContext = (headers?: Headers): FormBuilderHookContext => ({ diff --git a/packages/stack/src/plugins/kanban/__tests__/getters.test.ts b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts new file mode 100644 index 0000000..5359db1 --- /dev/null +++ b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { Adapter } from "@btst/db"; +import { kanbanSchema } from "../db"; +import { getAllBoards, getBoardById } from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(kanbanSchema); + return createMemoryAdapter(db)({}); +}; + +async function createBoard( + adapter: Adapter, + name: string, + slug: string, + ownerId?: string, +): Promise { + return adapter.create({ + model: "kanbanBoard", + data: { + name, + slug, + ...(ownerId ? { ownerId } : {}), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function createColumn( + adapter: Adapter, + boardId: string, + title: string, + order: number, +): Promise { + return adapter.create({ + model: "kanbanColumn", + data: { + boardId, + title, + order, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function createTask( + adapter: Adapter, + columnId: string, + title: string, + order: number, +): Promise { + return adapter.create({ + model: "kanbanTask", + data: { + columnId, + title, + priority: "MEDIUM", + order, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +describe("kanban getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + describe("getAllBoards", () => { + it("returns empty array when no boards exist", async () => { + const boards = await getAllBoards(adapter); + expect(boards).toEqual([]); + }); + + it("returns all boards with columns and tasks", async () => { + const board = (await createBoard(adapter, "My Board", "my-board")) as any; + const col = (await createColumn(adapter, board.id, "To Do", 0)) as any; + await createTask(adapter, col.id, "Task 1", 0); + + const boards = await getAllBoards(adapter); + expect(boards).toHaveLength(1); + expect(boards[0]!.slug).toBe("my-board"); + expect(boards[0]!.columns).toHaveLength(1); + expect(boards[0]!.columns[0]!.title).toBe("To Do"); + expect(boards[0]!.columns[0]!.tasks).toHaveLength(1); + expect(boards[0]!.columns[0]!.tasks[0]!.title).toBe("Task 1"); + }); + + it("returns boards with empty columns array when no columns exist", async () => { + await createBoard(adapter, "Empty Board", "empty-board"); + + const boards = await getAllBoards(adapter); + expect(boards).toHaveLength(1); + expect(boards[0]!.columns).toEqual([]); + }); + + it("filters boards by slug", async () => { + await createBoard(adapter, "Board A", "board-a"); + await createBoard(adapter, "Board B", "board-b"); + + const result = await getAllBoards(adapter, { slug: "board-a" }); + expect(result).toHaveLength(1); + expect(result[0]!.slug).toBe("board-a"); + }); + + it("filters boards by ownerId", async () => { + await createBoard(adapter, "Alice Board", "alice-board", "user-alice"); + await createBoard(adapter, "Bob Board", "bob-board", "user-bob"); + + const result = await getAllBoards(adapter, { ownerId: "user-alice" }); + expect(result).toHaveLength(1); + expect(result[0]!.slug).toBe("alice-board"); + }); + + it("sorts columns by order", async () => { + const board = (await createBoard(adapter, "Board", "board")) as any; + // Create columns out of order + await createColumn(adapter, board.id, "Done", 2); + await createColumn(adapter, board.id, "To Do", 0); + await createColumn(adapter, board.id, "In Progress", 1); + + const boards = await getAllBoards(adapter); + expect(boards[0]!.columns[0]!.title).toBe("To Do"); + expect(boards[0]!.columns[1]!.title).toBe("In Progress"); + expect(boards[0]!.columns[2]!.title).toBe("Done"); + }); + }); + + describe("getBoardById", () => { + it("returns null when board does not exist", async () => { + const board = await getBoardById(adapter, "nonexistent"); + expect(board).toBeNull(); + }); + + it("returns the board with columns and tasks", async () => { + const board = (await createBoard(adapter, "My Board", "my-board")) as any; + const col1 = (await createColumn(adapter, board.id, "To Do", 0)) as any; + const col2 = (await createColumn(adapter, board.id, "Done", 1)) as any; + await createTask(adapter, col1.id, "Task A", 0); + await createTask(adapter, col1.id, "Task B", 1); + await createTask(adapter, col2.id, "Task C", 0); + + const result = await getBoardById(adapter, board.id); + expect(result).not.toBeNull(); + expect(result!.id).toBe(board.id); + expect(result!.columns).toHaveLength(2); + expect(result!.columns[0]!.tasks).toHaveLength(2); + expect(result!.columns[1]!.tasks).toHaveLength(1); + }); + + it("returns board with empty columns when no columns exist", async () => { + const board = (await createBoard(adapter, "Empty Board", "empty")) as any; + + const result = await getBoardById(adapter, board.id); + expect(result).not.toBeNull(); + expect(result!.columns).toEqual([]); + }); + }); +}); diff --git a/packages/stack/src/plugins/kanban/api/getters.ts b/packages/stack/src/plugins/kanban/api/getters.ts new file mode 100644 index 0000000..e76eb51 --- /dev/null +++ b/packages/stack/src/plugins/kanban/api/getters.ts @@ -0,0 +1,168 @@ +import type { Adapter } from "@btst/db"; +import type { + BoardWithKanbanColumn, + BoardWithColumns, + ColumnWithTasks, + Task, +} from "../types"; +import type { z } from "zod"; +import type { BoardListQuerySchema } from "../schemas"; + +/** + * Retrieve all boards matching optional filter criteria, with columns and tasks. + * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @param adapter - The database adapter + * @param params - Optional filter/pagination parameters (same shape as the list API query) + */ +export async function getAllBoards( + adapter: Adapter, + params?: z.infer, +): Promise { + const query = params ?? {}; + + const whereConditions: Array<{ + field: string; + value: string; + operator: "eq"; + }> = []; + + if (query.slug) { + whereConditions.push({ + field: "slug", + value: query.slug, + operator: "eq" as const, + }); + } + + if (query.ownerId) { + whereConditions.push({ + field: "ownerId", + value: query.ownerId, + operator: "eq" as const, + }); + } + + if (query.organizationId) { + whereConditions.push({ + field: "organizationId", + value: query.organizationId, + operator: "eq" as const, + }); + } + + const boards = await adapter.findMany({ + model: "kanbanBoard", + limit: query.limit ?? 50, + offset: query.offset ?? 0, + where: whereConditions.length > 0 ? whereConditions : undefined, + sortBy: { field: "createdAt", direction: "desc" }, + join: { kanbanColumn: true }, + }); + + // Collect all column IDs to fetch tasks + const columnIds: string[] = []; + for (const board of boards) { + if (board.column) { + for (const col of board.column) { + columnIds.push(col.id); + } + } + } + + // Fetch tasks for each column in parallel + const tasksByColumn = new Map(); + if (columnIds.length > 0) { + const taskQueries = columnIds.map((columnId) => + adapter.findMany({ + model: "kanbanTask", + where: [ + { field: "columnId", value: columnId, operator: "eq" as const }, + ], + sortBy: { field: "order", direction: "asc" }, + }), + ); + const taskResults = await Promise.all(taskQueries); + for (let i = 0; i < columnIds.length; i++) { + const columnId = columnIds[i]; + const tasks = taskResults[i]; + if (columnId && tasks) { + tasksByColumn.set(columnId, tasks); + } + } + } + + // Map boards with sorted columns and tasks + return boards.map((board) => { + const columns: ColumnWithTasks[] = (board.column || []) + .sort((a, b) => a.order - b.order) + .map((col) => ({ + ...col, + tasks: tasksByColumn.get(col.id) || [], + })); + const { column: _, ...boardWithoutJoin } = board; + return { + ...boardWithoutJoin, + columns, + }; + }); +} + +/** + * Retrieve a single board by its ID, with all columns and tasks. + * Returns null if the board is not found. + * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @param adapter - The database adapter + * @param id - The board ID + */ +export async function getBoardById( + adapter: Adapter, + id: string, +): Promise { + const board = await adapter.findOne({ + model: "kanbanBoard", + where: [{ field: "id", value: id, operator: "eq" as const }], + join: { kanbanColumn: true }, + }); + + if (!board) { + return null; + } + + const columnIds = (board.column || []).map((c) => c.id); + const tasksByColumn = new Map(); + + if (columnIds.length > 0) { + const taskQueries = columnIds.map((columnId) => + adapter.findMany({ + model: "kanbanTask", + where: [ + { field: "columnId", value: columnId, operator: "eq" as const }, + ], + sortBy: { field: "order", direction: "asc" }, + }), + ); + const taskResults = await Promise.all(taskQueries); + for (let i = 0; i < columnIds.length; i++) { + const columnId = columnIds[i]; + const tasks = taskResults[i]; + if (columnId && tasks) { + tasksByColumn.set(columnId, tasks); + } + } + } + + const columns: ColumnWithTasks[] = (board.column || []) + .sort((a, b) => a.order - b.order) + .map((col) => ({ + ...col, + tasks: tasksByColumn.get(col.id) || [], + })); + + const { column: _, ...boardWithoutJoin } = board; + return { + ...boardWithoutJoin, + columns, + }; +} diff --git a/packages/stack/src/plugins/kanban/api/index.ts b/packages/stack/src/plugins/kanban/api/index.ts index 9cce8d2..f49cb2f 100644 --- a/packages/stack/src/plugins/kanban/api/index.ts +++ b/packages/stack/src/plugins/kanban/api/index.ts @@ -4,3 +4,4 @@ export { type KanbanApiContext, type KanbanBackendHooks, } from "./plugin"; +export { getAllBoards, getBoardById } from "./getters"; diff --git a/packages/stack/src/plugins/kanban/api/plugin.ts b/packages/stack/src/plugins/kanban/api/plugin.ts index 15e1f42..eeacb46 100644 --- a/packages/stack/src/plugins/kanban/api/plugin.ts +++ b/packages/stack/src/plugins/kanban/api/plugin.ts @@ -3,13 +3,7 @@ import { defineBackendPlugin } from "@btst/stack/plugins/api"; import { createEndpoint } from "@btst/stack/plugins/api"; import { z } from "zod"; import { kanbanSchema as dbSchema } from "../db"; -import type { - Board, - BoardWithKanbanColumn, - Column, - ColumnWithTasks, - Task, -} from "../types"; +import type { Board, Column, ColumnWithTasks, Task } from "../types"; import { slugify } from "../utils"; import { BoardListQuerySchema, @@ -23,6 +17,7 @@ import { updateColumnSchema, updateTaskSchema, } from "../schemas"; +import { getAllBoards, getBoardById } from "./getters"; /** * Context passed to kanban API hooks @@ -261,6 +256,12 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => dbPlugin: dbSchema, + api: (adapter) => ({ + getAllBoards: (params?: Parameters[1]) => + getAllBoards(adapter, params), + getBoardById: (id: string) => getBoardById(adapter, id), + }), + routes: (adapter: Adapter) => { // ============ Board Endpoints ============ @@ -284,97 +285,7 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => } } - const whereConditions = []; - - if (query.slug) { - whereConditions.push({ - field: "slug", - value: query.slug, - operator: "eq" as const, - }); - } - - if (query.ownerId) { - whereConditions.push({ - field: "ownerId", - value: query.ownerId, - operator: "eq" as const, - }); - } - - if (query.organizationId) { - whereConditions.push({ - field: "organizationId", - value: query.organizationId, - operator: "eq" as const, - }); - } - - const boards = await adapter.findMany({ - model: "kanbanBoard", - limit: query.limit ?? 50, - offset: query.offset ?? 0, - where: whereConditions, - sortBy: { - field: "createdAt", - direction: "desc", - }, - join: { - kanbanColumn: true, - }, - }); - - // Get all column IDs to fetch tasks - // Note: adapter returns joined data under schema key name ("column"), not model name - const columnIds: string[] = []; - for (const board of boards) { - if (board.column) { - for (const col of board.column) { - columnIds.push(col.id); - } - } - } - - // Fetch tasks for each column in parallel (avoids loading all tasks from DB) - const tasksByColumn = new Map(); - if (columnIds.length > 0) { - const taskQueries = columnIds.map((columnId) => - adapter.findMany({ - model: "kanbanTask", - where: [ - { - field: "columnId", - value: columnId, - operator: "eq" as const, - }, - ], - sortBy: { field: "order", direction: "asc" }, - }), - ); - const taskResults = await Promise.all(taskQueries); - for (let i = 0; i < columnIds.length; i++) { - const columnId = columnIds[i]; - const tasks = taskResults[i]; - if (columnId && tasks) { - tasksByColumn.set(columnId, tasks); - } - } - } - - // Map boards with columns and tasks - const result = boards.map((board) => { - const columns = (board.column || []) - .sort((a, b) => a.order - b.order) - .map((col) => ({ - ...col, - tasks: tasksByColumn.get(col.id) || [], - })); - const { column: _, ...boardWithoutJoin } = board; - return { - ...boardWithoutJoin, - columns, - }; - }); + const result = await getAllBoards(adapter, query); if (hooks?.onBoardsRead) { await hooks.onBoardsRead(result, query, context); @@ -409,61 +320,12 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => } } - const board = await adapter.findOne({ - model: "kanbanBoard", - where: [ - { field: "id", value: params.id, operator: "eq" as const }, - ], - join: { - kanbanColumn: true, - }, - }); + const result = await getBoardById(adapter, params.id); - if (!board) { + if (!result) { throw ctx.error(404, { message: "Board not found" }); } - // Fetch tasks for each column in parallel (avoids loading all tasks from DB) - // Note: adapter returns joined data under schema key name ("column"), not model name - const columnIds = (board.column || []).map((c) => c.id); - const tasksByColumn = new Map(); - if (columnIds.length > 0) { - const taskQueries = columnIds.map((columnId) => - adapter.findMany({ - model: "kanbanTask", - where: [ - { - field: "columnId", - value: columnId, - operator: "eq" as const, - }, - ], - sortBy: { field: "order", direction: "asc" }, - }), - ); - const taskResults = await Promise.all(taskQueries); - for (let i = 0; i < columnIds.length; i++) { - const columnId = columnIds[i]; - const tasks = taskResults[i]; - if (columnId && tasks) { - tasksByColumn.set(columnId, tasks); - } - } - } - - const columns = (board.column || []) - .sort((a, b) => a.order - b.order) - .map((col) => ({ - ...col, - tasks: tasksByColumn.get(col.id) || [], - })); - - const { column: _, ...boardWithoutJoin } = board; - const result = { - ...boardWithoutJoin, - columns, - }; - if (hooks?.onBoardRead) { await hooks.onBoardRead(result, context); } diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 08f03d2..fbbd035 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -8,7 +8,7 @@ import type { Endpoint, Router } from "better-call"; */ export interface StackContext { /** All registered backend plugins */ - plugins: Record>; + plugins: Record>; /** The API base path (e.g., "/api/data") */ basePath: string; /** The database adapter */ @@ -40,9 +40,14 @@ export interface ClientStackContext< * You can optionally provide a base schema via the dbSchema config option. * * @template TRoutes - The exact shape of routes this plugin provides (preserves keys and endpoint types) + * @template TApi - The shape of the server-side API surface exposed via `stack().api` */ export interface BackendPlugin< TRoutes extends Record = Record, + TApi extends Record any> = Record< + string, + (...args: any[]) => any + >, > { name: string; @@ -56,6 +61,15 @@ export interface BackendPlugin< */ routes: (adapter: Adapter, context?: StackContext) => TRoutes; dbPlugin: DbPlugin; + + /** + * Optional factory that returns server-side getter functions bound to the adapter. + * The returned object is merged into `stack().api..*` for direct + * server-side or SSG data access without going through HTTP. + * + * @param adapter - The adapter instance shared with `routes` + */ + api?: (adapter: Adapter) => TApi; } /** @@ -87,13 +101,25 @@ export interface ClientPlugin< sitemap?: () => Promise | Sitemap; } +/** + * Utility type that maps each plugin key to the return type of its `api` factory. + * Used to build the fully-typed `stack().api` surface. + */ +export type PluginApis< + TPlugins extends Record>, +> = { + [K in keyof TPlugins]: TPlugins[K] extends BackendPlugin + ? TApi + : never; +}; + /** * Configuration for creating the backend library */ export interface BackendLibConfig< - TPlugins extends Record> = Record< + TPlugins extends Record> = Record< string, - BackendPlugin + BackendPlugin >, > { basePath: string; @@ -150,11 +176,12 @@ export type PluginRoutes< * Example: { messages: { list: Endpoint } } => { messages_list: Endpoint } */ export type PrefixedPluginRoutes< - TPlugins extends Record>, + TPlugins extends Record>, > = UnionToIntersection< { [PluginKey in keyof TPlugins]: TPlugins[PluginKey] extends BackendPlugin< - infer TRoutes + infer TRoutes, + any > ? { [RouteKey in keyof TRoutes as `${PluginKey & string}_${RouteKey & string}`]: TRoutes[RouteKey]; @@ -172,10 +199,18 @@ export type PrefixedPluginRoutes< */ export interface BackendLib< TRoutes extends Record = Record, + TApis extends Record< + string, + Record any> + > = Record any>>, > { handler: (request: Request) => Promise; // API route handler router: Router; // Better-call router dbSchema: DatabaseDefinition; // Better-db schema + /** The database adapter shared across all plugins */ + adapter: Adapter; + /** Fully-typed server-side getter functions, namespaced per plugin */ + api: TApis; } /** From 0215e69bac3a701b7b53c99f44c7915e3b5c11a1 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 11:26:09 -0500 Subject: [PATCH 02/20] refactor: remove duplicated code --- packages/stack/src/plugins/cms/api/getters.ts | 8 +- packages/stack/src/plugins/cms/api/plugin.ts | 96 +------------------ .../src/plugins/form-builder/api/getters.ts | 6 +- .../src/plugins/form-builder/api/plugin.ts | 56 ++--------- 4 files changed, 18 insertions(+), 148 deletions(-) diff --git a/packages/stack/src/plugins/cms/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts index 0ca049d..516faa4 100644 --- a/packages/stack/src/plugins/cms/api/getters.ts +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -12,7 +12,7 @@ import type { * 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 { +export function serializeContentType(ct: ContentType): SerializedContentType { const needsMigration = !ct.autoFormVersion || ct.autoFormVersion < 2; const migratedJsonSchema = needsMigration ? migrateToUnifiedSchema(ct.jsonSchema, ct.fieldConfig) @@ -29,7 +29,7 @@ function serializeContentType(ct: ContentType): SerializedContentType { }; } -function migrateToUnifiedSchema( +export function migrateToUnifiedSchema( jsonSchemaStr: string, fieldConfigStr: string | null | undefined, ): string { @@ -61,7 +61,7 @@ function migrateToUnifiedSchema( /** * Serialize a ContentItem for SSR/SSG use (convert dates to strings). */ -function serializeContentItem(item: ContentItem): SerializedContentItem { +export function serializeContentItem(item: ContentItem): SerializedContentItem { return { ...item, createdAt: item.createdAt.toISOString(), @@ -72,7 +72,7 @@ function serializeContentItem(item: ContentItem): SerializedContentItem { /** * Serialize a ContentItem with parsed data and joined ContentType. */ -function serializeContentItemWithType( +export function serializeContentItemWithType( item: ContentItemWithType, ): SerializedContentItemWithType { return { diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index bb97f9e..3316973 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, @@ -27,99 +25,11 @@ import { getAllContentTypes, getAllContentItems, getContentItemBySlug, + serializeContentType, + serializeContentItem, + serializeContentItemWithType, } from "./getters"; -/** - * 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, - }; -} - /** * Sync content types from config to database * Creates or updates content types based on the developer's Zod schemas diff --git a/packages/stack/src/plugins/form-builder/api/getters.ts b/packages/stack/src/plugins/form-builder/api/getters.ts index 1c171d1..a1ccac8 100644 --- a/packages/stack/src/plugins/form-builder/api/getters.ts +++ b/packages/stack/src/plugins/form-builder/api/getters.ts @@ -11,7 +11,7 @@ import type { /** * Serialize a Form for SSR/SSG use (convert dates to strings). */ -function serializeForm(form: Form): SerializedForm { +export function serializeForm(form: Form): SerializedForm { return { id: form.id, name: form.name, @@ -30,7 +30,7 @@ function serializeForm(form: Form): SerializedForm { /** * Serialize a FormSubmission for SSR/SSG use (convert dates to strings). */ -function serializeFormSubmission( +export function serializeFormSubmission( submission: FormSubmission, ): SerializedFormSubmission { return { @@ -42,7 +42,7 @@ function serializeFormSubmission( /** * Serialize a FormSubmission with parsed data and joined Form. */ -function serializeFormSubmissionWithData( +export function serializeFormSubmissionWithData( submission: FormSubmissionWithForm, ): SerializedFormSubmissionWithData { return { diff --git a/packages/stack/src/plugins/form-builder/api/plugin.ts b/packages/stack/src/plugins/form-builder/api/plugin.ts index 394a10b..be8c6a6 100644 --- a/packages/stack/src/plugins/form-builder/api/plugin.ts +++ b/packages/stack/src/plugins/form-builder/api/plugin.ts @@ -11,9 +11,6 @@ import type { FormBuilderBackendConfig, FormBuilderHookContext, SubmissionHookContext, - SerializedForm, - SerializedFormSubmission, - SerializedFormSubmissionWithData, FormInput, FormUpdate, } from "../types"; @@ -24,51 +21,14 @@ import { listSubmissionsQuerySchema, } from "../schemas"; import { slugify, extractIpAddress, extractUserAgent } from "../utils"; -import { getAllForms, getFormBySlug, getFormSubmissions } from "./getters"; - -/** - * Serialize a Form for API response (convert dates to strings) - */ -function serializeForm(form: Form): SerializedForm { - return { - id: form.id, - name: form.name, - slug: form.slug, - description: form.description, - schema: form.schema, - successMessage: form.successMessage, - redirectUrl: form.redirectUrl, - status: form.status, - createdBy: form.createdBy, - createdAt: form.createdAt.toISOString(), - updatedAt: form.updatedAt.toISOString(), - }; -} - -/** - * Serialize a FormSubmission for API response (convert dates to strings) - */ -function serializeFormSubmission( - submission: FormSubmission, -): SerializedFormSubmission { - return { - ...submission, - submittedAt: submission.submittedAt.toISOString(), - }; -} - -/** - * Serialize a FormSubmission with parsed data and joined Form - */ -function serializeFormSubmissionWithData( - submission: FormSubmissionWithForm, -): SerializedFormSubmissionWithData { - return { - ...serializeFormSubmission(submission), - parsedData: JSON.parse(submission.data), - form: submission.form ? serializeForm(submission.form) : undefined, - }; -} +import { + getAllForms, + getFormBySlug, + getFormSubmissions, + serializeForm, + serializeFormSubmission, + serializeFormSubmissionWithData, +} from "./getters"; /** * Form Builder backend plugin From 19c9349958e693dd3de04df63d9b9e23c85597db Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 11:28:48 -0500 Subject: [PATCH 03/20] refactor: update BackendPlugin type to exclude plugins without an api factory --- packages/stack/src/plugins/api/index.ts | 5 +---- packages/stack/src/types.ts | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/stack/src/plugins/api/index.ts b/packages/stack/src/plugins/api/index.ts index 4ecc3b4..6752b76 100644 --- a/packages/stack/src/plugins/api/index.ts +++ b/packages/stack/src/plugins/api/index.ts @@ -46,10 +46,7 @@ export { createDbPlugin } from "@btst/db"; */ export function defineBackendPlugin< TRoutes extends Record = Record, - TApi extends Record any> = Record< - string, - (...args: any[]) => any - >, + TApi extends Record any> = never, >(plugin: BackendPlugin): BackendPlugin { return plugin; } diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index fbbd035..673f791 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -40,14 +40,13 @@ export interface ClientStackContext< * You can optionally provide a base schema via the dbSchema config option. * * @template TRoutes - The exact shape of routes this plugin provides (preserves keys and endpoint types) - * @template TApi - The shape of the server-side API surface exposed via `stack().api` + * @template TApi - The shape of the server-side API surface exposed via `stack().api`. + * Defaults to `never` so that plugins without an `api` factory are excluded from the + * `stack().api` namespace entirely, preventing accidental access of `undefined` at runtime. */ export interface BackendPlugin< TRoutes extends Record = Record, - TApi extends Record any> = Record< - string, - (...args: any[]) => any - >, + TApi extends Record any> = never, > { name: string; @@ -103,16 +102,21 @@ export interface ClientPlugin< /** * Utility type that maps each plugin key to the return type of its `api` factory. - * Used to build the fully-typed `stack().api` surface. + * Plugin keys whose `TApi` resolves to `never` (i.e. plugins with no `api` factory) + * are excluded from the resulting type via key remapping, preventing TypeScript from + * suggesting callable functions on what is actually `undefined` at runtime. */ export type PluginApis< TPlugins extends Record>, > = { - [K in keyof TPlugins]: TPlugins[K] extends BackendPlugin - ? TApi - : never; + [K in keyof TPlugins as _ApiOf extends never + ? never + : K]: _ApiOf; }; +/** @internal Extract the TApi parameter from a BackendPlugin type. */ +type _ApiOf = T extends BackendPlugin ? TApi : never; + /** * Configuration for creating the backend library */ From bd141c91b48710e90aa24f3361b79b7212773ebb Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 11:39:47 -0500 Subject: [PATCH 04/20] feat: implement CRUD operations for todos with server-side getters --- docs/content/docs/plugins/development.mdx | 78 +++++++++++++++---- .../nextjs/lib/plugins/todo/api/backend.ts | 7 ++ .../nextjs/lib/plugins/todo/api/getters.ts | 27 +++++++ examples/nextjs/lib/stack.ts | 4 +- 4 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 examples/nextjs/lib/plugins/todo/api/getters.ts diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index b57b8d0..5f7d284 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -693,12 +693,12 @@ import { createMemoryAdapter } from "@btst/adapter-memory" import { todosBackendPlugin } from "./plugins/todo/api/backend" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" +// 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 @@ -711,17 +711,17 @@ export const myStack = stack({ adapter: (db) => createMemoryAdapter(db)({}) }) -// myStack exposes: -// .handler — HTTP route handler -// .dbSchema — Better-db schema -// .adapter — Raw database adapter -// .api — Typed server-side getters per plugin +// 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 }) - -export const { handler, dbSchema } = myStack ``` ### Client Registration @@ -805,28 +805,57 @@ 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: () => - 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" }] }), + listTodos: () => listTodos(adapter), + getTodoById: (id: string) => getTodoById(adapter, id), }), routes: (adapter: Adapter) => { @@ -840,8 +869,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 } }) @@ -977,7 +1025,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/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 From 5444d73d5188f0586731c007242dc1083c3fd453 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 14:16:21 -0500 Subject: [PATCH 05/20] feat: enhance blog API with paginated results and authorization warnings for database access --- docs/content/docs/plugins/development.mdx | 4 + .../stack/src/__tests__/stack-api.test.ts | 4 +- .../plugins/blog/__tests__/getters.test.ts | 84 +++++++++++++------ .../stack/src/plugins/blog/api/getters.ts | 63 +++++++++++--- packages/stack/src/plugins/blog/api/index.ts | 7 +- packages/stack/src/plugins/blog/api/plugin.ts | 11 ++- packages/stack/src/plugins/blog/query-keys.ts | 29 ++++--- packages/stack/src/plugins/cms/api/getters.ts | 20 +++-- .../src/plugins/form-builder/api/getters.ts | 24 ++++-- 9 files changed, 181 insertions(+), 65 deletions(-) diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index 5f7d284..dfb3cfd 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -300,6 +300,10 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => 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 diff --git a/packages/stack/src/__tests__/stack-api.test.ts b/packages/stack/src/__tests__/stack-api.test.ts index 5f8fbf0..794a2a6 100644 --- a/packages/stack/src/__tests__/stack-api.test.ts +++ b/packages/stack/src/__tests__/stack-api.test.ts @@ -93,8 +93,8 @@ describe("stack.api surface", () => { // Retrieve via stack.api const posts = await backend.api.blog.getAllPosts(); - expect(posts).toHaveLength(1); - expect(posts[0]!.slug).toBe("hello-world"); + 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"); diff --git a/packages/stack/src/plugins/blog/__tests__/getters.test.ts b/packages/stack/src/plugins/blog/__tests__/getters.test.ts index bfbdde4..e315820 100644 --- a/packages/stack/src/plugins/blog/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/blog/__tests__/getters.test.ts @@ -18,9 +18,10 @@ describe("blog getters", () => { }); describe("getAllPosts", () => { - it("returns empty array when no posts exist", async () => { - const posts = await getAllPosts(adapter); - expect(posts).toEqual([]); + 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 () => { @@ -38,10 +39,11 @@ describe("blog getters", () => { }, }); - const posts = await getAllPosts(adapter); - expect(posts).toHaveLength(1); - expect(posts[0]!.slug).toBe("hello-world"); - expect(posts[0]!.tags).toEqual([]); + 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 () => { @@ -73,12 +75,14 @@ describe("blog getters", () => { }); const published = await getAllPosts(adapter, { published: true }); - expect(published).toHaveLength(1); - expect(published[0]!.slug).toBe("published"); + 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).toHaveLength(1); - expect(drafts[0]!.slug).toBe("draft"); + expect(drafts.items).toHaveLength(1); + expect(drafts.total).toBe(1); + expect(drafts.items[0]!.slug).toBe("draft"); }); it("filters posts by slug", async () => { @@ -110,8 +114,9 @@ describe("blog getters", () => { }); const result = await getAllPosts(adapter, { slug: "post-a" }); - expect(result).toHaveLength(1); - expect(result[0]!.slug).toBe("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 () => { @@ -143,8 +148,9 @@ describe("blog getters", () => { }); const result = await getAllPosts(adapter, { query: "typescript" }); - expect(result).toHaveLength(1); - expect(result[0]!.slug).toBe("ts-tips"); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.slug).toBe("ts-tips"); }); it("respects limit and offset", async () => { @@ -165,13 +171,15 @@ describe("blog getters", () => { } const page1 = await getAllPosts(adapter, { limit: 2, offset: 0 }); - expect(page1).toHaveLength(2); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(5); const page2 = await getAllPosts(adapter, { limit: 2, offset: 2 }); - expect(page2).toHaveLength(2); + expect(page2.items).toHaveLength(2); + expect(page2.total).toBe(5); // Pages should be different posts - expect(page1[0]!.slug).not.toBe(page2[0]!.slug); + expect(page1.items[0]!.slug).not.toBe(page2.items[0]!.slug); }); it("attaches tags to posts", async () => { @@ -202,14 +210,41 @@ describe("blog getters", () => { 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"); + 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).toEqual([]); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + 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); }); }); @@ -247,7 +282,7 @@ describe("blog getters", () => { expect(tags).toEqual([]); }); - it("returns all tags", async () => { + it("returns all tags sorted alphabetically by name", async () => { await adapter.create({ model: "tag", data: { @@ -269,7 +304,8 @@ describe("blog getters", () => { const tags = await getAllTags(adapter); expect(tags).toHaveLength(2); - expect(tags.map((t) => t.slug).sort()).toEqual(["react", "typescript"]); + 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 index 313f14e..e9c9f01 100644 --- a/packages/stack/src/plugins/blog/api/getters.ts +++ b/packages/stack/src/plugins/blog/api/getters.ts @@ -14,9 +14,23 @@ export interface PostListParams { 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. + * 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) @@ -24,7 +38,7 @@ export interface PostListParams { export async function getAllPosts( adapter: Adapter, params?: PostListParams, -): Promise> { +): Promise { const query = params ?? {}; let tagFilterPostIds: Set | null = null; @@ -42,7 +56,7 @@ export async function getAllPosts( }); if (!tag) { - return []; + return { items: [], total: 0, limit: query.limit, offset: query.offset }; } const postTags = await adapter.findMany<{ postId: string; tagId: string }>({ @@ -57,7 +71,7 @@ export async function getAllPosts( }); tagFilterPostIds = new Set(postTags.map((pt) => pt.postId)); if (tagFilterPostIds.size === 0) { - return []; + return { items: [], total: 0, limit: query.limit, offset: query.offset }; } } @@ -79,10 +93,21 @@ export async function getAllPosts( }); } + // For DB-paginated paths (no in-memory filtering), count total up front. + // For in-memory-filtered paths (query/tagSlug), total is computed after filtering. + // TODO: remove cast once @btst/db types expose adapter.count() + const dbPaginationOnly = !query.query && !query.tagSlug; + const dbTotal: number | undefined = dbPaginationOnly + ? await (adapter as any).count({ + model: "post", + where: whereConditions.length > 0 ? whereConditions : undefined, + }) + : undefined; + 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), + limit: dbPaginationOnly ? (query.limit ?? 10) : undefined, + offset: dbPaginationOnly ? (query.offset ?? 0) : undefined, where: whereConditions, sortBy: { field: "createdAt", @@ -147,17 +172,30 @@ export async function getAllPosts( } if (query.tagSlug || query.query) { + // Capture total after in-memory filters but before pagination slice + const total = result.length; const offset = query.offset ?? 0; const limit = query.limit ?? 10; result = result.slice(offset, offset + limit); + return { items: result, total, limit: query.limit, offset: query.offset }; } - return result; + // DB-paginated path: total was fetched with adapter.count() above + 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 @@ -166,17 +204,22 @@ export async function getPostBySlug( adapter: Adapter, slug: string, ): Promise<(Post & { tags: Tag[] }) | null> { - const results = await getAllPosts(adapter, { slug }); - return results[0] ?? null; + const { items } = await getAllPosts(adapter, { slug }); + return items[0] ?? null; } /** - * Retrieve all tags. + * 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 249a81d..f069652 100644 --- a/packages/stack/src/plugins/blog/api/index.ts +++ b/packages/stack/src/plugins/blog/api/index.ts @@ -1,3 +1,8 @@ export * from "./plugin"; -export { getAllPosts, getPostBySlug, getAllTags } from "./getters"; +export { + getAllPosts, + getPostBySlug, + getAllTags, + 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 9e33217..90cf854 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -6,7 +6,12 @@ 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"; +import { + getAllPosts, + getPostBySlug, + getAllTags, + type PostListResult, +} from "./getters"; export const PostListQuerySchema = z.object({ slug: z.string().optional(), @@ -87,12 +92,12 @@ export interface BlogBackendHooks { /** * Called after posts are read successfully - * @param posts - Array of posts that were read + * @param result - Paginated result containing posts and total count * @param filter - Query parameters used for filtering * @param context - Request context */ onPostsRead?: ( - posts: Post[], + result: PostListResult, filter: z.infer, context: BlogApiContext, ) => Promise | void; 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/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts index 516faa4..22a0a70 100644 --- a/packages/stack/src/plugins/cms/api/getters.ts +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -86,7 +86,10 @@ export function serializeContentItemWithType( /** * Retrieve all content types. - * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * 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 */ @@ -102,7 +105,11 @@ export async function getAllContentTypes( /** * 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. + * 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 @@ -158,11 +165,11 @@ export async function getAllContentItems( }); } - const allItems = await adapter.findMany({ + // TODO: remove cast once @btst/db types expose adapter.count() + const total: number = await (adapter as any).count({ model: "contentItem", where: whereConditions, }); - const total = allItems.length; const items = await adapter.findMany({ model: "contentItem", @@ -184,7 +191,10 @@ export async function getAllContentItems( /** * 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. + * 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 diff --git a/packages/stack/src/plugins/form-builder/api/getters.ts b/packages/stack/src/plugins/form-builder/api/getters.ts index a1ccac8..b800ee2 100644 --- a/packages/stack/src/plugins/form-builder/api/getters.ts +++ b/packages/stack/src/plugins/form-builder/api/getters.ts @@ -54,7 +54,11 @@ export function serializeFormSubmissionWithData( /** * Retrieve all forms with optional status filter and pagination. - * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * 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 @@ -82,11 +86,11 @@ export async function getAllForms( }); } - const allForms = await adapter.findMany({ + // TODO: remove cast once @btst/db types expose adapter.count() + const total: number = await (adapter as any).count({ model: "form", where: whereConditions.length > 0 ? whereConditions : undefined, }); - const total = allForms.length; const forms = await adapter.findMany({ model: "form", @@ -107,7 +111,10 @@ export async function getAllForms( /** * Retrieve a single form by its slug. * Returns null if the form is not found. - * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. * * @param adapter - The database adapter * @param slug - The form slug @@ -131,7 +138,10 @@ export async function getFormBySlug( /** * Retrieve submissions for a form by form ID, with optional pagination. * Returns an empty result if the form does not exist. - * Pure DB function - no hooks, no HTTP context. Safe for server-side use. + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. * * @param adapter - The database adapter * @param formId - The form ID @@ -161,11 +171,11 @@ export async function getFormSubmissions( }; } - const allSubmissions = await adapter.findMany({ + // TODO: remove cast once @btst/db types expose adapter.count() + const total: number = await (adapter as any).count({ model: "formSubmission", where: [{ field: "formId", value: formId, operator: "eq" as const }], }); - const total = allSubmissions.length; const submissions = await adapter.findMany({ model: "formSubmission", From 221a11b1f8c54d65f6e1ce17931d116bf9ad6ee7 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 14:27:03 -0500 Subject: [PATCH 06/20] refactor: remove redundant code --- packages/stack/src/plugins/cms/api/plugin.ts | 40 +--------- .../src/plugins/form-builder/api/plugin.ts | 76 ++----------------- 2 files changed, 7 insertions(+), 109 deletions(-) diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index 3316973..e0f770a 100644 --- a/packages/stack/src/plugins/cms/api/plugin.ts +++ b/packages/stack/src/plugins/cms/api/plugin.ts @@ -560,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 }); }, ); diff --git a/packages/stack/src/plugins/form-builder/api/plugin.ts b/packages/stack/src/plugins/form-builder/api/plugin.ts index be8c6a6..e5ab431 100644 --- a/packages/stack/src/plugins/form-builder/api/plugin.ts +++ b/packages/stack/src/plugins/form-builder/api/plugin.ts @@ -23,7 +23,7 @@ import { import { slugify, extractIpAddress, extractUserAgent } from "../utils"; import { getAllForms, - getFormBySlug, + getFormBySlug as getFormBySlugFromDb, getFormSubmissions, serializeForm, serializeFormSubmission, @@ -47,7 +47,7 @@ export const formBuilderBackendPlugin = ( api: (adapter) => ({ getAllForms: (params?: Parameters[1]) => getAllForms(adapter, params), - getFormBySlug: (slug: string) => getFormBySlug(adapter, slug), + getFormBySlug: (slug: string) => getFormBySlugFromDb(adapter, slug), getFormSubmissions: ( formId: string, params?: Parameters[2], @@ -85,7 +85,6 @@ export const formBuilderBackendPlugin = ( const { status, limit, offset } = ctx.query; const context = createContext(ctx.headers); - // Call before hook for auth check if (config.hooks?.onBeforeListForms) { const canList = await config.hooks.onBeforeListForms(context); if (!canList) { @@ -93,41 +92,7 @@ export const formBuilderBackendPlugin = ( } } - const whereConditions: Array<{ - field: string; - value: string; - operator: "eq"; - }> = []; - if (status) { - whereConditions.push({ - field: "status", - value: status, - operator: "eq" as const, - }); - } - - // Get total count - const allForms = await adapter.findMany({ - model: "form", - where: whereConditions.length > 0 ? whereConditions : undefined, - }); - const total = allForms.length; - - // Get paginated forms - const forms = await adapter.findMany({ - model: "form", - where: whereConditions.length > 0 ? whereConditions : undefined, - limit, - offset, - sortBy: { field: "createdAt", direction: "desc" }, - }); - - return { - items: forms.map(serializeForm), - total, - limit, - offset, - }; + return getAllForms(adapter, { status, limit, offset }); }, ); @@ -149,16 +114,13 @@ export const formBuilderBackendPlugin = ( } } - const form = await adapter.findOne({ - model: "form", - where: [{ field: "slug", value: slug, operator: "eq" as const }], - }); + const form = await getFormBySlugFromDb(adapter, slug); if (!form) { throw ctx.error(404, { message: "Form not found" }); } - return serializeForm(form); + return form; }, ); @@ -618,33 +580,7 @@ export const formBuilderBackendPlugin = ( } } - // Get total count - const allSubmissions = await adapter.findMany({ - model: "formSubmission", - where: [ - { field: "formId", value: formId, operator: "eq" as const }, - ], - }); - const total = allSubmissions.length; - - // Get paginated submissions - const submissions = await adapter.findMany({ - model: "formSubmission", - where: [ - { field: "formId", value: formId, operator: "eq" as const }, - ], - limit, - offset, - sortBy: { field: "submittedAt", direction: "desc" }, - join: { form: true }, - }); - - return { - items: submissions.map(serializeFormSubmissionWithData), - total, - limit, - offset, - }; + return getFormSubmissions(adapter, formId, { limit, offset }); }, ); From 63d48be6c73986cd04f3d3c4627846434d84a5bf Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 14:34:38 -0500 Subject: [PATCH 07/20] refactor: improve error handling for JSON parsing in content serialization and streamline database count calls --- packages/stack/src/plugins/blog/api/getters.ts | 2 +- packages/stack/src/plugins/blog/api/index.ts | 1 + packages/stack/src/plugins/cms/api/getters.ts | 12 ++++++++++-- packages/stack/src/plugins/cms/types.ts | 4 ++-- .../stack/src/plugins/form-builder/api/getters.ts | 14 +++++++++++--- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/plugins/blog/api/getters.ts b/packages/stack/src/plugins/blog/api/getters.ts index e9c9f01..4e1e744 100644 --- a/packages/stack/src/plugins/blog/api/getters.ts +++ b/packages/stack/src/plugins/blog/api/getters.ts @@ -98,7 +98,7 @@ export async function getAllPosts( // TODO: remove cast once @btst/db types expose adapter.count() const dbPaginationOnly = !query.query && !query.tagSlug; const dbTotal: number | undefined = dbPaginationOnly - ? await (adapter as any).count({ + ? await adapter.count({ model: "post", where: whereConditions.length > 0 ? whereConditions : undefined, }) diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts index f069652..7a4187b 100644 --- a/packages/stack/src/plugins/blog/api/index.ts +++ b/packages/stack/src/plugins/blog/api/index.ts @@ -3,6 +3,7 @@ export { getAllPosts, getPostBySlug, getAllTags, + type PostListParams, type PostListResult, } from "./getters"; export { createBlogQueryKeys } from "../query-keys"; diff --git a/packages/stack/src/plugins/cms/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts index 22a0a70..2b7c727 100644 --- a/packages/stack/src/plugins/cms/api/getters.ts +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -71,13 +71,21 @@ export function serializeContentItem(item: ContentItem): SerializedContentItem { /** * Serialize a ContentItem with parsed data and joined ContentType. + * If `item.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 serializeContentItemWithType( item: ContentItemWithType, ): SerializedContentItemWithType { + let parsedData: Record | null = null; + try { + parsedData = JSON.parse(item.data); + } catch { + // Corrupted JSON — leave parsedData as null so callers can handle it + } return { ...serializeContentItem(item), - parsedData: JSON.parse(item.data), + parsedData, contentType: item.contentType ? serializeContentType(item.contentType) : undefined, @@ -166,7 +174,7 @@ export async function getAllContentItems( } // TODO: remove cast once @btst/db types expose adapter.count() - const total: number = await (adapter as any).count({ + const total: number = await adapter.count({ model: "contentItem", where: whereConditions, }); diff --git a/packages/stack/src/plugins/cms/types.ts b/packages/stack/src/plugins/cms/types.ts index f916a9f..23d1b57 100644 --- a/packages/stack/src/plugins/cms/types.ts +++ b/packages/stack/src/plugins/cms/types.ts @@ -178,8 +178,8 @@ export interface SerializedContentItem */ export interface SerializedContentItemWithType> extends SerializedContentItem { - /** Parsed data object (JSON.parse of data field) */ - parsedData: TData; + /** Parsed data object (JSON.parse of data field). Null when the stored JSON is corrupted. */ + parsedData: TData | null; /** Joined content type */ contentType?: SerializedContentType; /** diff --git a/packages/stack/src/plugins/form-builder/api/getters.ts b/packages/stack/src/plugins/form-builder/api/getters.ts index b800ee2..130e1b8 100644 --- a/packages/stack/src/plugins/form-builder/api/getters.ts +++ b/packages/stack/src/plugins/form-builder/api/getters.ts @@ -41,13 +41,21 @@ export function serializeFormSubmission( /** * 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: JSON.parse(submission.data), + parsedData, form: submission.form ? serializeForm(submission.form) : undefined, }; } @@ -87,7 +95,7 @@ export async function getAllForms( } // TODO: remove cast once @btst/db types expose adapter.count() - const total: number = await (adapter as any).count({ + const total: number = await adapter.count({ model: "form", where: whereConditions.length > 0 ? whereConditions : undefined, }); @@ -172,7 +180,7 @@ export async function getFormSubmissions( } // TODO: remove cast once @btst/db types expose adapter.count() - const total: number = await (adapter as any).count({ + const total: number = await adapter.count({ model: "formSubmission", where: [{ field: "formId", value: formId, operator: "eq" as const }], }); From e7bfbbfc4c4ebbfc84a002b56ccc9b36e4844bf0 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 14:35:01 -0500 Subject: [PATCH 08/20] refactor: streamline blog API response handling and improve null safety in form submissions --- packages/stack/src/plugins/blog/api/plugin.ts | 13 ++++--------- .../pages/content-editor-page.internal.tsx | 2 +- .../components/pages/submissions-page.internal.tsx | 2 +- packages/stack/src/plugins/form-builder/types.ts | 4 ++-- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts index 90cf854..f7110b8 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -6,12 +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, - type PostListResult, -} from "./getters"; +import { getAllPosts, getPostBySlug, getAllTags } from "./getters"; export const PostListQuerySchema = z.object({ slug: z.string().optional(), @@ -92,12 +87,12 @@ export interface BlogBackendHooks { /** * Called after posts are read successfully - * @param result - Paginated result containing posts and total count + * @param posts - The list of posts returned by the query * @param filter - Query parameters used for filtering * @param context - Request context */ onPostsRead?: ( - result: PostListResult, + posts: Array, filter: z.infer, context: BlogApiContext, ) => Promise | void; @@ -281,7 +276,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => const result = await getAllPosts(adapter, query); if (hooks?.onPostsRead) { - await hooks.onPostsRead(result, query, context); + await hooks.onPostsRead(result.items, query, context); } return result; 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/form-builder/client/components/pages/submissions-page.internal.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx index aae797e..acf1b0d 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx @@ -147,7 +147,7 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { {sub.id.slice(0, 8)}... - {formatSubmissionData(sub.parsedData)} + {formatSubmissionData(sub.parsedData ?? {})} {new Date(sub.submittedAt).toLocaleString()} diff --git a/packages/stack/src/plugins/form-builder/types.ts b/packages/stack/src/plugins/form-builder/types.ts index 593da41..9ccf70e 100644 --- a/packages/stack/src/plugins/form-builder/types.ts +++ b/packages/stack/src/plugins/form-builder/types.ts @@ -82,8 +82,8 @@ export interface SerializedFormSubmission export interface SerializedFormSubmissionWithData< TData = Record, > extends SerializedFormSubmission { - /** Parsed data object (JSON.parse of data field) */ - parsedData: TData; + /** Parsed data object (JSON.parse of data field). Null when the stored JSON is corrupted. */ + parsedData: TData | null; /** Joined form */ form?: SerializedForm; } From ddc5cd3a48544fc86262bba983e56a9f68b7d494 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 15:32:37 -0500 Subject: [PATCH 09/20] refactor: improve error handling in content serialization by throwing SyntaxError for invalid JSON --- packages/stack/src/plugins/cms/api/getters.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/plugins/cms/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts index 2b7c727..98ec1a3 100644 --- a/packages/stack/src/plugins/cms/api/getters.ts +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -71,18 +71,13 @@ export function serializeContentItem(item: ContentItem): SerializedContentItem { /** * Serialize a ContentItem with parsed data and joined ContentType. - * If `item.data` is corrupted JSON, `parsedData` is set to `null` rather than - * throwing, so one bad row cannot crash the entire list or SSG build. + * 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 { - let parsedData: Record | null = null; - try { - parsedData = JSON.parse(item.data); - } catch { - // Corrupted JSON — leave parsedData as null so callers can handle it - } + const parsedData = JSON.parse(item.data) as Record; return { ...serializeContentItem(item), parsedData, From c951b076664b6551470b5c30bc0b4c1b5c50a232 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 15:33:16 -0500 Subject: [PATCH 10/20] refactor: simplify task fetching logic by introducing a helper --- packages/stack/src/plugins/cms/types.ts | 4 +- .../stack/src/plugins/kanban/api/getters.ts | 126 +++++++----------- 2 files changed, 47 insertions(+), 83 deletions(-) diff --git a/packages/stack/src/plugins/cms/types.ts b/packages/stack/src/plugins/cms/types.ts index 23d1b57..9ad0159 100644 --- a/packages/stack/src/plugins/cms/types.ts +++ b/packages/stack/src/plugins/cms/types.ts @@ -178,8 +178,8 @@ export interface SerializedContentItem */ export interface SerializedContentItemWithType> extends SerializedContentItem { - /** Parsed data object (JSON.parse of data field). Null when the stored JSON is corrupted. */ - parsedData: TData | null; + /** Parsed data object (JSON.parse of data field). */ + parsedData: TData; /** Joined content type */ contentType?: SerializedContentType; /** diff --git a/packages/stack/src/plugins/kanban/api/getters.ts b/packages/stack/src/plugins/kanban/api/getters.ts index e76eb51..4214c00 100644 --- a/packages/stack/src/plugins/kanban/api/getters.ts +++ b/packages/stack/src/plugins/kanban/api/getters.ts @@ -8,6 +8,47 @@ import type { import type { z } from "zod"; import type { BoardListQuerySchema } from "../schemas"; +/** + * Given a raw board record (with a `column` join), fetches tasks for every + * column in parallel and returns the sorted columns with their tasks attached. + * Strips the raw `column` join field from the returned board object. + */ +async function hydrateColumnsWithTasks( + adapter: Adapter, + board: BoardWithKanbanColumn, +): Promise { + const columnIds = (board.column || []).map((c) => c.id); + const tasksByColumn = new Map(); + + if (columnIds.length > 0) { + const taskResults = await Promise.all( + columnIds.map((columnId) => + adapter.findMany({ + model: "kanbanTask", + where: [ + { field: "columnId", value: columnId, operator: "eq" as const }, + ], + sortBy: { field: "order", direction: "asc" }, + }), + ), + ); + for (let i = 0; i < columnIds.length; i++) { + const columnId = columnIds[i]; + const tasks = taskResults[i]; + if (columnId && tasks) { + tasksByColumn.set(columnId, tasks); + } + } + } + + const columns: ColumnWithTasks[] = (board.column || []) + .sort((a, b) => a.order - b.order) + .map((col) => ({ ...col, tasks: tasksByColumn.get(col.id) || [] })); + + const { column: _, ...boardWithoutJoin } = board; + return { ...boardWithoutJoin, columns }; +} + /** * Retrieve all boards matching optional filter criteria, with columns and tasks. * Pure DB function - no hooks, no HTTP context. Safe for SSG and server-side use. @@ -60,52 +101,9 @@ export async function getAllBoards( join: { kanbanColumn: true }, }); - // Collect all column IDs to fetch tasks - const columnIds: string[] = []; - for (const board of boards) { - if (board.column) { - for (const col of board.column) { - columnIds.push(col.id); - } - } - } - - // Fetch tasks for each column in parallel - const tasksByColumn = new Map(); - if (columnIds.length > 0) { - const taskQueries = columnIds.map((columnId) => - adapter.findMany({ - model: "kanbanTask", - where: [ - { field: "columnId", value: columnId, operator: "eq" as const }, - ], - sortBy: { field: "order", direction: "asc" }, - }), - ); - const taskResults = await Promise.all(taskQueries); - for (let i = 0; i < columnIds.length; i++) { - const columnId = columnIds[i]; - const tasks = taskResults[i]; - if (columnId && tasks) { - tasksByColumn.set(columnId, tasks); - } - } - } - - // Map boards with sorted columns and tasks - return boards.map((board) => { - const columns: ColumnWithTasks[] = (board.column || []) - .sort((a, b) => a.order - b.order) - .map((col) => ({ - ...col, - tasks: tasksByColumn.get(col.id) || [], - })); - const { column: _, ...boardWithoutJoin } = board; - return { - ...boardWithoutJoin, - columns, - }; - }); + return Promise.all( + boards.map((board) => hydrateColumnsWithTasks(adapter, board)), + ); } /** @@ -130,39 +128,5 @@ export async function getBoardById( return null; } - const columnIds = (board.column || []).map((c) => c.id); - const tasksByColumn = new Map(); - - if (columnIds.length > 0) { - const taskQueries = columnIds.map((columnId) => - adapter.findMany({ - model: "kanbanTask", - where: [ - { field: "columnId", value: columnId, operator: "eq" as const }, - ], - sortBy: { field: "order", direction: "asc" }, - }), - ); - const taskResults = await Promise.all(taskQueries); - for (let i = 0; i < columnIds.length; i++) { - const columnId = columnIds[i]; - const tasks = taskResults[i]; - if (columnId && tasks) { - tasksByColumn.set(columnId, tasks); - } - } - } - - const columns: ColumnWithTasks[] = (board.column || []) - .sort((a, b) => a.order - b.order) - .map((col) => ({ - ...col, - tasks: tasksByColumn.get(col.id) || [], - })); - - const { column: _, ...boardWithoutJoin } = board; - return { - ...boardWithoutJoin, - columns, - }; + return hydrateColumnsWithTasks(adapter, board); } From bc4073abbe98850a819ee9531b15c3a4e1650b6f Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 15:54:24 -0500 Subject: [PATCH 11/20] feat: blog performance improvements --- .../plugins/blog/__tests__/getters.test.ts | 183 ++++++++++++++++++ .../stack/src/plugins/blog/api/getters.ts | 118 ++++++----- 2 files changed, 237 insertions(+), 64 deletions(-) diff --git a/packages/stack/src/plugins/blog/__tests__/getters.test.ts b/packages/stack/src/plugins/blog/__tests__/getters.test.ts index e315820..9ad2e33 100644 --- a/packages/stack/src/plugins/blog/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/blog/__tests__/getters.test.ts @@ -221,6 +221,189 @@ describe("blog getters", () => { 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("total reflects count before pagination slice for in-memory filters", async () => { for (let i = 1; i <= 4; i++) { await adapter.create({ diff --git a/packages/stack/src/plugins/blog/api/getters.ts b/packages/stack/src/plugins/blog/api/getters.ts index 4e1e744..e437552 100644 --- a/packages/stack/src/plugins/blog/api/getters.ts +++ b/packages/stack/src/plugins/blog/api/getters.ts @@ -41,18 +41,20 @@ export async function getAllPosts( ): Promise { const query = params ?? {}; - let tagFilterPostIds: Set | null = null; - + 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, - }, - ], + where: [{ field: "slug", value: query.tagSlug, operator: "eq" as const }], }); if (!tag) { @@ -61,21 +63,20 @@ export async function getAllPosts( const postTags = await adapter.findMany<{ postId: string; tagId: string }>({ model: "postTag", - where: [ - { - field: "tagId", - value: tag.id, - operator: "eq" as const, - }, - ], + where: [{ field: "tagId", value: tag.id, operator: "eq" as const }], }); - tagFilterPostIds = new Set(postTags.map((pt) => pt.postId)); - if (tagFilterPostIds.size === 0) { + + const taggedPostIds = postTags.map((pt) => pt.postId); + if (taggedPostIds.length === 0) { return { items: [], total: 0, limit: query.limit, offset: query.offset }; } - } - const whereConditions = []; + whereConditions.push({ + field: "id", + value: taggedPostIds, + operator: "in" as const, + }); + } if (query.published !== undefined) { whereConditions.push({ @@ -93,32 +94,30 @@ export async function getAllPosts( }); } - // For DB-paginated paths (no in-memory filtering), count total up front. - // For in-memory-filtered paths (query/tagSlug), total is computed after filtering. - // TODO: remove cast once @btst/db types expose adapter.count() - const dbPaginationOnly = !query.query && !query.tagSlug; - const dbTotal: number | undefined = dbPaginationOnly - ? await adapter.count({ - model: "post", - where: whereConditions.length > 0 ? whereConditions : undefined, - }) + // 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: dbPaginationOnly ? (query.limit ?? 10) : undefined, - offset: dbPaginationOnly ? (query.offset ?? 0) : undefined, + limit: !needsInMemoryFilter ? (query.limit ?? 10) : undefined, + offset: !needsInMemoryFilter ? (query.offset ?? 0) : undefined, where: whereConditions, - sortBy: { - field: "createdAt", - direction: "desc", - }, - join: { - postTag: true, - }, + sortBy: { field: "createdAt", direction: "desc" }, + join: { postTag: true }, }); - // Collect unique tag IDs + // 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) { @@ -128,21 +127,21 @@ export async function getAllPosts( } } - // Fetch all tags at once 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(); - for (const tag of tags) { - if (tagIds.has(tag.id)) { - tagMap.set(tag.id, tag); - } - } + const tagMap = new Map(tags.map((t) => [t.id, t])); - // Map tags to posts let result = posts.map((post) => { const postTags = (post.postTag || []) .map((pt) => { @@ -151,28 +150,20 @@ export async function getAllPosts( }) .filter((tag): tag is Tag => tag !== undefined); const { postTag: _, ...postWithoutJoin } = post; - return { - ...postWithoutJoin, - tags: postTags, - }; + 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; - }); + result = result.filter( + (post) => + post.title?.toLowerCase().includes(searchLower) || + post.content?.toLowerCase().includes(searchLower) || + post.excerpt?.toLowerCase().includes(searchLower), + ); } - if (query.tagSlug || query.query) { - // Capture total after in-memory filters but before pagination slice + if (needsInMemoryFilter) { const total = result.length; const offset = query.offset ?? 0; const limit = query.limit ?? 10; @@ -180,7 +171,6 @@ export async function getAllPosts( return { items: result, total, limit: query.limit, offset: query.offset }; } - // DB-paginated path: total was fetched with adapter.count() above return { items: result, total: dbTotal ?? result.length, From 82da42bc116805b398b0bc3e40524487ff2ba50d Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 15:58:59 -0500 Subject: [PATCH 12/20] refactor: enhance tag handling in blog API to return empty results for non-existent tags --- packages/stack/src/plugins/blog/api/plugin.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts index f7110b8..7fdc9d4 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -273,6 +273,56 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => } } + // 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", + 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, + }, + ], + }); + + if (postTags.length === 0) { + return { + items: [], + total: 0, + limit: query.limit, + offset: query.offset, + }; + } + } + const result = await getAllPosts(adapter, query); if (hooks?.onPostsRead) { From a17840b39dbdd6fde8831b23470ae85bf8b9d1d0 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:02:53 -0500 Subject: [PATCH 13/20] refactor: optimize getPostBySlug to improve tag resolution and handle empty post scenarios --- .../stack/src/plugins/blog/api/getters.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/plugins/blog/api/getters.ts b/packages/stack/src/plugins/blog/api/getters.ts index e437552..daed826 100644 --- a/packages/stack/src/plugins/blog/api/getters.ts +++ b/packages/stack/src/plugins/blog/api/getters.ts @@ -194,8 +194,33 @@ export async function getPostBySlug( adapter: Adapter, slug: string, ): Promise<(Post & { tags: Tag[] }) | null> { - const { items } = await getAllPosts(adapter, { slug }); - return items[0] ?? 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 }; } /** From 0a80c9de1aa0c5dc9f2f57df29ddf465350c164a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:14:27 -0500 Subject: [PATCH 14/20] refactor: update blog API tests and client to handle PostListResult structure for drafts and published posts --- e2e/tests/smoke.auth-blog.spec.ts | 25 ++++++++++++------- .../stack/src/plugins/blog/client/plugin.tsx | 3 ++- 2 files changed, 18 insertions(+), 10 deletions(-) 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/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; From 8106495a917bee5c89f067799cf16c61582be879 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:20:03 -0500 Subject: [PATCH 15/20] refactor: update blog API documentation --- docs/content/docs/plugins/blog.mdx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 271fdb9..d3d3cfa 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -542,9 +542,14 @@ After calling `stack()`, the returned object includes a fully-typed `api` namesp 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(); +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)** @@ -556,19 +561,23 @@ import { getAllPosts, getPostBySlug, getAllTags } from "@btst/stack/plugins/blog // e.g. in Next.js generateStaticParams export async function generateStaticParams() { - const posts = await getAllPosts(myAdapter, { published: true }); - return posts.map((p) => ({ slug: p.slug })); + const { items } = await getAllPosts(myAdapter, { published: true }); + return items.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 | +| 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` + + From 0dd81d1537175badbec998e989fb07ccc3b1b6f7 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:28:36 -0500 Subject: [PATCH 16/20] refactor: make getAllPosts functionality consistent with other plugins when limit is not provided --- .../plugins/blog/__tests__/getters.test.ts | 46 +++++++++++++++++++ .../stack/src/plugins/blog/api/getters.ts | 11 +++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/stack/src/plugins/blog/__tests__/getters.test.ts b/packages/stack/src/plugins/blog/__tests__/getters.test.ts index 9ad2e33..aca0a53 100644 --- a/packages/stack/src/plugins/blog/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/blog/__tests__/getters.test.ts @@ -404,6 +404,52 @@ describe("blog getters", () => { 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({ diff --git a/packages/stack/src/plugins/blog/api/getters.ts b/packages/stack/src/plugins/blog/api/getters.ts index daed826..9ee039c 100644 --- a/packages/stack/src/plugins/blog/api/getters.ts +++ b/packages/stack/src/plugins/blog/api/getters.ts @@ -109,8 +109,8 @@ export async function getAllPosts( const posts = await adapter.findMany({ model: "post", - limit: !needsInMemoryFilter ? (query.limit ?? 10) : undefined, - offset: !needsInMemoryFilter ? (query.offset ?? 0) : undefined, + limit: !needsInMemoryFilter ? query.limit : undefined, + offset: !needsInMemoryFilter ? query.offset : undefined, where: whereConditions, sortBy: { field: "createdAt", direction: "desc" }, join: { postTag: true }, @@ -166,8 +166,11 @@ export async function getAllPosts( if (needsInMemoryFilter) { const total = result.length; const offset = query.offset ?? 0; - const limit = query.limit ?? 10; - result = result.slice(offset, offset + limit); + const limit = query.limit; + result = result.slice( + offset, + limit !== undefined ? offset + limit : undefined, + ); return { items: result, total, limit: query.limit, offset: query.offset }; } From 83bc7c96f5e70534ed11b90772e40d9277f77dd6 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:30:31 -0500 Subject: [PATCH 17/20] refactor: update kanban API to return paginated results for boards with total count to make consistent with other plugins --- docs/content/docs/plugins/kanban.mdx | 24 ++++++++---- .../plugins/kanban/__tests__/getters.test.ts | 23 +++++++----- .../stack/src/plugins/kanban/api/getters.ts | 37 ++++++++++++++----- .../stack/src/plugins/kanban/api/index.ts | 2 +- .../stack/src/plugins/kanban/api/plugin.ts | 4 +- .../stack/src/plugins/kanban/query-keys.ts | 13 ++++--- 6 files changed, 68 insertions(+), 35 deletions(-) diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 6017a40..657f217 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -739,10 +739,14 @@ The Kanban plugin exposes standalone getter functions for server-side and SSG us import { myStack } from "./stack"; // List all boards (with columns and tasks) -const boards = await myStack.api.kanban.getAllBoards({ ownerId: "user-123" }); +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"); +const board = await myStack.api.kanban.getBoardById("board-456"); if (board) { board.columns.forEach((col) => { console.log(col.title, col.tasks.length, "tasks"); @@ -760,14 +764,18 @@ import { // In Next.js generateStaticParams export async function generateStaticParams() { - const boards = await getAllBoards(myAdapter); - return boards.map((b) => ({ slug: b.slug })); + const { items } = await getAllBoards(myAdapter); + return items.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` | +| 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/packages/stack/src/plugins/kanban/__tests__/getters.test.ts b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts index 5359db1..58f54d0 100644 --- a/packages/stack/src/plugins/kanban/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts @@ -84,8 +84,9 @@ describe("kanban getters", () => { const col = (await createColumn(adapter, board.id, "To Do", 0)) as any; await createTask(adapter, col.id, "Task 1", 0); - const boards = await getAllBoards(adapter); + const { items: boards, total } = await getAllBoards(adapter); expect(boards).toHaveLength(1); + expect(total).toBe(1); expect(boards[0]!.slug).toBe("my-board"); expect(boards[0]!.columns).toHaveLength(1); expect(boards[0]!.columns[0]!.title).toBe("To Do"); @@ -96,7 +97,7 @@ describe("kanban getters", () => { it("returns boards with empty columns array when no columns exist", async () => { await createBoard(adapter, "Empty Board", "empty-board"); - const boards = await getAllBoards(adapter); + const { items: boards } = await getAllBoards(adapter); expect(boards).toHaveLength(1); expect(boards[0]!.columns).toEqual([]); }); @@ -105,18 +106,22 @@ describe("kanban getters", () => { await createBoard(adapter, "Board A", "board-a"); await createBoard(adapter, "Board B", "board-b"); - const result = await getAllBoards(adapter, { slug: "board-a" }); - expect(result).toHaveLength(1); - expect(result[0]!.slug).toBe("board-a"); + const { items, total } = await getAllBoards(adapter, { slug: "board-a" }); + expect(items).toHaveLength(1); + expect(total).toBe(1); + expect(items[0]!.slug).toBe("board-a"); }); it("filters boards by ownerId", async () => { await createBoard(adapter, "Alice Board", "alice-board", "user-alice"); await createBoard(adapter, "Bob Board", "bob-board", "user-bob"); - const result = await getAllBoards(adapter, { ownerId: "user-alice" }); - expect(result).toHaveLength(1); - expect(result[0]!.slug).toBe("alice-board"); + const { items, total } = await getAllBoards(adapter, { + ownerId: "user-alice", + }); + expect(items).toHaveLength(1); + expect(total).toBe(1); + expect(items[0]!.slug).toBe("alice-board"); }); it("sorts columns by order", async () => { @@ -126,7 +131,7 @@ describe("kanban getters", () => { await createColumn(adapter, board.id, "To Do", 0); await createColumn(adapter, board.id, "In Progress", 1); - const boards = await getAllBoards(adapter); + const { items: boards } = await getAllBoards(adapter); expect(boards[0]!.columns[0]!.title).toBe("To Do"); expect(boards[0]!.columns[1]!.title).toBe("In Progress"); expect(boards[0]!.columns[2]!.title).toBe("Done"); diff --git a/packages/stack/src/plugins/kanban/api/getters.ts b/packages/stack/src/plugins/kanban/api/getters.ts index 4214c00..26ebcbe 100644 --- a/packages/stack/src/plugins/kanban/api/getters.ts +++ b/packages/stack/src/plugins/kanban/api/getters.ts @@ -8,6 +8,16 @@ import type { import type { z } from "zod"; import type { BoardListQuerySchema } from "../schemas"; +/** + * Paginated result returned by {@link getAllBoards}. + */ +export interface BoardListResult { + items: BoardWithColumns[]; + total: number; + limit?: number; + offset?: number; +} + /** * Given a raw board record (with a `column` join), fetches tasks for every * column in parallel and returns the sorted columns with their tasks attached. @@ -59,7 +69,7 @@ async function hydrateColumnsWithTasks( export async function getAllBoards( adapter: Adapter, params?: z.infer, -): Promise { +): Promise { const query = params ?? {}; const whereConditions: Array<{ @@ -92,18 +102,25 @@ export async function getAllBoards( }); } - const boards = await adapter.findMany({ - model: "kanbanBoard", - limit: query.limit ?? 50, - offset: query.offset ?? 0, - where: whereConditions.length > 0 ? whereConditions : undefined, - sortBy: { field: "createdAt", direction: "desc" }, - join: { kanbanColumn: true }, - }); + const where = whereConditions.length > 0 ? whereConditions : undefined; - return Promise.all( + const [boards, total] = await Promise.all([ + adapter.findMany({ + model: "kanbanBoard", + limit: query.limit ?? 50, + offset: query.offset ?? 0, + where, + sortBy: { field: "createdAt", direction: "desc" }, + join: { kanbanColumn: true }, + }), + adapter.count({ model: "kanbanBoard", where }), + ]); + + const items = await Promise.all( boards.map((board) => hydrateColumnsWithTasks(adapter, board)), ); + + return { items, total, limit: query.limit, offset: query.offset }; } /** diff --git a/packages/stack/src/plugins/kanban/api/index.ts b/packages/stack/src/plugins/kanban/api/index.ts index f49cb2f..7da737f 100644 --- a/packages/stack/src/plugins/kanban/api/index.ts +++ b/packages/stack/src/plugins/kanban/api/index.ts @@ -4,4 +4,4 @@ export { type KanbanApiContext, type KanbanBackendHooks, } from "./plugin"; -export { getAllBoards, getBoardById } from "./getters"; +export { getAllBoards, getBoardById, type BoardListResult } from "./getters"; diff --git a/packages/stack/src/plugins/kanban/api/plugin.ts b/packages/stack/src/plugins/kanban/api/plugin.ts index eeacb46..ccb60ab 100644 --- a/packages/stack/src/plugins/kanban/api/plugin.ts +++ b/packages/stack/src/plugins/kanban/api/plugin.ts @@ -17,7 +17,7 @@ import { updateColumnSchema, updateTaskSchema, } from "../schemas"; -import { getAllBoards, getBoardById } from "./getters"; +import { getAllBoards, getBoardById, type BoardListResult } from "./getters"; /** * Context passed to kanban API hooks @@ -82,7 +82,7 @@ export interface KanbanBackendHooks { * Called after boards are listed successfully */ onBoardsRead?: ( - boards: Board[], + result: BoardListResult, filter: z.infer, context: KanbanApiContext, ) => Promise | void; diff --git a/packages/stack/src/plugins/kanban/query-keys.ts b/packages/stack/src/plugins/kanban/query-keys.ts index ae2a04f..e21feac 100644 --- a/packages/stack/src/plugins/kanban/query-keys.ts +++ b/packages/stack/src/plugins/kanban/query-keys.ts @@ -91,8 +91,10 @@ function createBoardsQueries( throw toError(errorResponse.error); } - return ((response as { data?: unknown }).data ?? - []) as unknown as SerializedBoardWithColumns[]; + const envelope = (response as { data?: unknown }).data as + | { items?: SerializedBoardWithColumns[] } + | undefined; + return envelope?.items ?? ([] as SerializedBoardWithColumns[]); } catch (error) { throw error; } @@ -142,9 +144,10 @@ function createBoardsQueries( throw toError(errorResponse.error); } - const boards = ((response as { data?: unknown }).data ?? - []) as unknown as SerializedBoardWithColumns[]; - return boards[0] ?? null; + const envelope = (response as { data?: unknown }).data as + | { items?: SerializedBoardWithColumns[] } + | undefined; + return envelope?.items?.[0] ?? null; } catch (error) { throw error; } From abcad843803d2720c43e876315be4dec2b2a8462 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:49:20 -0500 Subject: [PATCH 18/20] refactor: update getAllBoards test to validate paginated results structure with total count --- packages/stack/src/plugins/kanban/__tests__/getters.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/plugins/kanban/__tests__/getters.test.ts b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts index 58f54d0..0f61dab 100644 --- a/packages/stack/src/plugins/kanban/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/kanban/__tests__/getters.test.ts @@ -75,8 +75,9 @@ describe("kanban getters", () => { describe("getAllBoards", () => { it("returns empty array when no boards exist", async () => { - const boards = await getAllBoards(adapter); - expect(boards).toEqual([]); + const { items, total } = await getAllBoards(adapter); + expect(items).toEqual([]); + expect(total).toBe(0); }); it("returns all boards with columns and tasks", async () => { From 141e597f2064960f37ca5c2f901f91a185d19e19 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 16:57:54 -0500 Subject: [PATCH 19/20] refactor: update kanban API to ensure consistency in onBoardsRead hook --- .../stack/src/plugins/kanban/api/plugin.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/stack/src/plugins/kanban/api/plugin.ts b/packages/stack/src/plugins/kanban/api/plugin.ts index ccb60ab..0e0674d 100644 --- a/packages/stack/src/plugins/kanban/api/plugin.ts +++ b/packages/stack/src/plugins/kanban/api/plugin.ts @@ -3,7 +3,13 @@ import { defineBackendPlugin } from "@btst/stack/plugins/api"; import { createEndpoint } from "@btst/stack/plugins/api"; import { z } from "zod"; import { kanbanSchema as dbSchema } from "../db"; -import type { Board, Column, ColumnWithTasks, Task } from "../types"; +import type { + Board, + BoardWithColumns, + Column, + ColumnWithTasks, + Task, +} from "../types"; import { slugify } from "../utils"; import { BoardListQuerySchema, @@ -17,7 +23,7 @@ import { updateColumnSchema, updateTaskSchema, } from "../schemas"; -import { getAllBoards, getBoardById, type BoardListResult } from "./getters"; +import { getAllBoards, getBoardById } from "./getters"; /** * Context passed to kanban API hooks @@ -79,10 +85,12 @@ export interface KanbanBackendHooks { ) => Promise | boolean; /** - * Called after boards are listed successfully + * Called after boards are listed successfully. + * Receives the items array (same shape as `board[]`) for consistency + * with analogous hooks in other plugins (e.g. `onPostsRead`). */ onBoardsRead?: ( - result: BoardListResult, + boards: BoardWithColumns[], filter: z.infer, context: KanbanApiContext, ) => Promise | void; @@ -288,7 +296,7 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => const result = await getAllBoards(adapter, query); if (hooks?.onBoardsRead) { - await hooks.onBoardsRead(result, query, context); + await hooks.onBoardsRead(result.items, query, context); } return result; From 72ddce780f641b4be982cb236f38e585a1081024 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 20 Feb 2026 17:01:23 -0500 Subject: [PATCH 20/20] refactor: update kanban API response handling to correctly extract board items --- packages/stack/src/plugins/kanban/client/plugin.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/plugins/kanban/client/plugin.tsx b/packages/stack/src/plugins/kanban/client/plugin.tsx index f176145..27416bc 100644 --- a/packages/stack/src/plugins/kanban/client/plugin.tsx +++ b/packages/stack/src/plugins/kanban/client/plugin.tsx @@ -437,7 +437,8 @@ export const kanbanClientPlugin = (config: KanbanClientConfig) => method: "GET", query: { limit: 100 }, }); - boards = ((res as { data?: unknown }).data ?? + // /boards returns BoardListResult { items, total, limit, offset } + boards = ((res.data as any)?.items ?? []) as SerializedBoardWithColumns[]; } catch { // Ignore errors for sitemap