diff --git a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md index 4bce341a5..f4f1e70e8 100644 --- a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md @@ -3,30 +3,34 @@ ## defineBackendPlugin shape (api/plugin.ts) ```typescript -export const myBackendPlugin = defineBackendPlugin({ - name: "my-plugin", - dbPlugin: dbSchema, - operations: (adapter) => ({ - createItem: defineOperation({ - input: CreateItemSchema, - permission: itemPermissions.item.create, - facts: () => undefined, - execute: ({ input }) => createItem(adapter, input), +/** Configuration accepted by `myBackendPlugin`. */ +export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ + hooks?: MyBackendHooks +} + +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => + defineBackendPlugin({ + id: "myPlugin", + dbPlugin: dbSchema, + operations: (adapter) => createMyOperations(adapter, options.hooks), + raw: (adapter) => ({ + prefetchForRoute: createItemPrefetchForRoute(adapter), }), - }), - raw: (adapter) => ({ - prefetchForRoute: createItemPrefetchForRoute(adapter), - }), - routes: (_adapter, _context, operations) => ({ - createItem: createEndpoint( - "/items", - { method: "POST", body: CreateItemSchema, requireRequest: true }, - operations.createItem.route((ctx) => ctx.body), - ), - }), -}) + routes: (_adapter, _context, operations) => { + const createItem = createEndpoint( + "/items", + { method: "POST", body: CreateItemSchema, requireRequest: true }, + operations.createItem.route((ctx) => ctx.body), + ) + return { createItem } as const + }, + }) -export type MyApiRouter = ReturnType +/** Inferred router contract imported by the client plugin. */ +export type MyApiRouter = ReturnType< + ReturnType["routes"] +> ``` ## getters.ts @@ -114,16 +118,16 @@ export { serializeItem } from "./serializers" Invoke domain hooks from the operation lifecycle after authorization. Hooks can enforce domain invariants, publish side effects, and observe errors; they are not the authorization policy. -## Plugin stack() wiring (in stack.ts) +## Plugin `createBackendStack()` wiring (in stack.ts) ```typescript -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { myBackendPlugin } from "./src/plugins/my-plugin/api/plugin" -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { - myPlugin: myBackendPlugin, + myPlugin: myBackendPlugin(), }, adapter: (db) => createDrizzleAdapter(schema, db, {}), }) diff --git a/.agents/skills/btst-backend-plugin-dev/SKILL.md b/.agents/skills/btst-backend-plugin-dev/SKILL.md index b260393ca..5d3fd57f3 100644 --- a/.agents/skills/btst-backend-plugin-dev/SKILL.md +++ b/.agents/skills/btst-backend-plugin-dev/SKILL.md @@ -25,7 +25,7 @@ src/plugins/{name}/ - **`api/index.ts`** — re-export everything from getters + mutations for direct server-side import. - **`operations.ts`** — define the one maintained business inventory with input validation, exact permission descriptors, authoritative facts, domain execution, and lifecycle hooks. - Bind HTTP routes to same-key operations. Use `operationRouteMap` only for real route-name mismatches. -- The optional `raw` factory is narrow: first-party plugins expose only `prefetchForRoute` for SSG. Do not duplicate business getters or mutations on `stack().raw`. +- The optional `raw` factory is narrow: first-party plugins expose only `prefetchForRoute` for SSG. Do not duplicate business getters or mutations on `createBackendStack().raw`. - Use `myStack.forRequest(request).operations.*` for request work and `myStack.trusted.*` for explicitly trusted jobs. ## Key patterns @@ -61,7 +61,9 @@ already support. `myStack` is a module-level const. The `execute` closure runs lazily (only on HTTP request), so `myStack` is always initialised by then: ```typescript -export const myStack = stack({ ... }) +import { createBackendStack } from "@btst/stack/api" + +export const myStack = createBackendStack({ ... }) const myTool = tool({ execute: async (params) => { @@ -80,7 +82,7 @@ const myTool = tool({ - **Wrong adapter type** — use `import type { DBAdapter as Adapter } from "@btst/db"` in getters/mutations/plugin files. - **`"GET /path"` string keys** — routes use `createEndpoint()`, not string-keyed method/path objects. - **`ctx.json()`** — does not exist; return data directly from route handlers. -- **Business methods on `stack().raw`** — do not add them. Keep the composed lifecycle explicit through `forRequest(request).operations` or `trusted`, and reserve `raw` for narrow lower-level/SSG helpers. +- **Business methods on `createBackendStack().raw`** — do not add them. Keep the composed lifecycle explicit through `forRequest(request).operations` or `trusted`, and reserve `raw` for narrow lower-level/SSG helpers. - **Authorization in lifecycle hooks** — routine access control belongs in operation descriptors and the one shared rule. Hooks receive already-authorized context. - **Write ops in `getters.ts`** — write functions belong in `mutations.ts`, not `getters.ts`. diff --git a/.agents/skills/btst-build-config/SKILL.md b/.agents/skills/btst-build-config/SKILL.md index 45dc771dc..b255984a4 100644 --- a/.agents/skills/btst-build-config/SKILL.md +++ b/.agents/skills/btst-build-config/SKILL.md @@ -117,21 +117,14 @@ built-in plugin override. Configure `api`, `site`, and `queryClient` once in accept only plugin-specific options; their loaders and metadata receive shared runtime through the stack resolver. -### Override type registration (in each layout) - -```typescript -import type { YourPluginOverrides } from "@btst/stack/plugins/{name}/client" - -type LegacyPluginOverrides = { - "{name}": YourPluginOverrides, -} -``` +### Override type inference Resolved definitions contribute their public override type automatically under -their canonical programmatic ID (`blog`, `aiChat`). Keep a manual map only for -an unmigrated compatibility plugin, and delete it when that plugin adopts the -resolved contract. Do not recreate removed v2 framework, API, guard, or identity -fields in local intersection types. +their canonical programmatic ID (`blog`, `aiChat`). Register the definition in +`createClientStack({ plugins })` and pass that resolved stack to +`StackProvider`; never recreate a manual application override map or provider +generic. Do not restore removed framework, API, guard, or identity fields in +local intersection types. ## Adding shared UI components (@workspace/ui) diff --git a/.agents/skills/btst-client-plugin-dev/REFERENCE.md b/.agents/skills/btst-client-plugin-dev/REFERENCE.md index b50bc9311..9e3977a57 100644 --- a/.agents/skills/btst-client-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-client-plugin-dev/REFERENCE.md @@ -132,7 +132,7 @@ export function createMyQueryKeys(client: ResourceClient, headers?: HeadersInit) ## Programmatic id (client/constants.ts) ```typescript -export const MY_PLUGIN_ID = "my-plugin" as const +export const MY_PLUGIN_ID = "myPlugin" as const ``` ## defineClientPlugin shape (client/plugin.tsx) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f369e4ed8..d3a80e1a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,31 +102,46 @@ If you want to publish a plugin as a standalone npm package (not merged into thi ### Plugin anatomy -A plugin has two halves that must be kept in sync: +A plugin may expose either or both of these independent halves: | Half | Entry point | Factory function | Import path | |------|-------------|------------------|-------------| | Backend | `api/plugin.ts` | `defineBackendPlugin` | `@btst/stack/plugins/api` | | Client | `client/plugin.tsx` | `defineClientPlugin` | `@btst/stack/plugins/client` | +Do not add a placeholder half for symmetry. OpenAPI is backend-only, Route Docs +is client-only, and UI Builder is client-only over CMS. When both halves exist, +their camelCase programmatic ID and registration key must agree; package and +URL slugs may remain kebab-case. + **Minimum backend shape:** ```typescript -import { defineBackendPlugin, createDbPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api" - -export const myBackendPlugin = defineBackendPlugin({ - name: "my-plugin", // unique key — must match the key used in stack({ plugins: { ... } }) - dbPlugin: mySchema, // from createDbPlugin(...) - routes: (adapter: Adapter) => { - const listItems = createEndpoint("/items", { method: "GET" }, async () => { - return adapter.findMany({ model: "item" }) - }) - return { listItems } as const - }, -}) +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" + +/** Configuration accepted by `myBackendPlugin`. */ +export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ + hooks?: MyBackendHooks +} -// Export the inferred router type — the client plugin imports this for end-to-end type safety -export type MyApiRouter = ReturnType +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => + defineBackendPlugin({ + id: "myPlugin", // camelCase programmatic ID; package and URL slugs may stay kebab-case + dbPlugin: mySchema, + operations: (adapter) => createMyOperations(adapter, options.hooks), + routes: (_adapter, _context, operations) => { + const listItems = createEndpoint( + "/items", + { method: "GET", requireRequest: true }, + operations.listItems.route(() => ({})), + ) + return { listItems } as const + }, + }) + +/** Inferred router contract imported by the client plugin for end-to-end type safety. */ +export type MyApiRouter = ReturnType["routes"]> ``` **Minimum client shape:** @@ -139,7 +154,7 @@ import { } from "@btst/stack/plugins/client" import { lazy } from "react" -export const MY_PLUGIN_ID = "my-plugin" as const +export const MY_PLUGIN_ID = "myPlugin" as const export interface MyClientConfig { title?: string @@ -186,7 +201,7 @@ choices; `resolve(runtime)` binds the shared runtime. **Backend hook naming conventions:** ```typescript -// Authorization hooks (throw to deny) +// Pre-execution lifecycle hooks (throw to stop execution after authorization) onBeforeCreateItem, onBeforeUpdateItem, onBeforeDeleteItem, onBeforeListItems // Lifecycle hooks (called after success) onAfterCreateItem, onAfterUpdateItem, onAfterDeleteItem, onAfterListItems @@ -205,6 +220,7 @@ packages/stack/src/plugins/your-plugin/ ├── db.ts # createDbPlugin(...) — database schema definition ├── types.ts # Shared TypeScript types (no framework dependencies) ├── schemas.ts # Zod validation schemas for request bodies +├── permissions.ts # Shared authorization descriptor catalog ├── query-keys.ts # React Query key factory (imports from api/query-key-defs.ts) ├── client.css # Plugin CSS (Tailwind source directives, component styles) ├── style.css # Full styles including Tailwind @source directives @@ -212,6 +228,7 @@ packages/stack/src/plugins/your-plugin/ │ ├── plugin.ts # defineBackendPlugin, RouteKey type, prefetchForRoute factory │ ├── getters.ts # Pure DB read functions — no hooks, no HTTP context │ ├── mutations.ts # Server-side write functions — no hooks, no HTTP context +│ ├── operations.ts # Validation, authorization, lifecycle, and execution │ ├── query-key-defs.ts # Shared query key shapes (prevents SSG/SSR key drift) │ ├── serializers.ts # Convert Date fields → ISO strings before setQueryData │ └── index.ts # Barrel re-export of all public backend surface @@ -227,7 +244,10 @@ packages/stack/src/plugins/your-plugin/ └── list-page.internal.tsx # Actual page content (useSuspenseQuery inside) ``` -Not every file is required for a minimal plugin. Start with `db.ts`, `types.ts`, `api/plugin.ts`, and `client/plugin.tsx`. Add the rest as the plugin grows. +Not every file is required for a minimal plugin. A backend plugin starts with +`db.ts`, `types.ts`, `permissions.ts`, `api/operations.ts`, and `api/plugin.ts`; +a client half starts with `client/plugin.tsx`. Add the optional query, style, +and component files as the plugin grows. --- @@ -239,7 +259,7 @@ Define your data models using `createDbPlugin`. Field types: `string`, `boolean` // packages/stack/src/plugins/your-plugin/db.ts import { createDbPlugin } from "@btst/stack/plugins/api" -export const mySchema = createDbPlugin("your-plugin", { +export const mySchema = createDbPlugin("yourPlugin", { item: { modelName: "item", fields: { @@ -279,18 +299,37 @@ export const createItemSchema = z.object({ export const updateItemSchema = createItemSchema.partial() ``` +```typescript +// packages/stack/src/plugins/your-plugin/permissions.ts +import { definePermissions, permission } from "@btst/stack/authorization" + +export const myPermissions = definePermissions("yourPlugin", { + item: { + list: permission(), + create: permission(), + update: permission(), + delete: permission(), + }, +}) +``` + --- ### 3. Backend plugin -**`api/getters.ts`** — pure DB reads, safe for SSG and scripts. Authorization hooks are **not** called here — callers are responsible for access control. +**`api/getters.ts`** — pure DB reads, safe for SSG and scripts. Operation +validation, authorization, and lifecycle hooks are **not** called here; callers +own those concerns. ```typescript // packages/stack/src/plugins/your-plugin/api/getters.ts -import type { Adapter } from "@btst/stack/plugins/api" +import type { DBAdapter as Adapter } from "@btst/db" import type { Item } from "../types" -/** Returns all items sorted newest-first. Authorization hooks are NOT called. */ +/** + * Returns all items sorted newest-first. + * Operation validation, authorization, and lifecycle hooks are NOT called. + */ export async function listItems(adapter: Adapter): Promise { return adapter.findMany({ model: "item", @@ -298,7 +337,10 @@ export async function listItems(adapter: Adapter): Promise { }) as Promise } -/** Returns a single item by ID, or null. Authorization hooks are NOT called. */ +/** + * Returns a single item by ID, or null. + * Operation validation, authorization, and lifecycle hooks are NOT called. + */ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.findOne({ model: "item", @@ -311,87 +353,217 @@ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.create({ model: "item", - data: { ...input, published: false, createdAt: new Date(), updatedAt: new Date() }, + data: { + ...input, + published: input.published ?? false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) +} + +/** + * @remarks Operation validation, authorization, and lifecycle hooks are NOT + * called. The caller owns those concerns. + */ +export async function updateItem( + adapter: Adapter, + id: string, + input: UpdateItemInput, +): Promise { + return adapter.update({ + model: "item", + where: [{ field: "id", value: id }], + update: { ...input, updatedAt: new Date() }, + }) +} + +/** + * @remarks Operation validation, authorization, and lifecycle hooks are NOT + * called. The caller owns those concerns. + */ +export async function deleteItem(adapter: Adapter, id: string): Promise { + await adapter.delete({ + model: "item", + where: [{ field: "id", value: id }], }) } ``` +**`api/operations.ts`** — the single validated, authorized operation inventory +used by HTTP routes, request-scoped server calls, and trusted jobs: + +```typescript +// packages/stack/src/plugins/your-plugin/api/operations.ts +import type { DBAdapter as Adapter } from "@btst/db" +import { defineOperation } from "@btst/stack/plugins/api" +import { z } from "zod" +import { myPermissions } from "../permissions" +import { createItemSchema, updateItemSchema } from "../schemas" +import type { Item } from "../types" +import { listItems } from "./getters" +import { + createItem, + deleteItem, + updateItem, +} from "./mutations" + +/** Lifecycle callbacks composed around the item operations. */ +export interface MyBackendHooks { + /** Runs before an item is created. */ + onBeforeCreateItem?: (data: unknown, ctx: { headers?: Headers }) => Promise | void + /** Runs after an item is created. */ + onAfterCreateItem?: (item: unknown, ctx: { headers?: Headers }) => Promise | void + /** Runs when item creation fails. */ + onErrorCreateItem?: (error: Error, ctx: { headers?: Headers }) => Promise | void +} + +const updateOperationSchema = z.object({ + id: z.string(), + data: updateItemSchema, +}) +const itemIdSchema = z.object({ id: z.string() }) + +function serializeItem(item: Item) { + return { + ...item, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + } +} + +const hookContext = (request?: Request) => ({ + ...(request ? { headers: request.headers } : {}), +}) + +export function createMyOperations( + adapter: Adapter, + hooks?: MyBackendHooks, +) { + const listItemsOperation = defineOperation({ + input: z.object({}), + permission: myPermissions.item.list, + facts: () => undefined, + execute: async () => (await listItems(adapter)).map(serializeItem), + }) + + const createItemOperation = defineOperation({ + input: createItemSchema, + permission: myPermissions.item.create, + facts: () => undefined, + before: async ({ input, request }) => { + await hooks?.onBeforeCreateItem?.(input, hookContext(request)) + }, + execute: async ({ input }) => serializeItem(await createItem(adapter, input)), + after: async ({ result, request }) => { + await hooks?.onAfterCreateItem?.(result, hookContext(request)) + }, + onError: async ({ error, request }) => { + const cause = error instanceof Error ? error : new Error("Create item failed") + await hooks?.onErrorCreateItem?.(cause, hookContext(request)) + }, + }) + + const updateItemOperation = defineOperation({ + input: updateOperationSchema, + permission: myPermissions.item.update, + facts: () => undefined, + execute: async ({ input }) => { + const item = await updateItem(adapter, input.id, input.data) + if (!item) throw new Error("Item not found") + return serializeItem(item) + }, + }) + + const deleteItemOperation = defineOperation({ + input: itemIdSchema, + permission: myPermissions.item.delete, + facts: () => undefined, + execute: async ({ input }) => { + await deleteItem(adapter, input.id) + return { success: true } as const + }, + }) + + return { + listItems: listItemsOperation, + createItem: createItemOperation, + updateItem: updateItemOperation, + deleteItem: deleteItemOperation, + } as const +} +``` + **`api/plugin.ts`** — the main backend plugin definition: ```typescript // packages/stack/src/plugins/your-plugin/api/plugin.ts -import { defineBackendPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api" +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" import { mySchema } from "../db" import { createItemSchema, updateItemSchema } from "../schemas" -import { listItems, getItemById } from "./getters" +import { createMyOperations, type MyBackendHooks } from "./operations" -export interface MyBackendHooks { - onBeforeCreateItem?: (data: unknown, ctx: { headers: Headers }) => Promise | void - onAfterCreateItem?: (item: unknown, ctx: { headers: Headers }) => Promise | void - onErrorCreateItem?: (error: Error, ctx: { headers: Headers }) => Promise | void +/** Configuration accepted by `myBackendPlugin`. */ +export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ + hooks?: MyBackendHooks } -export const myBackendPlugin = (hooks?: MyBackendHooks) => +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => defineBackendPlugin({ - name: "your-plugin", + id: "yourPlugin", dbPlugin: mySchema, - - routes: (adapter: Adapter) => { - const listItemsEndpoint = createEndpoint("/items", { method: "GET" }, async () => { - return listItems(adapter) - }) - - const createItemEndpoint = createEndpoint( + operations: (adapter) => createMyOperations(adapter, options.hooks), + routes: (_adapter, _context, operations) => { + const listItems = createEndpoint( "/items", - { method: "POST", body: createItemSchema }, - async (ctx) => { - if (hooks?.onBeforeCreateItem) { - try { - await hooks.onBeforeCreateItem(ctx.body, { headers: ctx.headers }) - } catch (e) { - throw ctx.error(403, { message: e instanceof Error ? e.message : "Unauthorized" }) - } - } - const item = await adapter.create({ model: "item", data: { ...ctx.body, createdAt: new Date(), updatedAt: new Date() } }) - await hooks?.onAfterCreateItem?.(item, { headers: ctx.headers }) - return item - }, + { method: "GET", requireRequest: true }, + operations.listItems.route(() => ({})), ) - - const updateItemEndpoint = createEndpoint( + const createItem = createEndpoint( + "/items", + { method: "POST", body: createItemSchema, requireRequest: true }, + operations.createItem.route((ctx) => ctx.body), + ) + const updateItem = createEndpoint( "/items/:id", - { method: "PUT", body: updateItemSchema }, - async (ctx) => { - const updated = await adapter.update({ - model: "item", - where: [{ field: "id", value: ctx.params.id }], - update: { ...ctx.body, updatedAt: new Date() }, - }) - if (!updated) throw ctx.error(404, { message: "Item not found" }) - return updated - }, + { method: "PUT", body: updateItemSchema, requireRequest: true }, + operations.updateItem.route((ctx) => ({ id: ctx.params.id, data: ctx.body })), ) - - const deleteItemEndpoint = createEndpoint("/items/:id", { method: "DELETE" }, async (ctx) => { - await adapter.delete({ model: "item", where: [{ field: "id", value: ctx.params.id }] }) - return { success: true } - }) - - return { listItemsEndpoint, createItemEndpoint, updateItemEndpoint, deleteItemEndpoint } as const + const deleteItem = createEndpoint( + "/items/:id", + { method: "DELETE", requireRequest: true }, + operations.deleteItem.route((ctx) => ({ id: ctx.params.id })), + ) + return { listItems, createItem, updateItem, deleteItem } as const }, }) @@ -403,8 +575,16 @@ export type MyApiRouter = ReturnType["routes" ```typescript // packages/stack/src/plugins/your-plugin/api/index.ts export * from "./plugin" +export { createMyOperations, type MyBackendHooks } from "./operations" export { listItems, getItemById } from "./getters" -export { createItem, type CreateItemInput } from "./mutations" +export { + createItem, + deleteItem, + updateItem, + type CreateItemInput, + type UpdateItemInput, +} from "./mutations" +export { myPermissions } from "../permissions" ``` --- @@ -426,7 +606,7 @@ import { lazy } from "react" import type { QueryClient } from "@tanstack/react-query" import type { MyApiRouter } from "../api/plugin" -export const MY_PLUGIN_ID = "your-plugin" as const +export const MY_PLUGIN_ID = "yourPlugin" as const export interface MyClientConfig { title?: string @@ -480,7 +660,7 @@ function myLoader(config: ResolvedMyClientConfig) { if (isConnectionError(error)) { console.warn( "[btst/your-plugin] route.loader() failed — no server at build time. " + - "Use myStack.raw['your-plugin'].prefetchForRoute() for SSG.", + "Use myStack.raw.yourPlugin.prefetchForRoute() for SSG.", ) } // Do not re-throw — let React Query store errors and Error Boundaries handle them during render @@ -820,7 +1000,7 @@ npm install @btst/stack ## Hooks - + ``` Preview locally: @@ -882,11 +1062,11 @@ Before opening a pull request for a new plugin, verify every item: **Plugin implementation** -- [ ] Backend plugin: `name`, `dbPlugin`, and `routes` are all present -- [ ] Client plugin: `name` and `routes` are present +- [ ] Backend plugin: camelCase `id`, `dbPlugin`, `operations`, and operation-bound `routes` are present +- [ ] Client plugin: the matching camelCase `id` and `resolve(runtime)` returning `routes` are present - [ ] `api/getters.ts` contains only pure DB reads — no HTTP context, no lifecycle hooks -- [ ] `api/getters.ts` has JSDoc noting "Authorization hooks are NOT called" -- [ ] `api/mutations.ts` (if present) has JSDoc noting "Authorization hooks are NOT called" +- [ ] `api/getters.ts` has JSDoc noting operation validation, authorization, and lifecycle hooks are not called +- [ ] `api/mutations.ts` (if present) has the same JSDoc and says the caller owns those concerns - [ ] `api/index.ts` re-exports all public backend surface (getters, mutations, types, router type) - [ ] `api/query-key-defs.ts` defines shared key shapes imported by both `query-keys.ts` and `prefetchForRoute` - [ ] `api/serializers.ts` converts `Date` fields to ISO strings before `setQueryData` diff --git a/README.md b/README.md index 2f8616bee..f80e40031 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,10 @@ Enable the features you need and keep building your product. | **Route Docs** | Auto-generated client route documentation with interactive navigation | | **Comments** | Commenting system with moderation, likes, and nested replies | -Each plugin ships **frontend + backend together**: -routes, APIs, database models, React components, SSR, and SEO — already wired. +Full-stack plugins ship separate frontend and backend definitions: routes, +APIs, database models, React components, SSR, and SEO—already wired. Intentional +one-sided plugins stay one-sided: OpenAPI is backend-only, Route Docs is +client-only, and UI Builder is client-only over CMS. **Want a specific plugin?** [Open an issue](https://github.com/better-stack-ai/better-stack/issues/new) and let us know! diff --git a/docs/content/docs/api-reference.mdx b/docs/content/docs/api-reference.mdx index 4c5448666..90746de7e 100644 --- a/docs/content/docs/api-reference.mdx +++ b/docs/content/docs/api-reference.mdx @@ -5,21 +5,21 @@ description: Autogenerated API reference for BTST ## Backend (`@btst/stack/api`) -### stack +### createBackendStack - + ### BackendPlugin -### BackendLibConfig +### BackendStackConfig - + -### BackendLib +### BackendStack - + ### toNodeHandler @@ -81,19 +81,17 @@ objects, or the shared API/site/QueryClient runtime. values through `providerConfig`; it is not a provider override and applications do not configure it on `StackProvider`. -`createStackClient` remains a deprecated forwarding alias during the RC migration. - ### ClientPlugin -### ClientLib +### ClientStack - + -### ClientLibConfig +### ResolvedClientStack - + ### SitemapEntry diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index 937d6fc3b..ce5a647c5 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -242,11 +242,11 @@ without constructing a synthetic `Request`. Existing backend-only adapters can keep the request-aware `getIdentity({ request, headers })` callback, but those adapters are not accepted by headers-only layout helpers until adapted. -Pass it to `stack({ auth: serverAuth })`. Every Blog HTTP route and its matching `myStack.forRequest(request).operations.blog.*` method use the same operation and rule. Operations load authoritative post, author, and current publish facts before authorization, so client-supplied ownership or visibility facts are never trusted. Trusted jobs can call `myStack.trusted.blog.*`; trusted calls skip user authorization but retain input validation, trusted fact derivation, domain behavior, and lifecycle hooks. +Pass it to `createBackendStack({ auth: serverAuth })`. Every Blog HTTP route and its matching `myStack.forRequest(request).operations.blog.*` method use the same operation and rule. Operations load authoritative post, author, and current publish facts before authorization, so client-supplied ownership or visibility facts are never trusted. Trusted jobs can call `myStack.trusted.blog.*`; trusted calls skip user authorization but retain input validation, trusted fact derivation, domain behavior, and lifecycle hooks. Ordinary denials become HTTP 401 for anonymous identities and 403 for authenticated identities. Identity validation and rule failures remain errors instead of being converted into denials. -`stack()` also checks the operation catalog at typecheck time. A server adapter +`createBackendStack()` also checks the operation catalog at typecheck time. A server adapter that registers a different permission catalog—or reuses the same stable id with an incompatible fact schema—cannot be composed with Blog operations. @@ -674,6 +674,8 @@ defineOperation({ ### Trusted server calls +{/* canonical-dx-guard: migration:start reason="removed server namespace example" */} + Before: ```ts @@ -681,6 +683,8 @@ await app.api.cms.createContentItem("article", body); await app.api.blog.getAllPosts(); ``` +{/* canonical-dx-guard: migration:end */} + After: ```ts @@ -703,13 +707,15 @@ After: ```ts aiChatBackendPlugin({ access: "authorized" }); -stack({ auth: serverAuth, plugins: { aiChat } }); +createBackendStack({ auth: serverAuth, plugins: { aiChat } }); ``` The backend `mode` alias and `getUserId` callback are removed. The client plugin's `mode` still controls its real conversation UI/persistence mode; it is not a security boundary. ### Lifecycle context and Comments authorship +{/* canonical-dx-guard: migration:start reason="removed lifecycle and identity bridge example" */} + Before: ```ts @@ -725,6 +731,8 @@ const onBeforeCreatePost = (_input: unknown, context: BlogApiContext) => { }; ``` +{/* canonical-dx-guard: migration:end */} + After: ```ts diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index 8e26535e2..7b38635f6 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -10,6 +10,422 @@ This page documents breaking changes between major versions and provides migrati --- +## v3 RC2 → RC3: Canonical stack and plugin DX + +RC3 removes the remaining duplicate runtime configuration and historical naming +seams. The migration is mechanical: rename the constructors, move shared client +runtime to one client stack, nest backend hooks, update programmatic IDs and +lifecycle names, and select the server surface whose trust contract matches the +caller. + +### 1. Rename both stack constructors + +{/* canonical-dx-guard: migration:start reason="removed constructor example" */} + +Before: + +```ts +import { stack } from "@btst/stack" +import { createStackClient } from "@btst/stack/client" + +const backend = stack({ /* ... */ }) +const client = createStackClient({ /* ... */ }) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +import { createBackendStack } from "@btst/stack/api" +import { createClientStack } from "@btst/stack/client" + +const backend = createBackendStack({ /* ... */ }) +const client = createClientStack({ /* ... */ }) +``` + +{/* canonical-dx-guard: migration:start reason="removed constructor names in migration prose" */} + +Use only `createBackendStack` and `createClientStack` in maintained code. The +earlier `stack` and `createStackClient` names are migration inputs, not parallel +constructor stories. + +{/* canonical-dx-guard: migration:end */} + +### 2. Move shared client runtime to one resolved stack + +{/* canonical-dx-guard: migration:start reason="duplicated client runtime example" */} + +Before, every client plugin and the provider repeated transport, site, cache, +and request values: + +```tsx +const blog = blogClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient, + headers: requestHeaders, +}) + + + {children} + +``` + +{/* canonical-dx-guard: migration:end */} + +After, create one request-specific stack for loaders and metadata by supplying +`api.headers`, and a separate stable browser stack without request headers: + +```tsx +const clientStack = createClientStack({ + api: { baseURL, basePath: "/api/data" }, + site: { baseURL, basePath: "/pages" }, + queryClient, + plugins: { + blog: blogClientPlugin(), + }, +}) + + + {children} + +``` + +`createClientStack` is the only owner of API location, site location, +QueryClient, request headers, registered plugin definitions, and endpoint +replacement. `StackProvider` owns browser/framework services (`router`, +`auth`, `notify`, and `i18n`) plus genuine plugin browser customization. + +Never serialize the request-specific stack into the browser. Construct the SSR +stack with filtered request headers, then construct the browser stack from +browser-safe origins and the hydrated QueryClient. + +### 3. Delete manual provider generics and override maps + +{/* canonical-dx-guard: migration:start reason="removed provider generic example" */} + +Before: + +```tsx +type AppPluginOverrides = { + "ai-chat": AiChatPluginOverrides + blog: BlogPluginOverrides +} + + + basePath="/pages" + overrides={overrides} +> + {children} + +``` + +{/* canonical-dx-guard: migration:end */} + +After, the resolved definitions registered in `createClientStack({ plugins })` +infer the valid override keys and each value shape: + +```tsx + + {children} + +``` + +Omit `overrides` entirely when no plugin needs customization. +`usePluginOverrides()` supplies a safe empty value for an omitted optional +block; genuinely required plugin customization remains required by the +registered definition's inferred type. + +### 4. Rename programmatic plugin IDs to camelCase + +Package paths and URL slugs remain kebab-case. Only programmatic registration +IDs, provider keys, resource namespaces, and diagnostics change: + +| Package or URL slug | Removed programmatic ID | Canonical programmatic ID | +| --- | --- | --- | +| `ai-chat` | `ai-chat` | `aiChat` | +| `blog` | `blog` | `blog` | +| `cms` | `cms` | `cms` | +| `comments` | `comments` | `comments` | +| `form-builder` | `form-builder` | `formBuilder` | +| `kanban` | `kanban` | `kanban` | +| `media` | `media` | `media` | +| `open-api` | `open-api` | `openApi` | +| `route-docs` | `route-docs` | `routeDocs` | +| `ui-builder` | `ui-builder` | `uiBuilder` | + +{/* canonical-dx-guard: migration:start reason="removed plugin ID example" */} + +```diff +plugins: { +- "ai-chat": aiChatClientPlugin(), +- "form-builder": formBuilderClientPlugin(), +- "route-docs": routeDocsClientPlugin(), +- "ui-builder": uiBuilderClientPlugin(), ++ aiChat: aiChatClientPlugin(), ++ formBuilder: formBuilderClientPlugin(), ++ routeDocs: routeDocsClientPlugin(), ++ uiBuilder: uiBuilderClientPlugin(), +} +``` + +{/* canonical-dx-guard: migration:end */} + +### 5. Use one backend options object with nested hooks + +{/* canonical-dx-guard: migration:start reason="removed backend factory forms" */} + +Before: + +```ts +blogBackendPlugin(blogHooks) +commentsBackendPlugin({ allowEditing: false, onAfterPost }) +kanbanBackendPlugin(resolveUser, searchUsers, kanbanHooks) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +import type { BlogBackendHooks } from "@btst/stack/plugins/blog/api" +import type { KanbanBackendHooks } from "@btst/stack/plugins/kanban/api" + +const blogHooks: BlogBackendHooks = { + // Use the canonical Blog hook names from the tables below. +} +const kanbanHooks: KanbanBackendHooks = { + // Use the canonical Kanban hook names from the tables below. +} + +blogBackendPlugin({ hooks: blogHooks }) +commentsBackendPlugin({ + allowEditing: false, + resolveUser, + hooks: { onAfterCreateComment }, +}) +kanbanBackendPlugin({ hooks: kanbanHooks }) + + + {children} + +``` + +Every backend plugin is a factory receiving at most one options object. +Optional-only factories allow `plugin()`. Required plugin configuration stays +required on its owning surface: AI Chat backend options keep its model, tools, +and access mode; CMS keeps content types; Comments keeps behavior and user +resolution; Media keeps storage, tenant, and upload configuration; OpenAPI +keeps its presentation/schema options. Kanban user resolution and search are +browser services and move to the inferred `StackProvider` override shown above. + +OpenAPI is intentionally backend-only. Route Docs is intentionally client-only. +UI Builder is intentionally client-only and composes over the registered CMS +backend/client contract; do not invent matching halves for symmetry. + +### 6. Rename every backend lifecycle callback + +The tables below come from the structured `*_LIFECYCLE_HOOK_MIGRATIONS` +inventories shipped by each plugin. Names absent from these tables did not +change. Keep all callbacks under the plugin factory's `hooks` field. + +{/* canonical-dx-guard: migration:start reason="complete removed lifecycle inventory" */} + +#### AI Chat + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeToolsActivated` | `onBeforeActivateTools` | +| `onConversationsRead` | `onAfterListConversations` | +| `onConversationRead` | `onAfterGetConversation` | +| `onConversationCreated` | `onAfterCreateConversation` | +| `onConversationUpdated` | `onAfterUpdateConversation` | +| `onConversationDeleted` | `onAfterDeleteConversation` | +| `onChatError` | `onErrorChat` | +| `onListConversationsError` | `onErrorListConversations` | +| `onGetConversationError` | `onErrorGetConversation` | +| `onCreateConversationError` | `onErrorCreateConversation` | +| `onUpdateConversationError` | `onErrorUpdateConversation` | +| `onDeleteConversationError` | `onErrorDeleteConversation` | + +#### Blog + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeNextPreviousPosts` | `onBeforeGetNextPreviousPosts` | +| `onPostsRead` | `onAfterListPosts` | +| `onPostCreated` | `onAfterCreatePost` | +| `onPostUpdated` | `onAfterUpdatePost` | +| `onPostDeleted` | `onAfterDeletePost` | +| `onNextPreviousPostsRead` | `onAfterGetNextPreviousPosts` | +| `onListPostsError` | `onErrorListPosts` | +| `onNextPreviousPostsError` | `onErrorGetNextPreviousPosts` | +| `onCreatePostError` | `onErrorCreatePost` | +| `onUpdatePostError` | `onErrorUpdatePost` | +| `onDeletePostError` | `onErrorDeletePost` | + +#### CMS + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeCreate` | `onBeforeCreateContent` | +| `onAfterCreate` | `onAfterCreateContent` | +| `onBeforeUpdate` | `onBeforeUpdateContent` | +| `onAfterUpdate` | `onAfterUpdateContent` | +| `onBeforeDelete` | `onBeforeDeleteContent` | +| `onAfterDelete` | `onAfterDeleteContent` | +| `onError` | `onErrorExecuteContentOperation` | + +#### Comments + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeList` | `onBeforeListComments` | +| `onBeforeCount` | `onBeforeCountComments` | +| `onBeforeListByAuthor` | `onBeforeListCommentsByAuthor` | +| `onBeforePost` | `onBeforeCreateComment` | +| `onAfterPost` | `onAfterCreateComment` | +| `onBeforeEdit` | `onBeforeUpdateComment` | +| `onAfterEdit` | `onAfterUpdateComment` | +| `onBeforeLike` | `onBeforeToggleCommentReaction` | +| `onBeforeStatusChange` | `onBeforeModerateComment` | +| `onAfterApprove` | `onAfterApproveComment` | +| `onBeforeDelete` | `onBeforeDeleteComment` | +| `onAfterDelete` | `onAfterDeleteComment` | + +#### Form Builder + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeFormCreated` | `onBeforeCreateForm` | +| `onAfterFormCreated` | `onAfterCreateForm` | +| `onBeforeFormUpdated` | `onBeforeUpdateForm` | +| `onAfterFormUpdated` | `onAfterUpdateForm` | +| `onBeforeFormDeleted` | `onBeforeDeleteForm` | +| `onAfterFormDeleted` | `onAfterDeleteForm` | +| `onSubmissionError` | `onErrorSubmission` | +| `onBeforeSubmissionDeleted` | `onBeforeDeleteSubmission` | +| `onAfterSubmissionDeleted` | `onAfterDeleteSubmission` | + +#### Kanban + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeReadBoard` | `onBeforeGetBoard` | +| `onBoardsRead` | `onAfterListBoards` | +| `onBoardRead` | `onAfterGetBoard` | +| `onBoardCreated` | `onAfterCreateBoard` | +| `onBoardUpdated` | `onAfterUpdateBoard` | +| `onBoardDeleted` | `onAfterDeleteBoard` | +| `onListBoardsError` | `onErrorListBoards` | +| `onReadBoardError` | `onErrorGetBoard` | +| `onCreateBoardError` | `onErrorCreateBoard` | +| `onUpdateBoardError` | `onErrorUpdateBoard` | +| `onDeleteBoardError` | `onErrorDeleteBoard` | +| `onColumnCreated` | `onAfterCreateColumn` | +| `onColumnUpdated` | `onAfterUpdateColumn` | +| `onColumnDeleted` | `onAfterDeleteColumn` | +| `onTaskCreated` | `onAfterCreateTask` | +| `onTaskUpdated` | `onAfterUpdateTask` | +| `onTaskDeleted` | `onAfterDeleteTask` | + +#### Media + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeDelete` | `onBeforeDeleteAsset` | +| `onAfterDelete` | `onAfterDeleteAsset` | +| `onOperationError` | `onError` | + +{/* canonical-dx-guard: migration:end */} + +The lifecycle grammar is `onBefore`, +`onAfter`, and `onError`. Meaningful domain +events such as chat, submission receipt, moderation approval, and upload +finalization retain their domain vocabulary. Media storage-adapter callbacks +are transport contracts and are not part of this mapping. + +### 7. Select an explicit server trust surface + +{/* canonical-dx-guard: migration:start reason="removed ambiguous server namespaces" */} + +Before: + +```ts +await app.api.blog.updatePost(input) +await app.forRequest(request).api.blog.updatePost(input) +await app.internal.blog.updatePost(input) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +await app.forRequest(request).operations.blog.updatePost(input) +await app.trusted.blog.updatePost(input) +await app.raw.blog.prefetchForRoute("post", queryClient, { slug }) +``` + +`forRequest(request).operations` runs validation, trusted-fact derivation, +configured server authorization, domain behavior, and lifecycle hooks. +`trusted` skips only user authorization and keeps the rest of that operation +pipeline. `raw` is the explicit lower-level escape hatch and first-party +plugins expose only narrow SSG prefetch helpers there. Standalone exported +getters and mutations are also lower-level primitives whose caller owns +validation, authorization, and lifecycle composition. + +Omitting `createBackendStack({ auth })` preserves permissive compatibility. +Once server auth is configured, its schema-bound rules are authoritative and +missing or denied rules fail closed. Browser checks remain presentation only; +derive authorization facts from server data rather than client input. + +### 8. Apply endpoint and identity boundaries + +A path-only `endpoints..api` replacement inherits the top-level API +origin and request headers. A replacement `baseURL` must include a replacement +`basePath` and establishes a new transport boundary: server cookies, +authorization, proxy-authorization, and other request headers are not +inherited. Add only explicitly browser-safe `browserHeaders` or an explicit +Fetch `credentials` mode, and only when the destination implements that +plugin's BTST HTTP contract. Site endpoints follow the same path-only versus +complete-replacement rule independently. + +`initialIdentity` is tri-state: `undefined` means no server snapshot was +supplied and the client resolver may run immediately; `null` is an explicitly +hydrated anonymous snapshot; an identity object is an explicitly hydrated +authenticated snapshot. Serialize only schema-validated identity and trusted +deployment origins, never a server stack or request headers. + +BTST core is authentication-provider agnostic. Better Auth and Better Auth UI +are not core dependencies or hidden identity bridges; adapt the provider your +application already uses through `createClientAuth` and `createServerAuth`. +The separate Better Auth UI companion migration happens downstream of the +completed core DX migration. + +--- + ## v2 → v3: Framework entries and resolved client runtime BTST v3 has one supported framework-wiring path: framework entry factories @@ -82,9 +498,9 @@ request-aware examples when SSR authorization needs headers or session state. ### 2. Move shared runtime to the client stack -The Blog client plugin is the first built-in migrated to the resolved runtime -definition. Remove its shared runtime fields and configure API, site, and -QueryClient once in a shared factory: +Every maintained client plugin now uses the resolved runtime definition. +Remove shared runtime fields from each plugin factory and configure API, site, +and QueryClient once in a shared factory: ```ts title="lib/stack-client.ts" export const getStackClient = ( @@ -147,12 +563,9 @@ Wrap this client provider from `app/(request)/pages/layout.tsx` using `app/(static)/pages` and use header-free `getServerClientOrigins()` there. Both route groups keep the `/pages/*` URL. -For Blog, `apiBaseURL`, `apiBasePath`, site fields, `queryClient`, and request -headers are no longer plugin options. Its SSR loaders, metadata, browser hooks, -and mutations use the same resolved runtime. Some other first-party plugins -temporarily retain their legacy shared-runtime factory shape until their -follow-up migration tickets land; new plugin definitions should use the -resolved shape shown here. +`apiBaseURL`, `apiBasePath`, site fields, `queryClient`, and request headers are +no longer built-in plugin options. SSR loaders, metadata, browser hooks, and +mutations use the same resolved runtime for every maintained client plugin. ### 3. Replace render guards with `StackProvider.auth` @@ -257,78 +670,11 @@ onBeforeSubmission: async (_formSlug, data, ctx) => { ### 7. Rename normalized backend lifecycle hooks -RC3 removes the lifecycle spellings used by earlier v3 prereleases. Rename -each configured hook key using the tables below; callback parameters and return -values are otherwise unchanged. JavaScript applications must make the same -updates because removed hook keys are not invoked at runtime. - -#### Form Builder - -| Earlier v3 spelling | RC3 spelling | -| --- | --- | -| `onBeforeFormCreated` | `onBeforeCreateForm` | -| `onAfterFormCreated` | `onAfterCreateForm` | -| `onBeforeFormUpdated` | `onBeforeUpdateForm` | -| `onAfterFormUpdated` | `onAfterUpdateForm` | -| `onBeforeFormDeleted` | `onBeforeDeleteForm` | -| `onAfterFormDeleted` | `onAfterDeleteForm` | -| `onSubmissionError` | `onErrorSubmission` | -| `onBeforeSubmissionDeleted` | `onBeforeDeleteSubmission` | -| `onAfterSubmissionDeleted` | `onAfterDeleteSubmission` | - -Form Builder's `onBeforeCreateForm`, `onBeforeUpdateForm`, and -`onBeforeSubmission` hooks receive a separate mutable-data value and may return -its transformed replacement. Other lifecycle hooks observe their deeply -readonly operation context and perform domain side effects; they do not -transform operation input by returning a value. Submission receipt hooks such -as `onBeforeSubmission` and `onAfterSubmission` keep their domain-event names. - -Keep hook denials exception-based while renaming them: - -```diff -- onBeforeSubmissionDeleted: async (submissionId, ctx) => { -- if (!isAdmin(ctx.headers)) return false -+ onBeforeDeleteSubmission: async (submissionId, ctx) => { -+ if (!isAdmin(ctx.headers)) throw new Error("Admin access required") - } -``` - -#### Kanban - -| Earlier v3 spelling | RC3 spelling | -| --- | --- | -| `onBeforeReadBoard` | `onBeforeGetBoard` | -| `onBoardsRead` | `onAfterListBoards` | -| `onBoardRead` | `onAfterGetBoard` | -| `onBoardCreated` | `onAfterCreateBoard` | -| `onBoardUpdated` | `onAfterUpdateBoard` | -| `onBoardDeleted` | `onAfterDeleteBoard` | -| `onListBoardsError` | `onErrorListBoards` | -| `onReadBoardError` | `onErrorGetBoard` | -| `onCreateBoardError` | `onErrorCreateBoard` | -| `onUpdateBoardError` | `onErrorUpdateBoard` | -| `onDeleteBoardError` | `onErrorDeleteBoard` | -| `onColumnCreated` | `onAfterCreateColumn` | -| `onColumnUpdated` | `onAfterUpdateColumn` | -| `onColumnDeleted` | `onAfterDeleteColumn` | -| `onTaskCreated` | `onAfterCreateTask` | -| `onTaskUpdated` | `onAfterUpdateTask` | -| `onTaskDeleted` | `onAfterDeleteTask` | - -Task and column reorder or move operations continue to use the corresponding -update lifecycle; RC3 does not add separate reorder or move hook phases. - -#### Media - -| Earlier v3 spelling | RC3 spelling | -| --- | --- | -| `onBeforeDelete` | `onBeforeDeleteAsset` | -| `onAfterDelete` | `onAfterDeleteAsset` | -| `onOperationError` | `onError` | - -Media upload hooks keep their domain-event names. Storage-adapter callbacks are -transport contracts rather than public plugin lifecycle hooks and are not part -of this rename. +Use the complete seven-plugin mapping in +[Rename every backend lifecycle callback](#6-rename-every-backend-lifecycle-callback) +above. JavaScript applications must update removed keys too; removed callback +names are not invoked at runtime. Keep hook denials exception-based while +renaming them. ### 8. Replace the retired provider-specific auth scaffold @@ -369,7 +715,8 @@ export const serverAuth = createServerAuth({ }) ``` -Pass `clientAuth` to `StackProvider`, pass `serverAuth` to `stack()`, and keep +Pass `clientAuth` to `StackProvider`, pass `serverAuth` to +`createBackendStack({ auth: serverAuth })`, and keep sign-in, account, and organization routes in your chosen authentication framework. Remove the retired plugin from existing `btst init` commands and delete its generated imports, overrides, CSS import, and package dependencies. @@ -402,6 +749,8 @@ BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers ### Summary of Changes +{/* canonical-dx-guard: migration:start reason="historical v2 constructor summary" */} + | v1 (Better Stack) | v2 (BTST) | |-------------------|-----------| | `betterStack()` | `stack()` | @@ -413,6 +762,8 @@ BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers | `better-stack-client.tsx` | `stack-client.tsx` | | `"Powered by Better Stack"` | `"Powered by BTST"` | +{/* canonical-dx-guard: migration:end */} + ### Migration Steps @@ -434,6 +785,8 @@ pnpm add @btst/stack@latest Update your backend configuration file (commonly `lib/better-stack.ts` → `lib/stack.ts`): +{/* canonical-dx-guard: migration:start reason="historical v2 constructor example" */} + ```diff - import { betterStack } from "@btst/stack"; + import { stack } from "@btst/stack"; @@ -443,6 +796,8 @@ Update your backend configuration file (commonly `lib/better-stack.ts` → `lib/ // ... your configuration }); ``` + +{/* canonical-dx-guard: migration:end */} @@ -550,6 +905,8 @@ mv lib/better-stack-auth.ts lib/stack-auth.ts You can use these find-and-replace patterns to quickly update your codebase: +{/* canonical-dx-guard: migration:start reason="historical v2 constructor replacement" */} + | Find | Replace | |------|---------| | `betterStack(` | `stack(` | @@ -563,6 +920,8 @@ You can use these find-and-replace patterns to quickly update your codebase: | `from "./better-stack"` | `from "./stack"` | | `from "./better-stack-client"` | `from "./stack-client"` | +{/* canonical-dx-guard: migration:end */} + ### TypeScript Types The following types have been renamed: diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 2c918731d..8e8e183d4 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -42,8 +42,8 @@ Common flags: - `lib/query-client.ts` - API and pages catch-all routes using the framework entry factories - Global CSS imports (including plugin CSS) -- Pages layout with `QueryClientProvider` and one top-level - `StackProvider.router` / `StackProvider.api` configuration +- Pages layout with `QueryClientProvider`, one resolved client stack, and the + framework router passed to `StackProvider` If root layout patching is not safe for your file shape, the command prints manual patch instructions instead of applying a destructive rewrite. diff --git a/docs/content/docs/databases/adapters.mdx b/docs/content/docs/databases/adapters.mdx index b06a23050..a07e3e08c 100644 --- a/docs/content/docs/databases/adapters.mdx +++ b/docs/content/docs/databases/adapters.mdx @@ -45,18 +45,18 @@ See the [Installation guide](/installation#install-database-adapter) for detaile ## Usage -When you configure BTST, the `stack()` function collects all plugin database schemas and merges them into a unified schema. The adapter function receives this merged schema and returns an adapter that translates BTST's database operations to your ORM. +When you configure BTST, the `createBackendStack()` function collects all plugin database schemas and merges them into a unified schema. The adapter function receives this merged schema and returns an adapter that translates BTST's database operations to your ORM. ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -73,7 +73,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createDrizzleAdapter } from "@btst/adapter-drizzle" import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc. import postgres from "postgres" @@ -81,7 +81,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch const client = postgres(process.env.DATABASE_URL!) const drizzleDb = drizzle(client) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -95,7 +95,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createKyselyAdapter } from "@btst/adapter-kysely" import { Kysely, PostgresDialect } from "kysely" import { Pool } from "pg" @@ -106,7 +106,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch }) }) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -120,14 +120,14 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMongodbAdapter } from "@btst/adapter-mongodb" import { MongoClient } from "mongodb" const client = new MongoClient(process.env.MONGODB_URI!) const mongoDb = client.db() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -142,10 +142,10 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" // IMPORTANT: Memory adapter is used for development and testing only - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -168,7 +168,7 @@ The `adapter` function receives the merged database schema (`db`) containing all The adapter pattern follows this flow: -1. `stack()` merges all plugin schemas into a unified `db` object +1. `createBackendStack()` merges all plugin schemas into a unified `db` object 2. Your `adapter` function receives this `db` object 3. The adapter creator function (e.g., `createPrismaAdapter`, `createDrizzleAdapter`) returns an adapter instance 4. This adapter instance implements the common interface (create, update, findOne, etc.) diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 42be2dd00..d287cb89d 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -11,7 +11,7 @@ Here's a high-level overview of how BTST works: The server handles database operations, API endpoints, data prefetching, routing, and server-side rendering. -**`stack`** manages the backend layer: +**`createBackendStack`** manages the backend layer: - **API Router**: Routes incoming requests to the appropriate plugin handlers. Returns a handler function that you mount at your API path. - **DB Adapter**: Translates BTST's database operations to your ORM (Prisma, Drizzle, Kysely, MongoDB). Plugins define schemas that get merged and passed to the adapter. @@ -57,14 +57,15 @@ customization. ## Plugins -Plugins are the building blocks of BTST. Each feature (like Blog) ships as **two separate plugins**—one for the backend, one for the client—that you register independently: +Plugins are the building blocks of BTST. Full-stack features such as Blog ship +separate backend and client definitions that you register independently: ```ts // Backend: lib/stack.ts -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" -const { handler } = stack({ +const { handler } = createBackendStack({ plugins: { blog: blogBackendPlugin({ /* config */ }) }, @@ -87,7 +88,7 @@ const stackClient = createClientStack({ }) ``` -**Backend plugins** (registered in `stack`): +**Backend plugins** (registered in `createBackendStack`): - Define database schemas (tables, columns, relations) - Register API route handlers for CRUD operations - Provide post-authorization domain lifecycle hooks @@ -100,6 +101,10 @@ const stackClient = createClientStack({ This separation keeps server-only code (database schemas, API handlers) out of your client bundle, and allows each plugin to be configured independently for its context. +One-sided plugins are intentional: OpenAPI is backend-only, Route Docs is +client-only, and UI Builder is client-only over the CMS backend/client +contract. Register only the side that exists; do not add a placeholder half. + ## Resolved Client Runtime and Provider Services Configure shared runtime once and pass the resolved result to the provider: diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index d3407a7ac..383302f4f 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -196,13 +196,13 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -218,7 +218,7 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createDrizzleAdapter } from "@btst/adapter-drizzle" import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc. import postgres from "postgres" @@ -226,7 +226,7 @@ In order to use BTST, your application must meet the following requirements: const client = postgres(process.env.DATABASE_URL!) const drizzleDb = drizzle(client) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -240,7 +240,7 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createKyselyAdapter } from "@btst/adapter-kysely" import { Kysely, PostgresDialect } from "kysely" import { Pool } from "pg" @@ -251,7 +251,7 @@ In order to use BTST, your application must meet the following requirements: }) }) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -265,14 +265,14 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMongodbAdapter } from "@btst/adapter-mongodb" import { MongoClient } from "mongodb" const client = new MongoClient(process.env.MONGODB_URI!) const mongoDb = client.db() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -288,10 +288,10 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" // IMPORTANT: Memory adapter is used for development and testing only - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -306,7 +306,7 @@ In order to use BTST, your application must meet the following requirements: **What happens here:** - - `stack()` collects all plugin database schemas and merges them into a unified `dbSchema` + - `createBackendStack()` collects all plugin database schemas and merges them into a unified `dbSchema` - The `basePath` determines where your API is mounted (e.g., `/api/data/*`) - The `adapter` function receives this merged schema (`db`) and returns an adapter that translates BTST's database operations to your ORM - The `handler` is a request handler function `(request: Request) => Promise` that processes all API calls @@ -434,7 +434,7 @@ In order to use BTST, your application must meet the following requirements: - Keep the API path in this route, `stack({ basePath })`, and + Keep the API path in this route, `createBackendStack({ basePath })`, and `createClientStack({ api: { basePath } })` identical. `StackProvider` receives that browser-safe projection through its `stack` prop. diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index c2b0e77e2..5f4f8e269 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -34,7 +34,7 @@ Follow these steps to add the AI Chat plugin to your BTST setup. Import and register the AI Chat backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { defineAuthorization } from "@btst/stack/authorization" import { createServerAuth } from "@btst/stack/authorization/server" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" @@ -92,7 +92,7 @@ const serverAuth = createServerAuth({ }, }) -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", auth: serverAuth, plugins: { @@ -481,6 +481,8 @@ Customize post-authorization domain behavior with optional lifecycle hooks. Keep AI Chat lifecycle names use the action-first `onBefore`, `onAfter`, and `onError` grammar. Chat completion remains the domain event `onAfterChat`. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Before RC3 | RC3 | |---|---| | `onBeforeToolsActivated` | `onBeforeActivateTools` | @@ -496,6 +498,8 @@ AI Chat lifecycle names use the action-first `onBefore`, `onAfte | `onUpdateConversationError` | `onErrorUpdateConversation` | | `onDeleteConversationError` | `onErrorDeleteConversation` | +{/* canonical-dx-guard: migration:end */} + **Example usage:** ```ts title="lib/stack.ts" @@ -519,7 +523,7 @@ const chatHooks: AiChatBackendHooks = { }, } -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ plugins: { aiChat: aiChatBackendPlugin({ model: openai("gpt-4o"), @@ -1006,14 +1010,14 @@ For public chatbots without user authentication: ### Backend Setup ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" import { openai } from "@ai-sdk/openai" // Example rate limiter (implement your own) const rateLimiter = new Map() -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { aiChat: aiChatBackendPlugin({ diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 04bcfbdd0..a76a80ecb 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -43,11 +43,11 @@ Follow these steps to add the Blog plugin to your BTST setup. Import and register the blog backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { blog: blogBackendPlugin() @@ -419,6 +419,8 @@ platform objects. Blog lifecycle names use the action-first `onBefore`, `onAfter`, and `onError` grammar. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Removed name | Canonical name | | --- | --- | | `onBeforeNextPreviousPosts` | `onBeforeGetNextPreviousPosts` | @@ -433,6 +435,8 @@ Blog lifecycle names use the action-first `onBefore`, | `onUpdatePostError` | `onErrorUpdatePost` | | `onDeletePostError` | `onErrorDeletePost` | +{/* canonical-dx-guard: migration:end */} + **Example usage:** ```ts title="lib/stack.ts" @@ -453,7 +457,7 @@ const blogHooks: BlogBackendHooks = { }, } -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ plugins: { blog: blogBackendPlugin({ hooks: blogHooks }) }, @@ -661,7 +665,7 @@ request-time application behavior. ### Authorized operations -When `stack()` receives a one-rule server adapter, use the request-scoped API +When `createBackendStack()` receives a one-rule server adapter, use the request-scoped API for user-facing Blog work. These calls run the same operations as the HTTP routes, including input validation, trusted fact derivation, authorization, and lifecycle hooks: diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 9eaf13da2..83e2ac51f 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -123,11 +123,11 @@ export type CMSTypes = { Register the CMS backend plugin with your content types: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { contentTypes } from "./cms-schemas" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { cms: cmsBackendPlugin({ contentTypes }) @@ -571,6 +571,8 @@ CMS lifecycle names use the action-first `onBefore`, aggregate error phase remains one callback; its `operation` argument identifies the failed create, update, delete, list, or get operation. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Removed name | Canonical name | | --- | --- | | `onBeforeCreate` | `onBeforeCreateContent` | @@ -581,6 +583,8 @@ the failed create, update, delete, list, or get operation. | `onAfterDelete` | `onAfterDeleteContent` | | `onError` | `onErrorExecuteContentOperation` | +{/* canonical-dx-guard: migration:end */} + ## Type Safety The CMS plugin provides **end-to-end type safety** from schema definition to frontend rendering: @@ -720,7 +724,7 @@ const auth = createServerAuth({ }, }) -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { cms: cmsBackendPlugin({ contentTypes }) }, adapter: (db) => createMemoryAdapter(db)({}), diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx index 498f1168a..dd5e2d096 100644 --- a/docs/content/docs/plugins/comments.mdx +++ b/docs/content/docs/plugins/comments.mdx @@ -30,11 +30,11 @@ your `stack.ts` file. The adapter can wrap any session/authentication library; Comments has no dependency on it. ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" import { serverAuth } from "./authorization.server" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", auth: serverAuth, plugins: { @@ -252,7 +252,7 @@ and resource facts their rule authorized, so overlapping changes cannot bypass the rule. Once authorization is installed, a missing Comments rule denies that action. -Omitting `stack({ auth })` preserves permissive server behavior for applications +Omitting `createBackendStack({ auth })` preserves permissive server behavior for applications that do not configure authorization. Use `createServerAuth()` to enable exact descriptor enforcement and default-deny missing rules. Client gates use the matching `createClientAuth()` adapter and exact descriptors shown above. @@ -399,6 +399,8 @@ Comments lifecycle names use the action-first `onBefore` and `onAfter` grammar. Approval remains a distinct moderation event; it is not flattened into a generic update callback. +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + | Removed name | Canonical name | | --- | --- | | `onBeforeList` | `onBeforeListComments` | @@ -414,6 +416,8 @@ it is not flattened into a generic update callback. | `onBeforeDelete` | `onBeforeDeleteComment` | | `onAfterDelete` | `onAfterDeleteComment` | +{/* canonical-dx-guard: migration:end */} + All lifecycle hooks receive validated input, server-derived facts, resolved identity, and request in a deeply readonly context. Authorization runs first; diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index 09893fde9..f15411a7b 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -7,11 +7,16 @@ Learn how to create custom plugins for BTST. Plugins extend your application wit ## Overview -A BTST plugin consists of two parts: +A BTST plugin may expose either or both of these independent parts: - **Backend Plugin** - Defines database schema and API endpoints - **Client Plugin** - Provides routes, React components, and hooks +Do not invent a matching half solely for symmetry. OpenAPI is the reference +backend-only plugin, Route Docs is client-only, and UI Builder is client-only +over CMS. Programmatic IDs use camelCase even when package and URL slugs use +kebab-case. + You can create plugins **inside your project** (like the [Todo example](#in-project-plugin-example)) or as a **standalone package** to publish on npm using the [Plugin Starter repository](https://github.com/better-stack-ai/plugin-starter). ## Core Concepts @@ -31,7 +36,7 @@ your-plugin/ │ ├── client.tsx # Client plugin with routes │ ├── hooks.tsx # React Query hooks │ ├── components.tsx # Page components -│ └── overrides.ts # Framework adapter types +│ └── overrides.ts # Plugin-specific browser customization ├── schema.ts # Database schema definition └── types.ts # Shared TypeScript types ``` @@ -253,7 +258,7 @@ it skips only user authorization. Once a plugin declares operations, every composed HTTP route must have a same-key operation and use `operations.operationKey.route(ctx => input)` as its endpoint handler. The generated handler owns execution of that exact -request-bound operation and carries a private transport identity; `stack()` +request-bound operation and carries a private transport identity; `createBackendStack()` validates both the inventory and exact binding during composition. It reports the plugin key, route key, method, and path for an undeclared or unbound route. This keeps a new route from silently bypassing the operation pipeline. Extra @@ -299,7 +304,7 @@ rationale, or route declared as both operation-backed and infrastructure fails composition. Public infrastructure still runs the handler's validation and security/domain checks; the declaration does not turn other routes public. -Register the same catalog in the application's server authorization. `stack()` +Register the same catalog in the application's server authorization. `createBackendStack()` rejects catalog mismatches at typecheck time. Call the composed operation—not the descriptor—from application code: @@ -398,9 +403,9 @@ export const todosBackendPlugin = () => defineBackendPlugin({ }) ``` -Use `app.forRequest(request).operations.todos.*` for request-driven server work and `app.trusted.todos.*` for explicitly trusted jobs. Trusted calls skip user authorization but retain validation, fact derivation, domain behavior, and lifecycle hooks. First-party `stack().raw` namespaces are narrow SSG prefetch surfaces only. +Use `app.forRequest(request).operations.todos.*` for request-driven server work and `app.trusted.todos.*` for explicitly trusted jobs. Trusted calls skip user authorization but retain validation, fact derivation, domain behavior, and lifecycle hooks. First-party backend stack `raw` namespaces are narrow SSG prefetch surfaces only. -Pure getters and mutations may remain exported lower-level adapter primitives for plugin internals and migrations. They do not promise authorization, validation, or lifecycle composition and should not be duplicated onto `stack().raw`. +Pure getters and mutations may remain exported lower-level adapter primitives for plugin internals and migrations. They do not promise authorization, validation, or lifecycle composition and should not be duplicated onto the backend stack's `raw` surface. ## Client Plugin @@ -963,7 +968,7 @@ This pattern ensures: ### Backend Registration ```ts -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin(), blog: blogBackendPlugin() }, adapter: (db) => createMemoryAdapter(db)({}), diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index be6c329fa..8425aee33 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -49,14 +49,14 @@ Ensure you followed the general [framework installation guide](/installation) fi Register the Form Builder backend plugin: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { formBuilder: formBuilderBackendPlugin({ @@ -438,7 +438,7 @@ export const serverAuth = createServerAuth({ }) ``` -Pass `serverAuth` as `stack({ auth: serverAuth, ... })`. On the client, create a +Pass `serverAuth` as `createBackendStack({ auth: serverAuth, ... })`. On the client, create a client binding from the same rule set and hydrate the request identity in the layout: diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index cef7a8adb..22eeb374e 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -41,11 +41,11 @@ Follow these steps to add the Kanban plugin to your BTST setup. Import and register the kanban backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { kanban: kanbanBackendPlugin() @@ -59,7 +59,7 @@ export { handler, dbSchema } ``` The `kanbanBackendPlugin()` accepts optional lifecycle hooks for domain -validation and integrations. Configure authorization once on `stack({ auth })`; +validation and integrations. Configure authorization once on `createBackendStack({ auth })`; ordinary role and ownership rules do not belong in hooks. ### 2. Add Plugin to Client @@ -400,7 +400,7 @@ export const serverAuth = createServerAuth({ }) ``` -Pass `serverAuth` to `stack({ auth: serverAuth, ... })`. Create the client +Pass `serverAuth` to `createBackendStack({ auth: serverAuth, ... })`. Create the client binding from the same browser-safe definition. Resolve the request identity and trusted client origins in the request layout, then serialize only those plain values to the client provider. Request headers and the resolved request stack @@ -639,9 +639,16 @@ HTTP endpoints, request-scoped calls, and trusted calls all adapt these same operations. ```ts -import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" +import { + kanbanBackendPlugin, + type KanbanBackendHooks, +} from "@btst/stack/plugins/kanban/api" + +const hooks: KanbanBackendHooks = { + // Add canonical lifecycle callbacks here. +} -const app = stack({ +const app = createBackendStack({ auth: serverAuth, plugins: { kanban: kanbanBackendPlugin({ hooks }) diff --git a/docs/content/docs/plugins/media.mdx b/docs/content/docs/plugins/media.mdx index 6023609f1..23b507b7d 100644 --- a/docs/content/docs/plugins/media.mdx +++ b/docs/content/docs/plugins/media.mdx @@ -35,11 +35,11 @@ Follow these steps to add the Media plugin to your BTST setup. Import and register the media backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { mediaBackendPlugin } from "@btst/stack/plugins/media/api" import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { media: mediaBackendPlugin({ @@ -416,7 +416,7 @@ const serverAuth = createServerAuth({ }, }) -export const appStack = stack({ +export const appStack = createBackendStack({ auth: serverAuth, plugins: { media: mediaBackendPlugin({ @@ -545,12 +545,12 @@ use the raw migration helpers for an intentional cross-tenant move. ### Configuration ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { mediaBackendPlugin } from "@btst/stack/plugins/media/api" import { s3Adapter } from "@btst/stack/plugins/media/api/adapters/s3" import { getSession } from "@/lib/auth" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { media: mediaBackendPlugin({ @@ -582,13 +582,13 @@ an adapter-level concern, so use the retained standalone primitives instead of the removed raw business API: ```ts -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createFolder, getFolderByName, } from "@btst/stack/plugins/media/api" -const app = stack({ /* ... */ }) +const app = createBackendStack({ /* ... */ }) async function getOrCreateProfileFolder(profileId: string) { const name = `blog-gen-${profileId}` diff --git a/docs/content/docs/plugins/open-api.mdx b/docs/content/docs/plugins/open-api.mdx index 6805cd040..d9054f2cb 100644 --- a/docs/content/docs/plugins/open-api.mdx +++ b/docs/content/docs/plugins/open-api.mdx @@ -37,12 +37,12 @@ Ensure you followed the general [framework installation guide](/installation) fi Import and register the OpenAPI backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { blog: blogBackendPlugin(), @@ -238,7 +238,7 @@ openApiBackendPlugin({ ### Schema shows empty or incomplete endpoints -The OpenAPI plugin reads the routes composed by `stack()`, so plugin registration +The OpenAPI plugin reads the routes composed by `createBackendStack()`, so plugin registration order does not affect completeness or generated ordering. A migrated business route without a same-key operation fails stack composition with its plugin, route, method, and path instead of appearing as an undeclared endpoint. diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx index 5a730af7c..c936f27e5 100644 --- a/docs/content/docs/plugins/ui-builder.mdx +++ b/docs/content/docs/plugins/ui-builder.mdx @@ -50,7 +50,7 @@ Ensure you followed the general [framework installation guide](/installation) fi The UI Builder stores pages as CMS content items. Add the pre-configured content type to your CMS: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createServerAuth } from "@btst/stack/authorization/server" import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" import { @@ -67,7 +67,7 @@ const auth = createServerAuth({ }, }) -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { cms: cmsBackendPlugin({ @@ -228,6 +228,10 @@ uiBuilderClientPlugin({ }) ``` +UI Builder is intentionally client-only over CMS. The `cmsBackendPlugin` +supplies its persistence and HTTP contract; there is no UI Builder backend +plugin to register. + ## Component Registry The UI Builder uses a component registry to define available components. A default registry is provided with common components: diff --git a/e2e/tests/helpers/mock-auth.ts b/e2e/tests/helpers/mock-auth.ts index 052c4abb7..9ca0a5b6f 100644 --- a/e2e/tests/helpers/mock-auth.ts +++ b/e2e/tests/helpers/mock-auth.ts @@ -3,7 +3,7 @@ import type { BrowserContext } from "@playwright/test"; /** Headers for direct requests to the generated apps' request-aware backend. */ export function mockAuthHeaders(userId = "admin-e2e") { return { - cookie: `better-auth.session_token=mock-session-${userId}`, + cookie: `btst.example_session=mock-session-${userId}`, }; } @@ -14,7 +14,7 @@ export async function setMockAuthCookie( ) { await context.addCookies([ { - name: "better-auth.session_token", + name: "btst.example_session", value: `mock-session-${userId}`, domain: "localhost", path: "/", diff --git a/e2e/tests/smoke.auth-blog.spec.ts b/e2e/tests/smoke.auth-blog.spec.ts index dc567ef72..ef7beb288 100644 --- a/e2e/tests/smoke.auth-blog.spec.ts +++ b/e2e/tests/smoke.auth-blog.spec.ts @@ -14,7 +14,7 @@ import { expect, test, type BrowserContext } from "@playwright/test"; */ // Mock auth cookie name (replace with your actual auth cookie name) -const AUTH_COOKIE_NAME = "better-auth.session_token"; +const AUTH_COOKIE_NAME = "btst.example_session"; // API base path for authenticated endpoints // Note: Blog plugin defines routes at /posts, /tags, etc. diff --git a/package.json b/package.json index 2edf729f5..caee99fad 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:dts": "turbo --filter \"./packages/*\" dev:dts", "clean": "turbo --filter \"./packages/*\" clean && rm -rf node_modules", "format": "biome format . --write", - "lint": "biome check .", + "lint": "node scripts/check-canonical-dx.mjs && biome check .", + "check:canonical-dx": "node scripts/check-canonical-dx.mjs", "lint:fix": "biome check . --fix --unsafe", "bump": "bumpp", "test": "turbo --filter \"./packages/*\" test", diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs new file mode 100644 index 000000000..d03852c63 --- /dev/null +++ b/scripts/check-canonical-dx.mjs @@ -0,0 +1,651 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const textExtensions = new Set([ + ".hbs", + ".js", + ".json", + ".jsx", + ".md", + ".mdx", + ".mjs", + ".ts", + ".tsx", +]); + +const guidanceTargets = [ + "README.md", + "CONTRIBUTING.md", + "docs/content/docs", + ".agents/skills", +]; +const generatedTargets = [ + "scripts/codegen/README.md", + "scripts/codegen/files", + "packages/cli/src/templates", + "packages/cli/scripts/fixtures", + "packages/stack/scripts/fixtures/registry/README.md", + "packages/stack/registry", + "playground/src", +]; + +// Exact historical inputs are immutable negative fixtures, not maintained +// examples. Their README and hash allowlist fail closed if their bytes change. +const guardExclusions = new Map([ + [ + "packages/cli/scripts/fixtures/legacy-next", + "immutable pre-migration CLI inputs documented by the fixture README", + ], +]); + +const backendPlugins = [ + { + factory: "aiChatBackendPlugin", + lifecycleSlug: "ai-chat", + hookType: "AiChatBackendHooks", + }, + { + factory: "blogBackendPlugin", + lifecycleSlug: "blog", + hookType: "BlogBackendHooks", + }, + { + factory: "cmsBackendPlugin", + lifecycleSlug: "cms", + hookType: "CMSBackendHooks", + }, + { + factory: "commentsBackendPlugin", + lifecycleSlug: "comments", + hookType: "CommentsBackendHooks", + }, + { + factory: "formBuilderBackendPlugin", + lifecycleSlug: "form-builder", + hookType: "FormBuilderBackendHooks", + }, + { + factory: "kanbanBackendPlugin", + lifecycleSlug: "kanban", + hookType: "KanbanBackendHooks", + }, + { + factory: "mediaBackendPlugin", + lifecycleSlug: "media", + hookType: "MediaBackendHooks", + }, + { factory: "openApiBackendPlugin" }, +]; +const clientFactories = [ + "aiChatClientPlugin", + "blogClientPlugin", + "cmsClientPlugin", + "commentsClientPlugin", + "formBuilderClientPlugin", + "kanbanClientPlugin", + "mediaClientPlugin", + "routeDocsClientPlugin", + "uiBuilderClientPlugin", +]; +const pluginIds = [ + "aiChat", + "blog", + "cms", + "comments", + "formBuilder", + "kanban", + "media", + "openApi", + "routeDocs", + "uiBuilder", +]; +const removedPluginIds = [ + "ai-chat", + "form-builder", + "open-api", + "route-docs", + "ui-builder", +]; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const pluginIdPattern = pluginIds.map(escapeRegExp).join("|"); +const removedPluginIdPattern = removedPluginIds.map(escapeRegExp).join("|"); + +function collectFiles(target) { + for (const excluded of guardExclusions.keys()) { + if (target === excluded || target.startsWith(`${excluded}/`)) return []; + } + const absolute = join(root, target); + if (!statSync(absolute).isDirectory()) return [absolute]; + const files = []; + for (const entry of readdirSync(absolute, { withFileTypes: true })) { + if (entry.name === "node_modules") continue; + const child = join(absolute, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(relative(root, child))); + } else if (textExtensions.has(extname(entry.name))) { + files.push(child); + } + } + return files; +} + +function readGuardSource(absolute, file) { + const source = readFileSync(absolute, "utf8"); + if (!/^packages\/stack\/registry\/[^/]+\.json$/.test(file)) return source; + + const registry = JSON.parse(source); + if (!Array.isArray(registry.files)) return source; + const encodedSources = []; + const metadata = { + ...registry, + files: registry.files.map(({ content, ...entry }) => { + if (typeof content === "string") encodedSources.push(content); + return entry; + }), + }; + return `${JSON.stringify(metadata)}\n${encodedSources.join("\n")}`; +} + +function stripMigrationBlocks(source, file) { + const startPattern = + /^\s*\{\/\* canonical-dx-guard: migration:start reason="[^"]+" \*\/\}\s*$/; + const endPattern = /^\s*\{\/\* canonical-dx-guard: migration:end \*\/\}\s*$/; + let insideMigration = false; + const stripped = source + .split("\n") + .map((line, index) => { + if (startPattern.test(line)) { + if (insideMigration) { + throw new Error( + `${file}:${index + 1}: nested canonical DX migration marker`, + ); + } + insideMigration = true; + return ""; + } + if (endPattern.test(line)) { + if (!insideMigration) { + throw new Error( + `${file}:${index + 1}: unmatched canonical DX migration marker`, + ); + } + insideMigration = false; + return ""; + } + return insideMigration ? "" : line; + }) + .join("\n"); + if (insideMigration) { + throw new Error(`${file}: unclosed canonical DX migration marker`); + } + return stripped; +} + +function lineAt(source, index) { + return source.slice(0, index).split("\n").length; +} + +function skipTrivia(source, startIndex) { + let index = startIndex; + while (index < source.length) { + if (/\s/.test(source[index])) { + index += 1; + continue; + } + if (source.startsWith("//", index)) { + const lineEnd = source.indexOf("\n", index + 2); + return lineEnd < 0 ? source.length : skipTrivia(source, lineEnd + 1); + } + if (source.startsWith("/*", index)) { + const commentEnd = source.indexOf("*/", index + 2); + return commentEnd < 0 + ? source.length + : skipTrivia(source, commentEnd + 2); + } + break; + } + return index; +} + +function recordMatches(failures, file, source, label, pattern) { + for (const match of source.matchAll(pattern)) { + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label, + match: match[0].replace(/\s+/g, " ").slice(0, 100), + }); + } +} + +function recordLifecycleProperties( + failures, + file, + fullSource, + objectSource, + baseIndex, + factory, + names, +) { + for (const name of names) { + const propertyPattern = new RegExp( + `(?:^|[,{])\\s*(?:async\\s+)?\\*?\\s*(?:${escapeRegExp(name)}\\b|["']${escapeRegExp(name)}["'])(?=\\s*(?:\\??:|\\(|,|\\}))`, + "gm", + ); + for (const match of objectSource.matchAll(propertyPattern)) { + const absoluteIndex = + baseIndex + (match.index ?? 0) + match[0].indexOf(name); + failures.push({ + file, + line: lineAt(fullSource, absoluteIndex), + label: `${factory} uses a removed lifecycle callback`, + match: name, + }); + } + } +} + +function readTopLevelObject(source, openIndex) { + let depth = 0; + let quote; + let escaped = false; + let lineComment = false; + let blockComment = false; + let topLevel = ""; + + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index]; + const next = source[index + 1]; + + if (lineComment) { + if (char === "\n") lineComment = false; + if (depth <= 1) topLevel += char; + continue; + } + if (blockComment) { + if (char === "*" && next === "/") { + blockComment = false; + index += 1; + } + if (depth <= 1) topLevel += char === "\n" ? "\n" : " "; + continue; + } + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + if (depth <= 1) topLevel += char; + else if (char === "\n") topLevel += "\n"; + continue; + } + if (char === "/" && next === "/") { + lineComment = true; + if (depth <= 1) topLevel += " "; + index += 1; + continue; + } + if (char === "/" && next === "*") { + blockComment = true; + if (depth <= 1) topLevel += " "; + index += 1; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + if (depth <= 1) topLevel += char; + continue; + } + if (char === "{") { + depth += 1; + topLevel += depth <= 1 ? char : " "; + continue; + } + if (char === "}") { + depth -= 1; + topLevel += depth <= 1 ? char : " "; + if (depth === 0) return { end: index, topLevel }; + continue; + } + topLevel += depth <= 1 || char === "\n" ? char : " "; + } + + return undefined; +} + +function checkFactoryCalls( + failures, + file, + source, + factory, + kind, + contextualLifecycleNames = [], +) { + // This guard intentionally checks the direct factory style maintained by this + // repository. It is a deterministic migration check, not a JavaScript parser; + // TypeScript, Biome, and the docs build validate general source semantics. + const callPattern = new RegExp(`\\b${factory}\\b`, "g"); + for (const match of source.matchAll(callPattern)) { + let cursor = skipTrivia(source, (match.index ?? 0) + match[0].length); + if (source[cursor] !== "(") continue; + cursor = skipTrivia(source, cursor + 1); + if (source[cursor] === ")") continue; + if (source[cursor] !== "{") { + const expression = source + .slice(cursor) + .match( + /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*(?:\s*\([^()\n]*\))?\s*(?=[,)])/, + )?.[0] + .trim(); + if (kind === "client" || !expression) continue; + if ( + /^[A-Za-z_$][\w$]*$/.test(expression) && + /(?:options?|config|opts)$/i.test(expression) + ) { + continue; + } + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: `${kind} factory must receive one options object, not positional hooks`, + match: factory, + }); + continue; + } + const object = readTopLevelObject(source, cursor); + if (!object) continue; + if ( + kind === "backend" && + /(?:^|[,{])\s*(?:async\s+)?\*?\s*(?:on(?:Before|After)[A-Z][A-Za-z0-9]*\b|onError(?:[A-Z][A-Za-z0-9]*)?\b|["'](?:on(?:Before|After)[A-Z][A-Za-z0-9]*|onError(?:[A-Z][A-Za-z0-9]*)?)["'])\s*(?::|\()/.test( + object.topLevel, + ) + ) { + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: "backend lifecycle callbacks must be nested under hooks", + match: factory, + }); + } + if (kind === "backend" && contextualLifecycleNames.length > 0) { + const inlineHooks = object.topLevel.match(/(?:^|[,{])\s*hooks\s*:/); + if (inlineHooks?.index !== undefined) { + const openIndex = skipTrivia( + source, + cursor + inlineHooks.index + inlineHooks[0].length, + ); + const hooksObject = + source[openIndex] === "{" + ? readTopLevelObject(source, openIndex) + : undefined; + if (hooksObject) { + recordLifecycleProperties( + failures, + file, + source, + hooksObject.topLevel, + openIndex, + factory, + contextualLifecycleNames, + ); + } + } + + const hooksReference = object.topLevel + .match(/\bhooks\s*:\s*([A-Za-z_$][\w$]*)|\b(hooks)\s*(?=[,}])/) + ?.slice(1) + .find(Boolean); + if (hooksReference) { + const declarationPattern = new RegExp( + `\\b(?:const|let|var)\\s+${escapeRegExp(hooksReference)}(?:\\s*:[^=;]+)?\\s*=\\s*\\{`, + "g", + ); + for (const declaration of source.matchAll(declarationPattern)) { + const openIndex = + (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); + const hooksObject = readTopLevelObject(source, openIndex); + if (!hooksObject) continue; + recordLifecycleProperties( + failures, + file, + source, + hooksObject.topLevel, + openIndex, + factory, + contextualLifecycleNames, + ); + } + } + } + if ( + kind === "client" && + /(?:^|[,{])\s*(?:(?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)\b|["'](?:apiBaseURL|apiBasePath|siteBaseURL|siteBasePath|queryClient|headers|credentials)["'])\s*(?::|,|})/.test( + object.topLevel, + ) + ) { + failures.push({ + file, + line: lineAt(source, match.index ?? 0), + label: "client plugin duplicates stack-owned runtime", + match: factory, + }); + } + } +} + +function checkTypedHookObjects( + failures, + file, + source, + factory, + typeName, + names, +) { + if (names.length === 0) return; + const declarationPattern = new RegExp( + `:\\s*${escapeRegExp(typeName)}\\s*=\\s*\\{`, + "g", + ); + for (const declaration of source.matchAll(declarationPattern)) { + const openIndex = + (declaration.index ?? 0) + declaration[0].lastIndexOf("{"); + const object = readTopLevelObject(source, openIndex); + if (!object) continue; + recordLifecycleProperties( + failures, + file, + source, + object.topLevel, + openIndex, + factory, + names, + ); + } +} + +function lifecycleInventory() { + const names = new Set(); + const namesByFactory = new Map(); + for (const { lifecycleSlug, factory } of backendPlugins) { + if (!lifecycleSlug) continue; + const source = readFileSync( + join( + root, + `packages/stack/src/plugins/${lifecycleSlug}/api/lifecycle-migrations.ts`, + ), + "utf8", + ); + const object = source.match( + /Object\.freeze\(\{([\s\S]*?)\}\s+as const\)/, + )?.[1]; + if (!object) + throw new Error(`Unable to read ${lifecycleSlug} lifecycle inventory`); + const pluginNames = [...object.matchAll(/^\s*([A-Za-z0-9]+):/gm)].map( + (match) => match[1], + ); + for (const name of pluginNames) names.add(name); + namesByFactory.set(factory, pluginNames); + } + const contextualNames = new Set([ + "onBeforeCreate", + "onAfterCreate", + "onBeforeUpdate", + "onAfterUpdate", + "onBeforeDelete", + "onAfterDelete", + "onError", + ]); + return { + globalNames: [...names].filter((name) => !contextualNames.has(name)), + contextualNamesByFactory: new Map( + [...namesByFactory].map(([factory, pluginNames]) => [ + factory, + pluginNames.filter((name) => contextualNames.has(name)), + ]), + ), + }; +} + +const guidanceFiles = guidanceTargets.flatMap(collectFiles); +const generatedFiles = generatedTargets.flatMap(collectFiles); +const allFiles = [...new Set([...guidanceFiles, ...generatedFiles])]; +const generatedSet = new Set(generatedFiles); +const { globalNames: removedLifecycleNames, contextualNamesByFactory } = + lifecycleInventory(); +const failures = []; + +for (const absolute of allFiles) { + const file = relative(root, absolute); + const source = stripMigrationBlocks(readGuardSource(absolute, file), file); + + recordMatches( + failures, + file, + source, + "removed constructor", + /\bcreateStackClient\b|\bstack\s*\(|import\s*\{[^}\n]*\bstack\b[^}\n]*\}\s*from\s*["']@btst\/stack(?:\/api)?["']/g, + ); + recordMatches( + failures, + file, + source, + "manual StackProvider generic", + /\bStackProvider[ \t]*).){0,2000}\b(?:api|basePath)=/gs, + ); + recordMatches( + failures, + file, + source, + "removed programmatic plugin ID", + new RegExp( + `["'](?:${removedPluginIdPattern})["']\\s*:\\s*\\w+(?:Backend|Client)Plugin`, + "g", + ), + ); + recordMatches( + failures, + file, + source, + "removed plugin registration or override key", + new RegExp( + `\\b(?:plugins|overrides)\\s*(?:=|:)\\s*\\{[\\s\\S]{0,3000}?["'](?:${removedPluginIdPattern})["']\\s*:|\\bplugin\\s*:\\s*["'](?:${removedPluginIdPattern})["']`, + "g", + ), + ); + recordMatches( + failures, + file, + source, + "removed intrinsic plugin ID", + new RegExp(`\\bid\\s*:\\s*["'](?:${removedPluginIdPattern})["']`, "g"), + ); + recordMatches( + failures, + file, + source, + "ambiguous server namespace", + new RegExp( + `\\.(?:api|internal)\\.(?:${pluginIdPattern})\\b|\\.forRequest\\([^)]*\\)\\.api\\b`, + "g", + ), + ); + + for (const { factory, hookType } of backendPlugins) { + const contextualNames = contextualNamesByFactory.get(factory) ?? []; + checkFactoryCalls( + failures, + file, + source, + factory, + "backend", + contextualNames, + ); + if (hookType) { + checkTypedHookObjects( + failures, + file, + source, + factory, + hookType, + contextualNames, + ); + } + } + for (const factory of clientFactories) { + checkFactoryCalls(failures, file, source, factory, "client"); + } + for (const name of removedLifecycleNames) { + recordMatches( + failures, + file, + source, + "removed lifecycle callback", + new RegExp(`\\b${escapeRegExp(name)}\\b`, "g"), + ); + } + + if (generatedSet.has(absolute)) { + recordMatches( + failures, + file, + source, + "provider-specific identity bridge in generated source", + /better-auth\.session_token|@btst\/better-auth-ui|\bcreateBetterAuth\w*/g, + ); + } +} + +if (failures.length > 0) { + console.error( + "Canonical DX guard found legacy maintained guidance or sources:", + ); + for (const failure of failures) { + console.error( + `- ${failure.file}:${failure.line} ${failure.label}: ${failure.match}`, + ); + } + process.exitCode = 1; +} else { + console.log( + `Canonical DX guard passed (${allFiles.length} maintained guidance/generated files).`, + ); +} diff --git a/scripts/codegen/files/nextjs/lib/authorization.server.ts b/scripts/codegen/files/nextjs/lib/authorization.server.ts index 3d89cd09e..3559693d4 100644 --- a/scripts/codegen/files/nextjs/lib/authorization.server.ts +++ b/scripts/codegen/files/nextjs/lib/authorization.server.ts @@ -7,8 +7,8 @@ function getMockRequestIdentity(headers: Headers) { const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/nextjs/lib/stack-auth.ts b/scripts/codegen/files/nextjs/lib/stack-auth.ts index c95a2d591..63c7ef37d 100644 --- a/scripts/codegen/files/nextjs/lib/stack-auth.ts +++ b/scripts/codegen/files/nextjs/lib/stack-auth.ts @@ -16,8 +16,8 @@ const exampleServerAuth = createServerAuth({ const token = cookie .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/nextjs/lib/stack-client.tsx b/scripts/codegen/files/nextjs/lib/stack-client.tsx index 897832f77..d119745a9 100644 --- a/scripts/codegen/files/nextjs/lib/stack-client.tsx +++ b/scripts/codegen/files/nextjs/lib/stack-client.tsx @@ -133,7 +133,7 @@ export const createAppClientStack = ( }, }), aiChat: aiChatClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, mode: "authenticated", seo: { siteName: "BTST Chat", @@ -175,7 +175,7 @@ export const createAppClientStack = ( description: "Documentation for all client routes in this application", }), kanban: kanbanClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, seo: { siteName: "BTST Kanban", description: "Manage your projects with kanban boards", @@ -203,7 +203,7 @@ export const createAppClientStack = ( }), media: mediaClientPlugin({ uploadMode: "direct", - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, }), }, ...(crossOriginApiEndpoint diff --git a/scripts/codegen/files/react-router/app/lib/authorization.server.ts b/scripts/codegen/files/react-router/app/lib/authorization.server.ts index b184bb59d..3cd0bd545 100644 --- a/scripts/codegen/files/react-router/app/lib/authorization.server.ts +++ b/scripts/codegen/files/react-router/app/lib/authorization.server.ts @@ -5,8 +5,8 @@ function getMockRequestIdentity(headers: Headers) { const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/react-router/app/lib/stack-auth.ts b/scripts/codegen/files/react-router/app/lib/stack-auth.ts index a940573e5..11e2c8c70 100644 --- a/scripts/codegen/files/react-router/app/lib/stack-auth.ts +++ b/scripts/codegen/files/react-router/app/lib/stack-auth.ts @@ -13,8 +13,8 @@ const exampleServerAuth = createServerAuth({ const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/react-router/app/lib/stack-client.tsx b/scripts/codegen/files/react-router/app/lib/stack-client.tsx index 375565cbf..8288a3869 100644 --- a/scripts/codegen/files/react-router/app/lib/stack-client.tsx +++ b/scripts/codegen/files/react-router/app/lib/stack-client.tsx @@ -130,7 +130,7 @@ export const createAppClientStack = ( }, }), aiChat: aiChatClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, mode: "authenticated", }), cms: cmsClientPlugin(), @@ -141,7 +141,7 @@ export const createAppClientStack = ( description: "Documentation for all client routes in this application", }), kanban: kanbanClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, seo: { siteName: "BTST Kanban", description: "Manage your projects with kanban boards", @@ -158,7 +158,7 @@ export const createAppClientStack = ( }), media: mediaClientPlugin({ uploadMode: "direct", - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, }), }, ...(crossOriginApiEndpoint diff --git a/scripts/codegen/files/tanstack/src/lib/authorization.server.ts b/scripts/codegen/files/tanstack/src/lib/authorization.server.ts index b184bb59d..3cd0bd545 100644 --- a/scripts/codegen/files/tanstack/src/lib/authorization.server.ts +++ b/scripts/codegen/files/tanstack/src/lib/authorization.server.ts @@ -5,8 +5,8 @@ function getMockRequestIdentity(headers: Headers) { const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/tanstack/src/lib/stack-auth.ts b/scripts/codegen/files/tanstack/src/lib/stack-auth.ts index a940573e5..11e2c8c70 100644 --- a/scripts/codegen/files/tanstack/src/lib/stack-auth.ts +++ b/scripts/codegen/files/tanstack/src/lib/stack-auth.ts @@ -13,8 +13,8 @@ const exampleServerAuth = createServerAuth({ const token = (headers.get("cookie") ?? "") .split(";") .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); + .find((part) => part.startsWith("btst.example_session=")) + ?.slice("btst.example_session=".length); if (!token?.startsWith("mock-session-")) return null; const id = token.slice("mock-session-".length); return { diff --git a/scripts/codegen/files/tanstack/src/lib/stack-client.tsx b/scripts/codegen/files/tanstack/src/lib/stack-client.tsx index 50af6ee01..e3707209a 100644 --- a/scripts/codegen/files/tanstack/src/lib/stack-client.tsx +++ b/scripts/codegen/files/tanstack/src/lib/stack-client.tsx @@ -130,7 +130,7 @@ export const createAppClientStack = ( }, }), aiChat: aiChatClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, mode: "authenticated", }), cms: cmsClientPlugin(), @@ -141,7 +141,7 @@ export const createAppClientStack = ( description: "Documentation for all client routes in this application", }), kanban: kanbanClientPlugin({ - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, seo: { siteName: "BTST Kanban", description: "Manage your projects with kanban boards", @@ -158,7 +158,7 @@ export const createAppClientStack = ( }), media: mediaClientPlugin({ uploadMode: "direct", - ...(requestIdentity ? { identityPartition: requestIdentity } : {}), + identityPartition: requestIdentity ?? undefined, }), }, ...(crossOriginApiEndpoint