diff --git a/AGENTS.md b/AGENTS.md index 9628b47..7ad38f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,120 @@ const item = await myStack.api["{name}"].getItemById("abc") - Re-export getters from `api/index.ts` for consumers who need direct import (SSG/build-time) - Authorization hooks are **not** called via `stack().api.*` — callers are responsible for access control +### SSG Support (`prefetchForRoute`) + +`route.loader()` makes HTTP requests that **silently fail at `next build`** (no server running). Plugins that support SSG expose `prefetchForRoute` on the `api` factory to seed the React Query cache directly from the DB instead. + +**Required files per plugin:** + +| File | Purpose | +|---|---| +| `api/query-key-defs.ts` | Shared key shapes — import into both `query-keys.ts` and `prefetchForRoute` to prevent drift | +| `api/serializers.ts` | Convert `Date` fields to ISO strings before `setQueryData` | +| `api/getters.ts` | Add any ID-based getters `prefetchForRoute` needs (e.g. `getItemById`) | +| `api/plugin.ts` | `RouteKey` type + typed overloads + wire `prefetchForRoute` into `api` factory | +| `api/index.ts` | Re-export `RouteKey`, serializers, `PLUGIN_QUERY_KEYS` | +| `query-keys.ts` | Import discriminator fn from `api/query-key-defs.ts` | +| `client/plugin.tsx` | Import and call `isConnectionError` in each loader `catch` block | + +**`api/query-key-defs.ts`:** +```typescript +export function itemsListDiscriminator(params?: { limit?: number }) { + return { limit: params?.limit ?? 20 } +} +export const PLUGIN_QUERY_KEYS = { + itemsList: (params?: { limit?: number }) => + ["items", "list", itemsListDiscriminator(params)] as const, + itemDetail: (id: string) => ["items", "detail", id] as const, +} +``` + +**`prefetchForRoute` in `api/plugin.ts`:** +```typescript +export type PluginRouteKey = "list" | "detail" | "new" + +// Typed overloads enforce correct params per route key +interface PluginPrefetchForRoute { + (key: "list" | "new", qc: QueryClient): Promise + (key: "detail", qc: QueryClient, params: { id: string }): Promise +} + +function createPluginPrefetchForRoute(adapter: Adapter): PluginPrefetchForRoute { + return async function prefetchForRoute(key, qc, params?) { + switch (key) { + case "list": { + const { items, total, limit, offset } = await listItems(adapter) + // useInfiniteQuery lists require { pages, pageParams } shape + qc.setQueryData(PLUGIN_QUERY_KEYS.itemsList(), { + pages: [{ items: items.map(serializeItem), total, limit, offset }], + pageParams: [0], + }) + break + } + case "detail": { + const item = await getItemById(adapter, params!.id) + if (item) qc.setQueryData(PLUGIN_QUERY_KEYS.itemDetail(params!.id), serializeItem(item)) + break + } + case "new": break + } + } as PluginPrefetchForRoute +} + +api: (adapter) => ({ + listItems: () => listItems(adapter), + prefetchForRoute: createPluginPrefetchForRoute(adapter), +}) +``` + +Rules: serialize `Date` → ISO string; for plugins with a one-time init step (e.g. CMS `ensureSynced`), call it once at the top of `prefetchForRoute` — it is idempotent and safe for concurrent SSG calls. + +**Build-time warning in `client/plugin.tsx` loader `catch` blocks:** +```typescript +import { isConnectionError } from "@btst/stack/plugins/client" + +// in each loader catch block: +if (isConnectionError(error)) { + console.warn("[btst/{plugin}] route.loader() failed — no server at build time. Use myStack.api.{plugin}.prefetchForRoute() for SSG.") +} +``` + +**SSG `page.tsx` pattern (Next.js — outside `[[...all]]/`):** +```tsx +export async function generateStaticParams() { return [{}] } +// export const revalidate = 3600 // ISR + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["{plugin}"])) + if (!route) return { title: "Fallback" } + await myStack.api.{plugin}.prefetchForRoute("list", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function Page() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["{plugin}"])) + if (!route) notFound() + await myStack.api.{plugin}.prefetchForRoute("list", queryClient) + return +} +``` + +The shared `StackProvider` layout must be at `app/pages/layout.tsx` (not `[[...all]]/layout.tsx`) so it applies to both SSG pages and the catch-all. + +**Plugins with SSG support:** + +| Plugin | Prefetched route keys | Skipped | +|---|---|---| +| Blog | `posts`, `drafts`, `post`, `tag`, `editPost` | `newPost` | +| CMS | `dashboard`, `contentList`, `editContent` | `newContent` | +| Form Builder | `formList`, `editForm`, `submissions` | `newForm` | +| Kanban | `boards`, `board` | `newBoard` | +| AI Chat | — (per-user, not static) | all | + ### Query Keys Factory Create a query keys file for React Query integration: @@ -568,3 +682,11 @@ The `AutoTypeTable` component automatically pulls from TypeScript files, so ensu 11. **`stack().api` bypasses authorization hooks** - Getters accessed via `myStack.api.*` skip all `onBefore*` hooks. Never use them as a substitute for authenticated HTTP endpoints — enforce access control at the call site. 12. **Plugin init steps not called via `api`** - If a plugin's `routes` factory runs a one-time setup (e.g. CMS `syncContentTypes`), that same setup must also be awaited inside the `api` getter wrappers, otherwise direct getter calls will query an uninitialised database. + +13. **`route.loader()` silently fails at build time** - No HTTP server exists during `next build`, so fetches fail silently and the static page renders empty. Use `myStack.api.{plugin}.prefetchForRoute()` in SSG pages instead. + +14. **Query key drift between HTTP and SSG paths** - Share key builders via `api/query-key-defs.ts`; import discriminator functions into `query-keys.ts`. Never hardcode key shapes in two places. + +15. **Wrong data shape for infinite queries** - Lists backed by `useInfiniteQuery` need `{ pages: [...], pageParams: [...] }` in `setQueryData`. Flat arrays will break hydration. + +16. **Dates not serialized before `setQueryData`** - DB getters return `Date` objects; the HTTP cache holds ISO strings. Always serialize (e.g. `serializePost`) before `setQueryData`. diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index d3d3cfa..c1c2445 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -581,3 +581,112 @@ export async function generateStaticParams() { ### `PostListResult` + +## Static Site Generation (SSG) + +`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. + +### `prefetchForRoute(routeKey, queryClient, params?)` + +| Route key | Params required | Data prefetched | +|---|---|---| +| `"posts"` | — | Published posts list | +| `"drafts"` | — | Draft posts list | +| `"post"` | `{ slug: string }` | Single post detail | +| `"tag"` | `{ tagSlug: string }` | Tag + tagged posts | +| `"newPost"` | — | *(nothing)* | +| `"editPost"` | `{ slug: string }` | Post to edit | + +### Next.js example + +```tsx title="app/pages/blog/page.tsx" +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +// Opt into SSG — Next.js generates this page at build time +export async function generateStaticParams() { + return [{}] +} + +// export const revalidate = 3600 // uncomment for ISR (1 hour) + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) return { title: "Blog" } + await myStack.api.blog.prefetchForRoute("posts", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function BlogListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) return null + // Reads directly from DB — works at build time, no HTTP server required + await myStack.api.blog.prefetchForRoute("posts", queryClient) + return ( + + + + ) +} +``` + +For individual post pages, also generate the static params list: + +```tsx title="app/pages/blog/[slug]/page.tsx" +export async function generateStaticParams() { + const { items } = await myStack.api.blog.getAllPosts({ published: true, limit: 1000 }) + return items.map((p) => ({ slug: p.slug })) +} + +export default async function BlogPostPage({ params }: { params: { slug: string } }) { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", params.slug])) + if (!route) return null + await myStack.api.blog.prefetchForRoute("post", queryClient, { slug: params.slug }) + return ( + + + + ) +} +``` + +### ISR cache invalidation + +If you use [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration), the static page cache must be purged whenever content changes. Wire up `revalidatePath` (or `revalidateTag`) inside the backend lifecycle hooks so Next.js regenerates the page on the next request: + +```ts title="lib/stack.ts" +import { revalidatePath } from "next/cache" +import type { BlogBackendHooks } from "@btst/stack/plugins/blog" + +const blogHooks: BlogBackendHooks = { + onPostCreated: async (post) => { + revalidatePath("/blog") + revalidatePath(`/blog/${post.slug}`) + }, + onPostUpdated: async (post) => { + revalidatePath("/blog") + revalidatePath(`/blog/${post.slug}`) + }, + onPostDeleted: async (postId) => { + revalidatePath("/blog") + }, +} +``` + + +`revalidatePath` / `revalidateTag` are Next.js APIs — import them from `"next/cache"`. They are no-ops outside of a Next.js runtime, so this pattern is safe to use in the `lib/stack.ts` shared file without breaking other frameworks. + + +### Query key consistency + +`prefetchForRoute` uses the same query key shapes as `createBlogQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/blog/api` as `BLOG_QUERY_KEYS` and `postsListDiscriminator`, so the two paths can never drift silently. diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index cb1e51f..31083a4 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -1287,3 +1287,89 @@ export async function generateStaticParams() { | `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` | + +## Static Site Generation (SSG) + +`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. + +### `prefetchForRoute(routeKey, queryClient, params?)` + +| Route key | Params required | Data prefetched | +|---|---|---| +| `"dashboard"` | — | All content types (with item counts) | +| `"contentList"` | `{ typeSlug: string }` | Content types + first page of items | +| `"newContent"` | — | All content types | +| `"editContent"` | `{ typeSlug: string; id: string }` | Content types + specific item | + + +`prefetchForRoute` calls `ensureSynced(adapter)` internally before any DB query. This function is idempotent — concurrent calls during `generateStaticParams` + `generateMetadata` + `page` all share the same Promise and the schema sync runs exactly once. + + +### Next.js example + +```tsx title="app/pages/cms/[typeSlug]/page.tsx" +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +// Generate one static page per content type slug +export async function generateStaticParams() { + const types = await myStack.api.cms.getAllContentTypes() + return types.map((t) => ({ typeSlug: t.slug })) +} + +export async function generateMetadata( + { params }: { params: { typeSlug: string } } +): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", params.typeSlug])) + if (!route) return { title: "Content" } + await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function ContentListPage({ params }: { params: { typeSlug: string } }) { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", params.typeSlug])) + if (!route) return null + await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug }) + return ( + + + + ) +} +``` + +### ISR cache invalidation + +If you use [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration), call `revalidatePath` inside the backend lifecycle hooks so Next.js regenerates the page on the next request: + +```ts title="lib/stack.ts" +import { revalidatePath } from "next/cache" +import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" + +cmsBackendPlugin({ + contentTypes: { ... }, + hooks: { + onAfterCreate: async (item, context) => { + revalidatePath(`/cms/${context.typeSlug}`, "page") + }, + onAfterUpdate: async (item, context) => { + revalidatePath(`/cms/${context.typeSlug}`, "page") + }, + onAfterDelete: async (id, context) => { + revalidatePath(`/cms/${context.typeSlug}`, "page") + }, + }, +}) +``` + +### Query key consistency + +`prefetchForRoute` uses the same query key shapes as `createCMSQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/cms/api` as `CMS_QUERY_KEYS` and `contentListDiscriminator`, so the two paths can never drift silently. diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index dfb3cfd..c0e5a28 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -21,7 +21,10 @@ You can create plugins **inside your project** (like the [Todo example](#in-proj ``` your-plugin/ ├── api/ -│ └── backend.ts # Backend plugin with API endpoints +│ ├── backend.ts # Backend plugin (defineBackendPlugin) +│ ├── getters.ts # Pure DB functions — no HTTP context +│ ├── query-key-defs.ts # Shared query key shapes (prevents SSG/SSR drift) +│ └── serializers.ts # Convert Date fields to strings for the query cache ├── client/ │ ├── client.tsx # Client plugin with routes │ ├── hooks.tsx # React Query hooks @@ -50,7 +53,8 @@ import { import { defineClientPlugin, // Create a client plugin createRoute, // Define a route - createApiClient // Type-safe API client + createApiClient, // Type-safe API client + isConnectionError // Detect build-time "no server" fetch failures } from "@btst/stack/plugins/client" ``` @@ -436,26 +440,38 @@ export const todosClientPlugin = (config: TodosClientConfig) => ### SSR Data Loaders -Loaders prefetch data during server-side rendering: +Loaders prefetch data during server-side rendering. Always add an `isConnectionError` check in the `catch` block so developers get an actionable warning if they call `route.loader()` during `next build` when no HTTP server is running (instead of a silent empty page): ```typescript +import { createApiClient, isConnectionError } from "@btst/stack/plugins/client" + function todosLoader(config: TodosClientConfig) { return async () => { // Only run on server if (typeof window === "undefined") { const { queryClient, apiBasePath, apiBaseURL } = config - await queryClient.prefetchQuery({ - queryKey: ["todos"], - queryFn: async () => { - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }) - const response = await client("/todos", { method: "GET" }) - return response.data - }, - }) + try { + await queryClient.prefetchQuery({ + queryKey: ["todos"], + queryFn: async () => { + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }) + const response = await client("/todos", { method: "GET" }) + return response.data + }, + }) + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[your-plugin] route.loader() failed — no server running at build time. " + + "Use myStack.api.todos.prefetchForRoute() for SSG data prefetching." + ) + } + // Don't re-throw — let Error Boundaries handle it during render + } } } } @@ -483,6 +499,174 @@ function createTodosMeta(config: TodosClientConfig, path: string) { } ``` +### Static Site Generation (SSG) + +`route.loader()` makes HTTP requests that **fail silently** during `next build` because no HTTP server is running. Plugins that support SSG must expose a `prefetchForRoute` method on the `api` factory so consumers can seed the query cache directly from the database at build time. + +#### 1. Shared query key constants (`api/query-key-defs.ts`) + +Create a file that both `query-keys.ts` (the HTTP client path) and `prefetchForRoute` (the DB path) import from. This prevents the two paths drifting out of sync silently: + +```typescript +// api/query-key-defs.ts +export function todosListDiscriminator(params?: { limit?: number }) { + return { limit: params?.limit ?? 20 } +} + +export const TODO_QUERY_KEYS = { + list: (params?: { limit?: number }) => + ["todos", "list", todosListDiscriminator(params)] as const, + detail: (id: string) => ["todos", "detail", id] as const, +} +``` + +Import `todosListDiscriminator` in `query-keys.ts` so both paths use the identical key shape. + +#### 2. Serializers (`api/serializers.ts`) + +DB getters return `Date` objects; the HTTP path returns ISO strings. Always serialize before calling `setQueryData`: + +```typescript +// api/serializers.ts +import type { Todo } from "../types" + +export function serializeTodo(todo: Todo) { + return { + ...todo, + createdAt: todo.createdAt.toISOString(), + } +} +``` + +#### 3. `RouteKey` type and `prefetchForRoute` overloads (`api/backend.ts`) + +Use typed function overloads so TypeScript enforces the correct `params` per route: + +```typescript +import type { QueryClient } from "@tanstack/react-query" +import { TODO_QUERY_KEYS } from "./query-key-defs" +import { serializeTodo } from "./serializers" +import { listTodos, getTodoById } from "./getters" + +export type TodosRouteKey = "list" | "detail" | "new" + +interface TodosPrefetchForRoute { + (key: "list" | "new", qc: QueryClient): Promise + (key: "detail", qc: QueryClient, params: { id: string }): Promise +} + +function createTodosPrefetchForRoute(adapter: Adapter): TodosPrefetchForRoute { + return async function prefetchForRoute( + key: TodosRouteKey, + qc: QueryClient, + params?: Record, + ): Promise { + switch (key) { + case "list": { + const todos = await listTodos(adapter) + // Lists backed by useInfiniteQuery need the { pages, pageParams } shape + qc.setQueryData(TODO_QUERY_KEYS.list(), { + pages: [todos.map(serializeTodo)], + pageParams: [0], + }) + break + } + case "detail": { + const todo = await getTodoById(adapter, params!.id) + if (todo) qc.setQueryData(TODO_QUERY_KEYS.detail(params!.id), serializeTodo(todo)) + break + } + case "new": + break // no data needed + } + } as TodosPrefetchForRoute +} + +export const todosBackendPlugin = defineBackendPlugin({ + name: "todos", + dbPlugin: dbSchema, + api: (adapter) => ({ + listTodos: () => listTodos(adapter), + getTodoById: (id: string) => getTodoById(adapter, id), + prefetchForRoute: createTodosPrefetchForRoute(adapter), // ← SSG entry point + }), + routes: (adapter) => { /* ... HTTP endpoints */ }, +}) +``` + +#### 4. SSG `page.tsx` (consumer side, Next.js App Router) + +The consumer creates a dedicated static page outside `[[...all]]/` that calls `prefetchForRoute` instead of `route.loader()`: + +```tsx +// app/pages/todos/page.tsx +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { return [{}] } +// export const revalidate = 3600 // uncomment for ISR + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["todos"])) + if (!route) return { title: "Todos" } + await myStack.api.todos.prefetchForRoute("list", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function TodosPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["todos"])) + if (!route) notFound() + // Direct DB read — no HTTP server required at build time + await myStack.api.todos.prefetchForRoute("list", queryClient) + return ( + + + + ) +} +``` + + +The shared `StackProvider` layout must live at `app/pages/layout.tsx` (not inside `[[...all]]/layout.tsx`) so it applies to both the catch-all routes and these specific SSG pages. + + +#### 5. ISR cache invalidation + +If you enable [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration) (`export const revalidate = 3600`), the cached page must be purged whenever the underlying data changes. Wire up `revalidatePath` (or `revalidateTag`) inside the backend plugin hooks: + +```ts title="lib/stack.ts" +import { revalidatePath } from "next/cache" + +const myPlugin = myBackendPlugin({ + hooks: { + onAfterCreate: async (item) => { + revalidatePath("/todos") + }, + onAfterUpdate: async (item) => { + revalidatePath("/todos") + }, + onAfterDelete: async (id) => { + revalidatePath("/todos") + }, + }, +}) +``` + + +`revalidatePath` / `revalidateTag` are Next.js APIs imported from `"next/cache"`. They are no-ops outside of a Next.js runtime, so it is safe to call them from a shared `lib/stack.ts` without breaking non-Next.js frameworks. + + +--- + ### Client Hooks Type-safe React Query hooks using `createApiClient`: @@ -1044,6 +1228,9 @@ Features: - Multiple related models (posts, tags, postTags) - Complex queries with pagination, filtering, search - SSR data loading with React Query +- **SSG support** via `prefetchForRoute` — seeds the query cache at build time without HTTP +- `api/query-key-defs.ts` — shared key constants used by both `query-keys.ts` and `prefetchForRoute` +- `api/serializers.ts` — `Date` → ISO string conversion for consistent cache hydration - SEO meta generation - Sitemap generation - Authorization and lifecycle hooks diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 751afa1..bb10af1 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -744,3 +744,78 @@ if (form) { | `getFormBySlug(adapter, slug)` | Returns a single form by slug, or `null` | | `getFormSubmissions(adapter, formId, params?)` | Returns paginated submissions for a form | +## Static Site Generation (SSG) + +`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. + +### `prefetchForRoute(routeKey, queryClient, params?)` + +| Route key | Params required | Data prefetched | +|---|---|---| +| `"formList"` | — | First page of forms | +| `"newForm"` | — | *(nothing)* | +| `"editForm"` | `{ id: string }` | Single form by ID | +| `"submissions"` | `{ formId: string }` | First page of submissions for a form | + +### Next.js example + +```tsx title="app/pages/forms/page.tsx" +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +// export const revalidate = 3600 // uncomment for ISR + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) return { title: "Forms" } + await myStack.api.formBuilder.prefetchForRoute("formList", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function FormsListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) return null + // Reads directly from DB — works at build time, no HTTP server required + await myStack.api.formBuilder.prefetchForRoute("formList", queryClient) + return ( + + + + ) +} +``` + +### ISR cache invalidation + +If you use [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration), call `revalidatePath` inside the backend lifecycle hooks so Next.js regenerates the page on the next request: + +```ts title="lib/stack.ts" +import { revalidatePath } from "next/cache" +import type { FormBuilderBackendHooks } from "@btst/stack/plugins/form-builder" + +const formHooks: FormBuilderBackendHooks = { + onAfterFormCreated: async (form) => { + revalidatePath("/forms", "page") + }, + onAfterFormUpdated: async (form) => { + revalidatePath("/forms", "page") + }, +} +``` + +### Query key consistency + +`prefetchForRoute` uses the same query key shapes as `createFormBuilderQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/form-builder/api` as `FORM_QUERY_KEYS`, `formsListDiscriminator`, and `submissionsListDiscriminator`, so the two paths can never drift silently. + diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 657f217..4b7b424 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -779,3 +779,80 @@ export async function generateStaticParams() { ### `BoardListResult` + +## Static Site Generation (SSG) + +`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. + +### `prefetchForRoute(routeKey, queryClient, params?)` + +| Route key | Params required | Data prefetched | +|---|---|---| +| `"boards"` | — | First page of boards | +| `"newBoard"` | — | *(nothing)* | +| `"board"` | `{ boardId: string }` | Single board with columns and tasks | + +### Next.js example + +```tsx title="app/pages/kanban/page.tsx" +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +// export const revalidate = 3600 // uncomment for ISR + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) return { title: "Kanban Boards" } + await myStack.api.kanban.prefetchForRoute("boards", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function KanbanBoardsPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) return null + // Reads directly from DB — works at build time, no HTTP server required + await myStack.api.kanban.prefetchForRoute("boards", queryClient) + return ( + + + + ) +} +``` + +### ISR cache invalidation + +If you use [Incremental Static Regeneration](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration), call `revalidatePath` inside the backend lifecycle hooks so Next.js regenerates the page on the next request: + +```ts title="lib/stack.ts" +import { revalidatePath } from "next/cache" +import type { KanbanBackendHooks } from "@btst/stack/plugins/kanban/api" + +const kanbanHooks: KanbanBackendHooks = { + onBoardCreated: async (board) => { + revalidatePath("/kanban") + }, + onBoardUpdated: async (board) => { + revalidatePath("/kanban") + }, + onBoardDeleted: async (boardId) => { + revalidatePath("/kanban") + }, +} +``` + +### Query key consistency + +`prefetchForRoute` uses the same query key shapes as `createKanbanQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/kanban/api` as `KANBAN_QUERY_KEYS` and `boardsListDiscriminator`, so the two paths can never drift silently. diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index c11ea6b..f040d9f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ "**/*.form-builder.spec.ts", "**/*.ui-builder.spec.ts", "**/*.kanban.spec.ts", + "**/*.ssg.spec.ts", ], }, { diff --git a/e2e/tests/smoke.ssg.spec.ts b/e2e/tests/smoke.ssg.spec.ts new file mode 100644 index 0000000..2e80dec --- /dev/null +++ b/e2e/tests/smoke.ssg.spec.ts @@ -0,0 +1,158 @@ +/** + * Minimal smoke tests for SSG (Static Site Generation) pages. + * + * These tests verify that SSG pages render correctly. The list pages are + * pre-rendered at build time (empty DB → may show empty state). Detail pages + * for slugs NOT in generateStaticParams are rendered on-demand by Next.js + * (dynamicParams defaults to true), which lets us test a freshly created post. + * + * Full cache-invalidation (revalidateTag / ISR) is covered by documentation + * and is exercised in production where the cache can be observed across + * requests. E2E tests focus on the page rendering contract. + */ +import { expect, test } from "@playwright/test"; + +const emptySelector = '[data-testid="empty-state"]'; + +test.describe("SSG Blog Pages", () => { + test("ssg blog list page renders", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + await page.goto("/pages/ssg-blog", { waitUntil: "networkidle" }); + + // Should render the blog home page component + await expect(page.locator('[data-testid="home-page"]')).toBeVisible({ + timeout: 15000, + }); + await expect(page).toHaveTitle(/Blog/i); + + // Either shows posts or empty state — both are valid for a static snapshot + const emptyVisible = await page + .locator(emptySelector) + .isVisible() + .catch(() => false); + if (!emptyVisible) { + await expect(page.getByTestId("page-header")).toBeVisible(); + } + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("ssg blog post detail page renders for a newly created post", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const slug = `ssg-smoke-${Date.now().toString(36)}`; + const title = `SSG Smoke ${slug}`; + + // Create a published post via the regular (SSR) admin pages + await page.goto("/pages/blog/new", { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="new-post-page"]')).toBeVisible({ + timeout: 15000, + }); + + // Click then fill — mirrors the pattern used in the working blog smoke tests + await page.getByLabel("Title").click(); + await page.getByLabel("Title").fill(title); + await expect(page.getByLabel("Title")).toHaveValue(title); + + await page.getByLabel("Slug").fill(slug); + await page.getByLabel("Excerpt").fill("SSG smoke test excerpt"); + + // ProseMirror/Milkdown requires select-all + pressSequentially; fill() alone + // doesn't trigger the editor's change events and leaves the field empty. + await page.waitForSelector(".milkdown-custom", { state: "visible" }); + await page.waitForTimeout(1000); + const editor = page + .locator(".milkdown-custom") + .locator("[contenteditable]") + .first(); + await editor.click(); + await page.evaluate(() => { + const editorEl = document.querySelector( + ".milkdown-custom [contenteditable]", + ) as HTMLElement; + if (editorEl) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editorEl); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }); + await editor.pressSequentially("SSG smoke test content.", { delay: 50 }); + + // Publish the post + const publishedSwitch = page + .locator('[data-slot="form-item"]') + .filter({ hasText: "Published" }) + .getByRole("switch"); + await expect(publishedSwitch).toBeVisible(); + await publishedSwitch.click(); + + // Close any open dropdowns before submitting + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: /^Create Post$/i }).click(); + await page.waitForURL("**/pages/blog", { timeout: 10000 }); + await page.waitForLoadState("networkidle"); + + // The SSG post detail page for a slug not in generateStaticParams is + // rendered on-demand by Next.js (dynamicParams: true), so we can visit it + // immediately and expect fresh content. + await page.goto(`/pages/ssg-blog/${slug}`, { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="post-page"]')).toBeVisible({ + timeout: 15000, + }); + await expect(page).toHaveTitle(new RegExp(title, "i")); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("ssg blog list shows updated content after revalidation", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + const slug = `ssg-reval-${Date.now().toString(36)}`; + const title = `SSG Reval ${slug}`; + + // Create a post directly via the API (faster than the form UI). + // The blog API is at /api/data/posts (basePath="/api/data", plugin="blog"). + const createRes = await request.post( + "http://localhost:3003/api/data/posts", + { + data: { + title, + slug, + excerpt: "Revalidation test", + content: "Content for revalidation test.", + published: true, + }, + }, + ); + expect(createRes.ok()).toBeTruthy(); + + // The onPostCreated hook calls revalidatePath("/pages/ssg-blog"), which + // purges the ISR cache immediately. The next request to /pages/ssg-blog + // triggers a blocking regeneration using the loader (HTTP request). + await page.goto("/pages/ssg-blog", { waitUntil: "networkidle" }); + + await expect(page.locator('[data-testid="home-page"]')).toBeVisible(); + await expect(page.locator(`text=${title}`).first()).toBeVisible({ + timeout: 10000, + }); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); +}); diff --git a/examples/nextjs/app/pages/[[...all]]/layout.tsx b/examples/nextjs/app/pages/layout.tsx similarity index 100% rename from examples/nextjs/app/pages/[[...all]]/layout.tsx rename to examples/nextjs/app/pages/layout.tsx diff --git a/examples/nextjs/app/pages/ssg-blog/[slug]/page.tsx b/examples/nextjs/app/pages/ssg-blog/[slug]/page.tsx new file mode 100644 index 0000000..6f18067 --- /dev/null +++ b/examples/nextjs/app/pages/ssg-blog/[slug]/page.tsx @@ -0,0 +1,59 @@ +/** + * SSG example: Individual blog post page with ISR + on-demand revalidation + * + * Uses `prefetchForRoute` (direct DB access). `myStack` is stored as a global + * singleton in `lib/stack.ts` so all Next.js module bundles share the same + * in-memory adapter instance — the post created via the API is visible here. + * + * New slugs are rendered on-demand (dynamicParams: true), so posts created + * after the build are served fresh on their first visit. + * + * Backend plugin hooks in `lib/stack.ts` call `revalidatePath` on + * create/update/delete to purge the ISR cache on demand. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { normalizePath, metaElementsToObject } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + const result = await myStack.api.blog.getAllPosts({ published: true }) + return result.items.map((post: { slug: string }) => ({ slug: post.slug })) +} + +export const revalidate = 3600 + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) return { title: slug } + await myStack.api.blog.prefetchForRoute("post", queryClient, { slug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgBlogPostPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) notFound() + await myStack.api.blog.prefetchForRoute("post", queryClient, { slug }) + return ( + + {route.PageComponent && } + + ) +} diff --git a/examples/nextjs/app/pages/ssg-blog/page.tsx b/examples/nextjs/app/pages/ssg-blog/page.tsx new file mode 100644 index 0000000..77bd2c6 --- /dev/null +++ b/examples/nextjs/app/pages/ssg-blog/page.tsx @@ -0,0 +1,49 @@ +/** + * SSG example: Blog list page with ISR + on-demand revalidation + * + * Uses `prefetchForRoute` (direct DB access) — the same pattern used by the + * CMS, Forms, and Kanban SSG pages. `myStack` is stored as a global singleton + * in `lib/stack.ts` so all Next.js module bundles (API routes and page routes) + * share the same in-memory adapter instance. + * + * At build time the DB is empty; the page renders as an empty shell and ISR + * regenerates it on the first request after deployment. + * + * Backend plugin hooks in `lib/stack.ts` call `revalidatePath("/pages/ssg-blog")` + * on create/update/delete so the next visitor always gets fresh content. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 // ISR: regenerate at most once per hour + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) return { title: "Blog" } + await myStack.api.blog.prefetchForRoute("posts", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgBlogListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) notFound() + await myStack.api.blog.prefetchForRoute("posts", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/examples/nextjs/app/pages/ssg-cms/[typeSlug]/page.tsx b/examples/nextjs/app/pages/ssg-cms/[typeSlug]/page.tsx new file mode 100644 index 0000000..6eddc39 --- /dev/null +++ b/examples/nextjs/app/pages/ssg-cms/[typeSlug]/page.tsx @@ -0,0 +1,56 @@ +/** + * SSG example: CMS content list page with Next.js cache tags + * + * Generates a static page for each registered content type at build time. + * Data is tagged with `'ssg-cms-${typeSlug}'` so that mutations via the + * backend plugin hooks (lib/stack.ts → revalidatePath) trigger regeneration + * on the next request. + * + * ISR (`revalidate = 3600`) provides a time-based fallback. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + const contentTypes = await myStack.api.cms.getAllContentTypes() + return contentTypes.map((ct: { slug: string }) => ({ typeSlug: ct.slug })) +} + +export const revalidate = 3600 // ISR: regenerate at most once per hour + +export async function generateMetadata({ + params, +}: { + params: Promise<{ typeSlug: string }> +}): Promise { + const { typeSlug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", typeSlug])) + if (!route) return { title: typeSlug } + await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgCmsContentListPage({ + params, +}: { + params: Promise<{ typeSlug: string }> +}) { + const { typeSlug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", typeSlug])) + if (!route) notFound() + await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug }) + return ( + + {route.PageComponent && } + + ) +} diff --git a/examples/nextjs/app/pages/ssg-forms/page.tsx b/examples/nextjs/app/pages/ssg-forms/page.tsx new file mode 100644 index 0000000..97f11a7 --- /dev/null +++ b/examples/nextjs/app/pages/ssg-forms/page.tsx @@ -0,0 +1,45 @@ +/** + * SSG example: Forms list page with ISR + * + * Statically generates the forms list page at build time. + * When a form is created/updated/deleted, the backend plugin hooks in + * lib/stack.ts call revalidatePath('/pages/ssg-forms', 'page') to + * trigger regeneration on the next request. + * + * ISR (`revalidate = 3600`) provides a time-based fallback. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 // ISR: regenerate at most once per hour + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) return { title: "Forms" } + await myStack.api.formBuilder.prefetchForRoute("formList", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgFormsListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) notFound() + await myStack.api.formBuilder.prefetchForRoute("formList", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/examples/nextjs/app/pages/ssg-kanban/page.tsx b/examples/nextjs/app/pages/ssg-kanban/page.tsx new file mode 100644 index 0000000..3e32b0d --- /dev/null +++ b/examples/nextjs/app/pages/ssg-kanban/page.tsx @@ -0,0 +1,45 @@ +/** + * SSG example: Kanban boards list page with ISR + * + * Statically generates the kanban boards list at build time. + * When a board is created/updated/deleted, the backend plugin hooks in + * lib/stack.ts call revalidatePath('/pages/ssg-kanban', 'page') to + * trigger regeneration on the next request. + * + * ISR (`revalidate = 3600`) provides a time-based fallback. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 // ISR: regenerate at most once per hour + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) return { title: "Kanban Boards" } + await myStack.api.kanban.prefetchForRoute("boards", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgKanbanBoardsPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) notFound() + await myStack.api.kanban.prefetchForRoute("boards", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 3260f57..c7341ec 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -12,6 +12,7 @@ import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" import { tool } from "ai" import { z } from "zod" +import { revalidateTag, revalidatePath } from "next/cache" // Import shared CMS schemas - these are used for both backend validation and client type inference import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, CommentSchema } from "./cms-schemas" @@ -71,12 +72,18 @@ const blogHooks: BlogBackendHooks = { // Lifecycle hooks - perform actions after operations onPostCreated: async (post) => { console.log("Post created:", post.id, post.title); + // Purge the ISR cache so the next request regenerates with fresh data + revalidatePath("/pages/ssg-blog"); + revalidatePath(`/pages/ssg-blog/${post.slug}`); }, onPostUpdated: async (post) => { console.log("Post updated:", post.id, post.title); + revalidatePath("/pages/ssg-blog"); + revalidatePath(`/pages/ssg-blog/${post.slug}`); }, onPostDeleted: async (postId) => { console.log("Post deleted:", postId); + revalidatePath("/pages/ssg-blog"); }, onPostsRead: async (posts) => { console.log("Posts read:", posts.length, "items"); @@ -97,8 +104,14 @@ const blogHooks: BlogBackendHooks = { }, }; -export const myStack = stack({ - basePath: "/api/data", +// Use a global singleton to share the same myStack (and in-memory adapter) +// across Next.js module boundaries (API routes vs page/SSG bundles are bundled +// separately, but all run in the same Node.js process). +const globalForStack = global as typeof global & { __btst_stack__?: ReturnType }; + +function createStack() { + return stack({ + basePath: "/api/data", plugins: { todos: todosBackendPlugin, blog: blogBackendPlugin(blogHooks), @@ -168,12 +181,15 @@ export const myStack = stack({ hooks: { onAfterCreate: async (item, context) => { console.log("CMS item created:", context.typeSlug, item.slug); + revalidatePath(`/pages/ssg-cms/${context.typeSlug}`, "page"); }, onAfterUpdate: async (item, context) => { console.log("CMS item updated:", context.typeSlug, item.slug); + revalidatePath(`/pages/ssg-cms/${context.typeSlug}`, "page"); }, onAfterDelete: async (id, context) => { console.log("CMS item deleted:", context.typeSlug, id); + revalidatePath(`/pages/ssg-cms/${context.typeSlug}`, "page"); }, }, }), @@ -182,9 +198,11 @@ export const myStack = stack({ hooks: { onAfterFormCreated: async (form, context) => { console.log("Form created:", form.name, form.slug); + revalidatePath("/pages/ssg-forms", "page"); }, onAfterFormUpdated: async (form, context) => { console.log("Form updated:", form.name); + revalidatePath("/pages/ssg-forms", "page"); }, onAfterSubmission: async (submission, form, context) => { console.log("Form submission received:", form.name, submission.id); @@ -212,10 +230,14 @@ export const myStack = stack({ }, onBoardCreated: async (board, context) => { console.log("Board created:", board.id, board.name); + revalidatePath("/pages/ssg-kanban", "page"); }, }), }, - adapter: (db) => createMemoryAdapter(db)({}) -}) + adapter: (db) => createMemoryAdapter(db)({}) + }) +} + +export const myStack = globalForStack.__btst_stack__ ??= createStack() export const { handler, dbSchema } = myStack diff --git a/packages/stack/package.json b/packages/stack/package.json index fe28169..cd56aa9 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.2.0", + "version": "2.3.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -458,7 +458,7 @@ }, "peerDependencies": { "@ai-sdk/react": ">=2.0.0", - "@btst/yar": ">=1.1.0", + "@btst/yar": ">=1.2.0", "@hookform/resolvers": ">=5.0.0", "@radix-ui/react-dialog": ">=1.1.0", "@radix-ui/react-label": ">=2.1.0", @@ -494,7 +494,7 @@ "devDependencies": { "@ai-sdk/react": "^2.0.94", "@btst/adapter-memory": "2.0.3", - "@btst/yar": "1.1.1", + "@btst/yar": "1.2.0", "@types/react": "^19.0.0", "@types/slug": "^5.0.9", "@workspace/ui": "workspace:*", diff --git a/packages/stack/src/plugins/blog/api/index.ts b/packages/stack/src/plugins/blog/api/index.ts index 7a4187b..f4c9674 100644 --- a/packages/stack/src/plugins/blog/api/index.ts +++ b/packages/stack/src/plugins/blog/api/index.ts @@ -6,4 +6,6 @@ export { type PostListParams, type PostListResult, } from "./getters"; +export { serializePost, serializeTag } from "./serializers"; +export { BLOG_QUERY_KEYS } from "./query-key-defs"; 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 f7110b8..0b7709f 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -7,6 +7,90 @@ import type { Post, PostWithPostTag, Tag } from "../types"; import { slugify } from "../utils"; import { createPostSchema, updatePostSchema } from "../schemas"; import { getAllPosts, getPostBySlug, getAllTags } from "./getters"; +import { BLOG_QUERY_KEYS } from "./query-key-defs"; +import { serializePost, serializeTag } from "./serializers"; +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Route keys for the blog plugin — matches the keys returned by + * `stackClient.router.getRoute(path).routeKey`. + */ +export type BlogRouteKey = + | "posts" + | "drafts" + | "post" + | "tag" + | "newPost" + | "editPost"; + +/** + * Overloaded signature for `prefetchForRoute`. + * TypeScript enforces the correct params for each routeKey at call sites. + */ +interface BlogPrefetchForRoute { + (key: "posts" | "drafts" | "newPost", qc: QueryClient): Promise; + ( + key: "post" | "editPost", + qc: QueryClient, + params: { slug: string }, + ): Promise; + (key: "tag", qc: QueryClient, params: { tagSlug: string }): Promise; +} + +function createBlogPrefetchForRoute(adapter: Adapter): BlogPrefetchForRoute { + return async function prefetchForRoute( + key: BlogRouteKey, + qc: QueryClient, + params?: Record, + ): Promise { + switch (key) { + case "posts": + case "drafts": { + const published = key === "posts"; + const [result, tags] = await Promise.all([ + getAllPosts(adapter, { published, limit: 10 }), + getAllTags(adapter), + ]); + qc.setQueryData(BLOG_QUERY_KEYS.postsList({ published, limit: 10 }), { + pages: [result.items.map(serializePost)], + pageParams: [0], + }); + qc.setQueryData(BLOG_QUERY_KEYS.tagsList(), tags.map(serializeTag)); + break; + } + case "post": + case "editPost": { + const slug = params?.slug ?? ""; + if (slug) { + const post = await getPostBySlug(adapter, slug); + qc.setQueryData( + BLOG_QUERY_KEYS.postDetail(slug), + post ? serializePost(post) : null, + ); + } + break; + } + case "tag": { + const tagSlug = params?.tagSlug ?? ""; + const [result, tags] = await Promise.all([ + getAllPosts(adapter, { published: true, limit: 10, tagSlug }), + getAllTags(adapter), + ]); + qc.setQueryData( + BLOG_QUERY_KEYS.postsList({ published: true, limit: 10, tagSlug }), + { + pages: [result.items.map(serializePost)], + pageParams: [0], + }, + ); + qc.setQueryData(BLOG_QUERY_KEYS.tagsList(), tags.map(serializeTag)); + break; + } + default: + break; + } + } as BlogPrefetchForRoute; +} export const PostListQuerySchema = z.object({ slug: z.string().optional(), @@ -174,6 +258,7 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) => getAllPosts(adapter, params), getPostBySlug: (slug: string) => getPostBySlug(adapter, slug), getAllTags: () => getAllTags(adapter), + prefetchForRoute: createBlogPrefetchForRoute(adapter), }), routes: (adapter: Adapter) => { diff --git a/packages/stack/src/plugins/blog/api/query-key-defs.ts b/packages/stack/src/plugins/blog/api/query-key-defs.ts new file mode 100644 index 0000000..ba86448 --- /dev/null +++ b/packages/stack/src/plugins/blog/api/query-key-defs.ts @@ -0,0 +1,46 @@ +/** + * Internal query key constants for the blog plugin. + * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path) + * to prevent key drift between SSR loaders and SSG prefetching. + */ + +export interface PostsListDiscriminator { + query: string | undefined; + limit: number; + published: boolean; + tagSlug: string | undefined; +} + +/** + * Builds the discriminator object used as the cache key for the posts list. + * Mirrors the inline object in createPostsQueries so both paths stay in sync. + */ +export function postsListDiscriminator(params: { + published: boolean; + limit?: number; + tagSlug?: string; + query?: string; +}): PostsListDiscriminator { + return { + query: + params.query !== undefined && params.query.trim() === "" + ? undefined + : params.query, + limit: params.limit ?? 10, + published: params.published, + tagSlug: params.tagSlug, + }; +} + +/** Full query key builders — use these with queryClient.setQueryData() */ +export const BLOG_QUERY_KEYS = { + postsList: (params: { + published: boolean; + limit?: number; + tagSlug?: string; + }) => ["posts", "list", postsListDiscriminator(params)] as const, + + postDetail: (slug: string) => ["posts", "detail", slug] as const, + + tagsList: () => ["tags", "list", "tags"] as const, +}; diff --git a/packages/stack/src/plugins/blog/api/serializers.ts b/packages/stack/src/plugins/blog/api/serializers.ts new file mode 100644 index 0000000..6b1f88e --- /dev/null +++ b/packages/stack/src/plugins/blog/api/serializers.ts @@ -0,0 +1,27 @@ +import type { Post, Tag, SerializedPost, SerializedTag } from "../types"; + +/** + * Serialize a Tag for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializeTag(tag: Tag): SerializedTag { + return { + ...tag, + createdAt: tag.createdAt.toISOString(), + updatedAt: tag.updatedAt.toISOString(), + }; +} + +/** + * Serialize a Post (with tags) for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializePost(post: Post & { tags: Tag[] }): SerializedPost { + return { + ...post, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + publishedAt: post.publishedAt?.toISOString(), + tags: post.tags.map(serializeTag), + }; +} diff --git a/packages/stack/src/plugins/blog/client/plugin.tsx b/packages/stack/src/plugins/blog/client/plugin.tsx index aa5da65..d9147e0 100644 --- a/packages/stack/src/plugins/blog/client/plugin.tsx +++ b/packages/stack/src/plugins/blog/client/plugin.tsx @@ -1,6 +1,7 @@ import { defineClientPlugin, createApiClient, + isConnectionError, } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; @@ -226,6 +227,12 @@ function createPostsLoader(published: boolean, config: BlogClientConfig) { } catch (error) { // Error hook - log the error but don't throw during SSR // Let Error Boundaries handle errors when components render + if (isConnectionError(error)) { + console.warn( + "[btst/blog] route.loader() failed — no server running at build time. " + + "Use myStack.api.blog.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -299,6 +306,12 @@ function createPostLoader( } catch (error) { // Error hook - log the error but don't throw during SSR // Let Error Boundaries handle errors when components render + if (isConnectionError(error)) { + console.warn( + "[btst/blog] route.loader() failed — no server running at build time. " + + "Use myStack.api.blog.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -398,6 +411,12 @@ function createTagLoader(tagSlug: string, config: BlogClientConfig) { await hooks.onLoadError(error, context); } } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/blog] route.loader() failed — no server running at build time. " + + "Use myStack.api.blog.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } diff --git a/packages/stack/src/plugins/blog/query-keys.ts b/packages/stack/src/plugins/blog/query-keys.ts index 1fcbbf3..f232660 100644 --- a/packages/stack/src/plugins/blog/query-keys.ts +++ b/packages/stack/src/plugins/blog/query-keys.ts @@ -5,6 +5,7 @@ import { import type { BlogApiRouter } from "./api"; import { createApiClient } from "@btst/stack/plugins/client"; import type { SerializedPost, SerializedTag } from "./types"; +import { postsListDiscriminator } from "./api/query-key-defs"; interface PostsListParams { query?: string; @@ -71,15 +72,12 @@ function createPostsQueries( return createQueryKeys("posts", { list: (params?: PostsListParams) => ({ queryKey: [ - { - query: - params?.query !== undefined && params?.query?.trim() === "" - ? undefined - : params?.query, - limit: params?.limit ?? 10, + postsListDiscriminator({ published: params?.published ?? true, + limit: params?.limit ?? 10, tagSlug: params?.tagSlug, - }, + query: params?.query, + }), ], queryFn: async ({ pageParam }: { pageParam?: number }) => { try { diff --git a/packages/stack/src/plugins/client/index.ts b/packages/stack/src/plugins/client/index.ts index 0e13e45..8d48a6c 100644 --- a/packages/stack/src/plugins/client/index.ts +++ b/packages/stack/src/plugins/client/index.ts @@ -18,7 +18,7 @@ export type { PluginOverrides, } from "../../types"; -export { createApiClient } from "../utils"; +export { createApiClient, isConnectionError } from "../utils"; // Re-export Yar types needed for plugins export type { Route } from "@btst/yar"; diff --git a/packages/stack/src/plugins/cms/api/getters.ts b/packages/stack/src/plugins/cms/api/getters.ts index 98ec1a3..83e994e 100644 --- a/packages/stack/src/plugins/cms/api/getters.ts +++ b/packages/stack/src/plugins/cms/api/getters.ts @@ -191,6 +191,30 @@ export async function getAllContentItems( }; } +/** + * Retrieve a single content item by its ID. + * Returns null if the item is not found. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param id - The content item ID (UUID) + */ +export async function getContentItemById( + adapter: Adapter, + id: string, +): Promise { + const item = await adapter.findOne({ + model: "contentItem", + where: [{ field: "id", value: id, operator: "eq" as const }], + join: { contentType: true }, + }); + if (!item) return null; + return serializeContentItemWithType(item); +} + /** * Retrieve a single content item by its slug within a content type. * Returns null if the content type or item is not found. diff --git a/packages/stack/src/plugins/cms/api/index.ts b/packages/stack/src/plugins/cms/api/index.ts index d6b7f12..dd5fc74 100644 --- a/packages/stack/src/plugins/cms/api/index.ts +++ b/packages/stack/src/plugins/cms/api/index.ts @@ -1,6 +1,15 @@ -export { cmsBackendPlugin, type CMSApiRouter } from "./plugin"; +export { + cmsBackendPlugin, + type CMSApiRouter, + type CMSRouteKey, +} from "./plugin"; export { getAllContentTypes, getAllContentItems, getContentItemBySlug, + getContentItemById, + serializeContentType, + serializeContentItem, + serializeContentItemWithType, } from "./getters"; +export { CMS_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index e0f770a..5d09a4a 100644 --- a/packages/stack/src/plugins/cms/api/plugin.ts +++ b/packages/stack/src/plugins/cms/api/plugin.ts @@ -25,10 +25,37 @@ import { getAllContentTypes, getAllContentItems, getContentItemBySlug, + getContentItemById, serializeContentType, serializeContentItem, serializeContentItemWithType, } from "./getters"; +import type { QueryClient } from "@tanstack/react-query"; +import { CMS_QUERY_KEYS } from "./query-key-defs"; + +/** + * Route keys for the CMS plugin — matches the keys returned by + * `stackClient.router.getRoute(path).routeKey`. + */ +export type CMSRouteKey = + | "dashboard" + | "contentList" + | "newContent" + | "editContent"; + +interface CMSPrefetchForRoute { + (key: "dashboard" | "newContent", qc: QueryClient): Promise; + ( + key: "contentList", + qc: QueryClient, + params: { typeSlug: string }, + ): Promise; + ( + key: "editContent", + qc: QueryClient, + params: { typeSlug: string; id: string }, + ): Promise; +} /** * Sync content types from config to database @@ -443,6 +470,79 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { return syncPromise; }; + const getContentTypesWithCounts = async (adapter: Adapter) => { + const contentTypes = await getAllContentTypes(adapter); + return Promise.all( + contentTypes.map(async (ct) => { + const count: number = await adapter.count({ + model: "contentItem", + where: [ + { field: "contentTypeId", value: ct.id, operator: "eq" as const }, + ], + }); + return { ...ct, itemCount: count }; + }), + ); + }; + + const createCMSPrefetchForRoute = (adapter: Adapter): CMSPrefetchForRoute => { + return async function prefetchForRoute( + key: CMSRouteKey, + qc: QueryClient, + params?: Record, + ): Promise { + // Sync content types once at the top — idempotent for concurrent SSG calls + await ensureSynced(adapter); + + switch (key) { + case "dashboard": + case "newContent": { + const typesWithCounts = await getContentTypesWithCounts(adapter); + qc.setQueryData(CMS_QUERY_KEYS.typesList(), typesWithCounts); + break; + } + case "contentList": { + const typeSlug = params?.typeSlug ?? ""; + const [contentTypes, contentItems] = await Promise.all([ + getContentTypesWithCounts(adapter), + getAllContentItems(adapter, typeSlug, { limit: 20, offset: 0 }), + ]); + qc.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes); + qc.setQueryData( + CMS_QUERY_KEYS.contentList({ typeSlug, limit: 20, offset: 0 }), + { + pages: [ + { + items: contentItems.items, + total: contentItems.total, + limit: contentItems.limit ?? 20, + offset: contentItems.offset ?? 0, + }, + ], + pageParams: [0], + }, + ); + break; + } + case "editContent": { + const typeSlug = params?.typeSlug ?? ""; + const id = params?.id ?? ""; + const [contentTypes, item] = await Promise.all([ + getContentTypesWithCounts(adapter), + id ? getContentItemById(adapter, id) : Promise.resolve(null), + ]); + qc.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes); + if (id) { + qc.setQueryData(CMS_QUERY_KEYS.contentDetail(typeSlug, id), item); + } + break; + } + default: + break; + } + } as CMSPrefetchForRoute; + }; + return defineBackendPlugin({ name: "cms", @@ -464,6 +564,11 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { await ensureSynced(adapter); return getContentItemBySlug(adapter, contentTypeSlug, slug); }, + getContentItemById: async (id: string) => { + await ensureSynced(adapter); + return getContentItemById(adapter, id); + }, + prefetchForRoute: createCMSPrefetchForRoute(adapter), }), routes: (adapter: Adapter) => { diff --git a/packages/stack/src/plugins/cms/api/query-key-defs.ts b/packages/stack/src/plugins/cms/api/query-key-defs.ts new file mode 100644 index 0000000..9cb3ba7 --- /dev/null +++ b/packages/stack/src/plugins/cms/api/query-key-defs.ts @@ -0,0 +1,53 @@ +/** + * Internal query key constants for the CMS plugin. + * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path) + * to prevent key drift between SSR loaders and SSG prefetching. + */ + +export interface ContentListDiscriminator { + typeSlug: string; + limit: number; + offset: number; +} + +/** + * Builds the discriminator object used as the cache key for the content list. + * Mirrors the params object used in createContentQueries.list so both paths stay in sync. + */ +export function contentListDiscriminator(params: { + typeSlug: string; + limit?: number; + offset?: number; +}): ContentListDiscriminator { + return { + typeSlug: params.typeSlug, + limit: params.limit ?? 20, + offset: params.offset ?? 0, + }; +} + +/** Full query key builders — use these with queryClient.setQueryData() */ +export const CMS_QUERY_KEYS = { + /** + * Key for the cmsTypes.list() query. + * Full key: ["cmsTypes", "list", "list"] + */ + typesList: () => ["cmsTypes", "list", "list"] as const, + + /** + * Key for the cmsContent.list({ typeSlug, limit, offset }) query. + * Full key: ["cmsContent", "list", { typeSlug, limit, offset }] + */ + contentList: (params: { + typeSlug: string; + limit?: number; + offset?: number; + }) => ["cmsContent", "list", contentListDiscriminator(params)] as const, + + /** + * Key for the cmsContent.detail(typeSlug, id) query. + * Full key: ["cmsContent", "detail", typeSlug, id] + */ + contentDetail: (typeSlug: string, id: string) => + ["cmsContent", "detail", typeSlug, id] as const, +}; diff --git a/packages/stack/src/plugins/cms/api/serializers.ts b/packages/stack/src/plugins/cms/api/serializers.ts new file mode 100644 index 0000000..a5566c5 --- /dev/null +++ b/packages/stack/src/plugins/cms/api/serializers.ts @@ -0,0 +1,12 @@ +/** + * Re-exports serialization helpers from getters.ts for consumers who import + * from @btst/stack/plugins/cms/api. + * + * The actual implementations live in getters.ts alongside the DB functions + * they serialize so they stay in sync with the returned types. + */ +export { + serializeContentType, + serializeContentItem, + serializeContentItemWithType, +} from "./getters"; diff --git a/packages/stack/src/plugins/cms/client/plugin.tsx b/packages/stack/src/plugins/cms/client/plugin.tsx index 0f2e866..696f52f 100644 --- a/packages/stack/src/plugins/cms/client/plugin.tsx +++ b/packages/stack/src/plugins/cms/client/plugin.tsx @@ -2,6 +2,7 @@ import { lazy } from "react"; import { defineClientPlugin, createApiClient, + isConnectionError, } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; @@ -181,6 +182,12 @@ function createDashboardLoader(config: CMSClientConfig) { } catch (error) { // Error hook - log the error but don't throw during SSR // Let Error Boundaries handle errors when components render + if (isConnectionError(error)) { + console.warn( + "[btst/cms] route.loader() failed — no server running at build time. " + + "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -275,6 +282,12 @@ function createContentListLoader(typeSlug: string, config: CMSClientConfig) { } catch (error) { // Error hook - log the error but don't throw during SSR // Let Error Boundaries handle errors when components render + if (isConnectionError(error)) { + console.warn( + "[btst/cms] route.loader() failed — no server running at build time. " + + "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -357,6 +370,12 @@ function createContentEditorLoader( } catch (error) { // Error hook - log the error but don't throw during SSR // Let Error Boundaries handle errors when components render + if (isConnectionError(error)) { + console.warn( + "[btst/cms] route.loader() failed — no server running at build time. " + + "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } diff --git a/packages/stack/src/plugins/cms/query-keys.ts b/packages/stack/src/plugins/cms/query-keys.ts index 91fca2e..9726685 100644 --- a/packages/stack/src/plugins/cms/query-keys.ts +++ b/packages/stack/src/plugins/cms/query-keys.ts @@ -9,6 +9,7 @@ import type { SerializedContentItemWithType, PaginatedContentItems, } from "./types"; +import { contentListDiscriminator } from "./api/query-key-defs"; interface ContentListParams { limit?: number; @@ -115,7 +116,7 @@ function createContentQueries( ) { return createQueryKeys("cmsContent", { list: (params: { typeSlug: string } & ContentListParams) => ({ - queryKey: [params], + queryKey: [contentListDiscriminator(params)], queryFn: async () => { try { const response: unknown = await client("/content/:typeSlug", { diff --git a/packages/stack/src/plugins/form-builder/api/getters.ts b/packages/stack/src/plugins/form-builder/api/getters.ts index 130e1b8..854748f 100644 --- a/packages/stack/src/plugins/form-builder/api/getters.ts +++ b/packages/stack/src/plugins/form-builder/api/getters.ts @@ -116,6 +116,29 @@ export async function getAllForms( }; } +/** + * Retrieve a single form by its ID (UUID). + * Returns null if the form is not found. + * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param id - The form UUID + */ +export async function getFormById( + adapter: Adapter, + id: string, +): Promise { + const form = await adapter.findOne
({ + model: "form", + where: [{ field: "id", value: id, operator: "eq" as const }], + }); + if (!form) return null; + return serializeForm(form); +} + /** * Retrieve a single form by its slug. * Returns null if the form is not found. diff --git a/packages/stack/src/plugins/form-builder/api/index.ts b/packages/stack/src/plugins/form-builder/api/index.ts index 717ea1f..6c2ca54 100644 --- a/packages/stack/src/plugins/form-builder/api/index.ts +++ b/packages/stack/src/plugins/form-builder/api/index.ts @@ -1,2 +1,15 @@ -export { formBuilderBackendPlugin, type FormBuilderApiRouter } from "./plugin"; -export { getAllForms, getFormBySlug, getFormSubmissions } from "./getters"; +export { + formBuilderBackendPlugin, + type FormBuilderApiRouter, + type FormBuilderRouteKey, +} from "./plugin"; +export { + getAllForms, + getFormById, + getFormBySlug, + getFormSubmissions, + serializeForm, + serializeFormSubmission, + serializeFormSubmissionWithData, +} from "./getters"; +export { FORM_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/form-builder/api/plugin.ts b/packages/stack/src/plugins/form-builder/api/plugin.ts index e5ab431..d982090 100644 --- a/packages/stack/src/plugins/form-builder/api/plugin.ts +++ b/packages/stack/src/plugins/form-builder/api/plugin.ts @@ -23,12 +23,101 @@ import { import { slugify, extractIpAddress, extractUserAgent } from "../utils"; import { getAllForms, + getFormById as getFormByIdFromDb, getFormBySlug as getFormBySlugFromDb, getFormSubmissions, serializeForm, serializeFormSubmission, serializeFormSubmissionWithData, } from "./getters"; +import { FORM_QUERY_KEYS } from "./query-key-defs"; +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Route keys for the Form Builder plugin — matches the keys returned by + * `stackClient.router.getRoute(path).routeKey`. + */ +export type FormBuilderRouteKey = + | "formList" + | "newForm" + | "editForm" + | "submissions"; + +interface FormBuilderPrefetchForRoute { + (key: "formList" | "newForm", qc: QueryClient): Promise; + ( + key: "editForm" | "submissions", + qc: QueryClient, + params: { id: string }, + ): Promise; +} + +function createFormBuilderPrefetchForRoute( + adapter: Parameters[0], +): FormBuilderPrefetchForRoute { + return async function prefetchForRoute( + key: FormBuilderRouteKey, + qc: QueryClient, + params?: Record, + ): Promise { + switch (key) { + case "formList": { + const result = await getAllForms(adapter, { limit: 20, offset: 0 }); + qc.setQueryData(FORM_QUERY_KEYS.formsList({ limit: 20, offset: 0 }), { + pages: [ + { + items: result.items, + total: result.total, + limit: result.limit ?? 20, + offset: result.offset ?? 0, + }, + ], + pageParams: [0], + }); + break; + } + case "editForm": { + const id = params?.id ?? ""; + if (id) { + const form = await getFormByIdFromDb(adapter, id); + qc.setQueryData(FORM_QUERY_KEYS.formById(id), form); + } + break; + } + case "submissions": { + const id = params?.id ?? ""; + if (id) { + const [form, submissionsResult] = await Promise.all([ + getFormByIdFromDb(adapter, id), + getFormSubmissions(adapter, id, { limit: 20, offset: 0 }), + ]); + qc.setQueryData(FORM_QUERY_KEYS.formById(id), form); + qc.setQueryData( + FORM_QUERY_KEYS.submissionsList({ + formId: id, + limit: 20, + offset: 0, + }), + { + pages: [ + { + items: submissionsResult.items, + total: submissionsResult.total, + limit: submissionsResult.limit ?? 20, + offset: submissionsResult.offset ?? 0, + }, + ], + pageParams: [0], + }, + ); + } + break; + } + default: + break; + } + } as FormBuilderPrefetchForRoute; +} /** * Form Builder backend plugin @@ -47,11 +136,13 @@ export const formBuilderBackendPlugin = ( api: (adapter) => ({ getAllForms: (params?: Parameters[1]) => getAllForms(adapter, params), + getFormById: (id: string) => getFormByIdFromDb(adapter, id), getFormBySlug: (slug: string) => getFormBySlugFromDb(adapter, slug), getFormSubmissions: ( formId: string, params?: Parameters[2], ) => getFormSubmissions(adapter, formId, params), + prefetchForRoute: createFormBuilderPrefetchForRoute(adapter), }), routes: (adapter: Adapter) => { diff --git a/packages/stack/src/plugins/form-builder/api/query-key-defs.ts b/packages/stack/src/plugins/form-builder/api/query-key-defs.ts new file mode 100644 index 0000000..0e6feed --- /dev/null +++ b/packages/stack/src/plugins/form-builder/api/query-key-defs.ts @@ -0,0 +1,79 @@ +/** + * Internal query key constants for the Form Builder plugin. + * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path) + * to prevent key drift between SSR loaders and SSG prefetching. + */ + +export interface FormsListDiscriminator { + status?: "active" | "inactive" | "archived"; + limit: number; + offset: number; +} + +export interface SubmissionsListDiscriminator { + formId: string; + limit: number; + offset: number; +} + +/** + * Builds the discriminator object for the forms list query key. + * Mirrors the params object used in createFormsQueries.list. + */ +export function formsListDiscriminator(params?: { + status?: "active" | "inactive" | "archived"; + limit?: number; + offset?: number; +}): FormsListDiscriminator { + return { + status: params?.status, + limit: params?.limit ?? 20, + offset: params?.offset ?? 0, + }; +} + +/** + * Builds the discriminator object for the submissions list query key. + * Mirrors the params object used in createSubmissionsQueries.list. + */ +export function submissionsListDiscriminator(params: { + formId: string; + limit?: number; + offset?: number; +}): SubmissionsListDiscriminator { + return { + formId: params.formId, + limit: params.limit ?? 20, + offset: params.offset ?? 0, + }; +} + +/** Full query key builders — use these with queryClient.setQueryData() */ +export const FORM_QUERY_KEYS = { + /** + * Key for forms.list(params) query. + * Full key: ["forms", "list", "list", { status, limit, offset }] + */ + formsList: (params?: { + status?: "active" | "inactive" | "archived"; + limit?: number; + offset?: number; + }) => ["forms", "list", "list", formsListDiscriminator(params)] as const, + + /** + * Key for forms.byId(id) query. + * Full key: ["forms", "byId", "byId", id] + */ + formById: (id: string) => ["forms", "byId", "byId", id] as const, + + /** + * Key for formSubmissions.list(params) query. + * Full key: ["formSubmissions", "list", { formId, limit, offset }] + */ + submissionsList: (params: { + formId: string; + limit?: number; + offset?: number; + }) => + ["formSubmissions", "list", submissionsListDiscriminator(params)] as const, +}; diff --git a/packages/stack/src/plugins/form-builder/api/serializers.ts b/packages/stack/src/plugins/form-builder/api/serializers.ts new file mode 100644 index 0000000..a330c13 --- /dev/null +++ b/packages/stack/src/plugins/form-builder/api/serializers.ts @@ -0,0 +1,12 @@ +/** + * Re-exports serialization helpers from getters.ts for consumers who import + * from @btst/stack/plugins/form-builder/api. + * + * The actual implementations live in getters.ts alongside the DB functions + * they serialize so they stay in sync with the returned types. + */ +export { + serializeForm, + serializeFormSubmission, + serializeFormSubmissionWithData, +} from "./getters"; diff --git a/packages/stack/src/plugins/form-builder/client/plugin.tsx b/packages/stack/src/plugins/form-builder/client/plugin.tsx index a76f7b6..25b6c20 100644 --- a/packages/stack/src/plugins/form-builder/client/plugin.tsx +++ b/packages/stack/src/plugins/form-builder/client/plugin.tsx @@ -3,6 +3,7 @@ import { lazy } from "react"; import { defineClientPlugin, createApiClient, + isConnectionError, } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; @@ -197,6 +198,12 @@ function createFormListLoader(config: FormBuilderClientConfig) { } } catch (error) { // Error hook - log the error but don't throw during SSR + if (isConnectionError(error)) { + console.warn( + "[btst/form-builder] route.loader() failed — no server running at build time. " + + "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -265,6 +272,12 @@ function createFormBuilderLoader( } } catch (error) { // Error hook - log the error but don't throw during SSR + if (isConnectionError(error)) { + console.warn( + "[btst/form-builder] route.loader() failed — no server running at build time. " + + "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -364,6 +377,12 @@ function createSubmissionsLoader( } } catch (error) { // Error hook - log the error but don't throw during SSR + if (isConnectionError(error)) { + console.warn( + "[btst/form-builder] route.loader() failed — no server running at build time. " + + "Use myStack.api.formBuilder.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } diff --git a/packages/stack/src/plugins/form-builder/query-keys.ts b/packages/stack/src/plugins/form-builder/query-keys.ts index 2513128..4a73001 100644 --- a/packages/stack/src/plugins/form-builder/query-keys.ts +++ b/packages/stack/src/plugins/form-builder/query-keys.ts @@ -10,6 +10,10 @@ import type { PaginatedFormSubmissions, SerializedFormSubmissionWithData, } from "./types"; +import { + formsListDiscriminator, + submissionsListDiscriminator, +} from "./api/query-key-defs"; interface FormListParams { status?: "active" | "inactive" | "archived"; @@ -75,7 +79,7 @@ function createFormsQueries( ) { return createQueryKeys("forms", { list: (params: FormListParams = {}) => ({ - queryKey: ["list", params], + queryKey: ["list", formsListDiscriminator(params)], queryFn: async () => { try { const response: unknown = await client("/forms", { @@ -147,7 +151,7 @@ function createSubmissionsQueries( ) { return createQueryKeys("formSubmissions", { list: (params: SubmissionListParams) => ({ - queryKey: [params], + queryKey: [submissionsListDiscriminator(params)], queryFn: async () => { try { const response: unknown = await client("/forms/:formId/submissions", { diff --git a/packages/stack/src/plugins/kanban/api/index.ts b/packages/stack/src/plugins/kanban/api/index.ts index 7da737f..0dbfa8d 100644 --- a/packages/stack/src/plugins/kanban/api/index.ts +++ b/packages/stack/src/plugins/kanban/api/index.ts @@ -1,7 +1,10 @@ export { kanbanBackendPlugin, type KanbanApiRouter, + type KanbanRouteKey, type KanbanApiContext, type KanbanBackendHooks, } from "./plugin"; export { getAllBoards, getBoardById, type BoardListResult } from "./getters"; +export { serializeBoard, serializeColumn, serializeTask } from "./serializers"; +export { KANBAN_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/kanban/api/plugin.ts b/packages/stack/src/plugins/kanban/api/plugin.ts index 0e0674d..6a9f962 100644 --- a/packages/stack/src/plugins/kanban/api/plugin.ts +++ b/packages/stack/src/plugins/kanban/api/plugin.ts @@ -24,6 +24,54 @@ import { updateTaskSchema, } from "../schemas"; import { getAllBoards, getBoardById } from "./getters"; +import { KANBAN_QUERY_KEYS } from "./query-key-defs"; +import { serializeBoard } from "./serializers"; +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Route keys for the Kanban plugin — matches the keys returned by + * `stackClient.router.getRoute(path).routeKey`. + */ +export type KanbanRouteKey = "boards" | "newBoard" | "board"; + +interface KanbanPrefetchForRoute { + (key: "boards" | "newBoard", qc: QueryClient): Promise; + (key: "board", qc: QueryClient, params: { boardId: string }): Promise; +} + +function createKanbanPrefetchForRoute( + adapter: Adapter, +): KanbanPrefetchForRoute { + return async function prefetchForRoute( + key: KanbanRouteKey, + qc: QueryClient, + params?: Record, + ): Promise { + switch (key) { + case "boards": { + const result = await getAllBoards(adapter, { limit: 50, offset: 0 }); + qc.setQueryData( + KANBAN_QUERY_KEYS.boardsList({}), + result.items.map(serializeBoard), + ); + break; + } + case "board": { + const boardId = params?.boardId ?? ""; + if (boardId) { + const board = await getBoardById(adapter, boardId); + qc.setQueryData( + KANBAN_QUERY_KEYS.boardDetail(boardId), + board ? serializeBoard(board) : null, + ); + } + break; + } + default: + break; + } + } as KanbanPrefetchForRoute; +} /** * Context passed to kanban API hooks @@ -268,6 +316,7 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => getAllBoards: (params?: Parameters[1]) => getAllBoards(adapter, params), getBoardById: (id: string) => getBoardById(adapter, id), + prefetchForRoute: createKanbanPrefetchForRoute(adapter), }), routes: (adapter: Adapter) => { diff --git a/packages/stack/src/plugins/kanban/api/query-key-defs.ts b/packages/stack/src/plugins/kanban/api/query-key-defs.ts new file mode 100644 index 0000000..20bba8f --- /dev/null +++ b/packages/stack/src/plugins/kanban/api/query-key-defs.ts @@ -0,0 +1,54 @@ +/** + * Internal query key constants for the Kanban plugin. + * Shared between query-keys.ts (HTTP path) and prefetchForRoute (DB path) + * to prevent key drift between SSR loaders and SSG prefetching. + */ + +export interface BoardsListDiscriminator { + slug: string | undefined; + ownerId: string | undefined; + organizationId: string | undefined; + limit: number; + offset: number; +} + +/** + * Builds the discriminator object for the boards list query key. + * Mirrors the inline object used in createBoardsQueries.list. + */ +export function boardsListDiscriminator(params?: { + slug?: string; + ownerId?: string; + organizationId?: string; + limit?: number; + offset?: number; +}): BoardsListDiscriminator { + return { + slug: params?.slug, + ownerId: params?.ownerId, + organizationId: params?.organizationId, + limit: params?.limit ?? 50, + offset: params?.offset ?? 0, + }; +} + +/** Full query key builders — use these with queryClient.setQueryData() */ +export const KANBAN_QUERY_KEYS = { + /** + * Key for boards.list(params) query. + * Full key: ["boards", "list", { slug, ownerId, organizationId, limit, offset }] + */ + boardsList: (params?: { + slug?: string; + ownerId?: string; + organizationId?: string; + limit?: number; + offset?: number; + }) => ["boards", "list", boardsListDiscriminator(params)] as const, + + /** + * Key for boards.detail(boardId) query. + * Full key: ["boards", "detail", boardId] + */ + boardDetail: (boardId: string) => ["boards", "detail", boardId] as const, +}; diff --git a/packages/stack/src/plugins/kanban/api/serializers.ts b/packages/stack/src/plugins/kanban/api/serializers.ts new file mode 100644 index 0000000..407cc3c --- /dev/null +++ b/packages/stack/src/plugins/kanban/api/serializers.ts @@ -0,0 +1,49 @@ +import type { + Task, + ColumnWithTasks, + BoardWithColumns, + SerializedTask, + SerializedColumn, + SerializedBoardWithColumns, +} from "../types"; + +/** + * Serialize a Task for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializeTask(task: Task): SerializedTask { + return { + ...task, + completedAt: task.completedAt?.toISOString(), + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + }; +} + +/** + * Serialize a Column (with its tasks) for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializeColumn(col: ColumnWithTasks): SerializedColumn { + return { + ...col, + createdAt: col.createdAt.toISOString(), + updatedAt: col.updatedAt.toISOString(), + tasks: col.tasks.map(serializeTask), + }; +} + +/** + * Serialize a Board (with columns and tasks) for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializeBoard( + board: BoardWithColumns, +): SerializedBoardWithColumns { + return { + ...board, + createdAt: board.createdAt.toISOString(), + updatedAt: board.updatedAt.toISOString(), + columns: board.columns.map(serializeColumn), + }; +} diff --git a/packages/stack/src/plugins/kanban/client/plugin.tsx b/packages/stack/src/plugins/kanban/client/plugin.tsx index 27416bc..1f2ab94 100644 --- a/packages/stack/src/plugins/kanban/client/plugin.tsx +++ b/packages/stack/src/plugins/kanban/client/plugin.tsx @@ -1,6 +1,7 @@ import { defineClientPlugin, createApiClient, + isConnectionError, } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; @@ -178,6 +179,12 @@ function createBoardsLoader(config: KanbanClientConfig) { await hooks.onLoadError(error, context); } } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/kanban] route.loader() failed — no server running at build time. " + + "Use myStack.api.kanban.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } @@ -241,6 +248,12 @@ function createBoardLoader(boardId: string, config: KanbanClientConfig) { await hooks.onLoadError(error, context); } } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/kanban] route.loader() failed — no server running at build time. " + + "Use myStack.api.kanban.prefetchForRoute() for SSG data prefetching.", + ); + } if (hooks?.onLoadError) { await hooks.onLoadError(error as Error, context); } diff --git a/packages/stack/src/plugins/kanban/query-keys.ts b/packages/stack/src/plugins/kanban/query-keys.ts index e21feac..219ceb4 100644 --- a/packages/stack/src/plugins/kanban/query-keys.ts +++ b/packages/stack/src/plugins/kanban/query-keys.ts @@ -5,6 +5,7 @@ import { import type { KanbanApiRouter } from "./api"; import { createApiClient } from "@btst/stack/plugins/client"; import type { SerializedBoardWithColumns } from "./types"; +import { boardsListDiscriminator } from "./api/query-key-defs"; interface BoardsListParams { slug?: string; @@ -63,15 +64,7 @@ function createBoardsQueries( ) { return createQueryKeys("boards", { list: (params?: BoardsListParams) => ({ - queryKey: [ - { - slug: params?.slug, - ownerId: params?.ownerId, - organizationId: params?.organizationId, - limit: params?.limit ?? 50, - offset: params?.offset ?? 0, - }, - ], + queryKey: [boardsListDiscriminator(params)], queryFn: async () => { try { const response = await client("/boards", { diff --git a/packages/stack/src/plugins/utils.ts b/packages/stack/src/plugins/utils.ts index 860487a..e557a33 100644 --- a/packages/stack/src/plugins/utils.ts +++ b/packages/stack/src/plugins/utils.ts @@ -1,4 +1,23 @@ import { createClient } from "better-call/client"; + +/** + * Returns true when a fetch error is a connection-refused / no-server error. + * Used in SSR loaders to emit an actionable build-time warning when + * `route.loader()` is called during `next build` with no HTTP server running. + */ +export function isConnectionError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const code = + (err as unknown as { cause?: { code?: string } }).cause?.code ?? + (err as unknown as { code?: string }).code; + return ( + err.message.includes("ECONNREFUSED") || + err.message.includes("fetch failed") || + err.message.includes("ERR_CONNECTION_REFUSED") || + code === "ECONNREFUSED" || + code === "ERR_CONNECTION_REFUSED" + ); +} import type { Router, Endpoint } from "better-call"; interface CreateApiClientOptions { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7560689..768ef81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -628,8 +628,8 @@ importers: specifier: 2.0.3 version: 2.0.3(cbbb9f8f765bc2577d5d561739823056) '@btst/yar': - specifier: 1.1.1 - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6) + specifier: 1.2.0 + version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6) '@types/react': specifier: ^19.0.0 version: 19.2.6 @@ -1246,6 +1246,12 @@ packages: '@types/react': ^19.1.16 '@types/react-dom': ^19.1.9 + '@btst/yar@1.2.0': + resolution: {integrity: sha512-+pjP7tkARs8ENwq0mTGdXLtOD2IEcv4tAgN8tHkAWjmAmldbjeqNkZzSt9KJFhW6bZJEORtRQKnYI1vN152f5A==} + peerDependencies: + '@types/react': ^19.1.16 + '@types/react-dom': ^19.1.9 + '@codemirror/autocomplete@6.19.1': resolution: {integrity: sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==} @@ -9457,6 +9463,12 @@ snapshots: '@types/react-dom': 19.2.3(@types/react@19.2.6) rou3: 0.7.12 + '@btst/yar@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)': + dependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + rou3: 0.7.12 + '@codemirror/autocomplete@6.19.1': dependencies: '@codemirror/language': 6.11.3