Skip to content

Commit 32e9f36

Browse files
authored
Merge pull request #256 from better-stack-ai/v3
Release Better Stack v3.0.0
2 parents 1f96adc + f967b03 commit 32e9f36

707 files changed

Lines changed: 97191 additions & 28505 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/btst-backend-plugin-dev/REFERENCE.md‎

Lines changed: 43 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -3,85 +3,45 @@
33
## defineBackendPlugin shape (api/plugin.ts)
44

55
```typescript
6-
import type { DBAdapter as Adapter } from "@btst/db"
7-
import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api"
8-
import { z } from "zod"
9-
import { dbSchema } from "./db-schema"
10-
import { listItems, getItemById } from "./getters"
11-
import { createItem, updateItem, deleteItem } from "./mutations"
12-
13-
const ItemQuerySchema = z.object({ limit: z.coerce.number().optional() })
14-
const CreateItemSchema = z.object({ name: z.string() })
15-
16-
export const myBackendPlugin = defineBackendPlugin({
17-
name: "my-plugin",
18-
dbPlugin: dbSchema,
19-
20-
// api factory — bound to shared adapter, no HTTP context
21-
api: (adapter: Adapter) => ({
22-
listItems: () => listItems(adapter),
23-
getItemById: (id: string) => getItemById(adapter, id),
24-
createItem: (data: CreateItemInput) => createItem(adapter, data),
25-
}),
26-
27-
// routes factory — HTTP endpoints built with createEndpoint
28-
routes: (adapter: Adapter) => {
29-
const listItemsEndpoint = createEndpoint(
30-
"/items",
31-
{ method: "GET", query: ItemQuerySchema },
32-
async (ctx) => {
33-
return await listItems(adapter)
34-
},
35-
)
36-
37-
const createItemEndpoint = createEndpoint(
38-
"/items",
39-
{ method: "POST", body: CreateItemSchema },
40-
async (ctx) => {
41-
return await createItem(adapter, ctx.body)
42-
},
43-
)
44-
45-
const getItemEndpoint = createEndpoint(
46-
"/items/:id",
47-
{ method: "GET" },
48-
async (ctx) => {
49-
const item = await getItemById(adapter, ctx.params.id)
50-
if (!item) throw ctx.error(404, { message: "Item not found" })
51-
return item
52-
},
53-
)
54-
55-
return { listItems: listItemsEndpoint, createItem: createItemEndpoint, getItem: getItemEndpoint } as const
56-
},
57-
})
58-
59-
// Router type for client consumption
60-
export type MyApiRouter = ReturnType<ReturnType<typeof myBackendPlugin>["routes"]>
61-
```
62-
63-
### ctx object inside createEndpoint handlers
6+
/** Configuration accepted by `myBackendPlugin`. */
7+
export interface MyBackendPluginOptions {
8+
/** Lifecycle callbacks composed around plugin operations. */
9+
hooks?: MyBackendHooks
10+
}
6411

65-
| Property | Description |
66-
|---|---|
67-
| `ctx.query` | Validated query params (when `query:` schema provided) |
68-
| `ctx.body` | Validated request body (when `body:` schema provided) |
69-
| `ctx.params` | URL path params (e.g. `:id` → `ctx.params.id`) |
70-
| `ctx.headers` | Request `Headers` object |
71-
| `ctx.request` | Raw `Request` object |
72-
| `ctx.error(status, { message })` | Create an HTTP error — always `throw` the result |
12+
export const myBackendPlugin = (options: MyBackendPluginOptions = {}) =>
13+
defineBackendPlugin({
14+
id: "myPlugin",
15+
dbPlugin: dbSchema,
16+
operations: (adapter) => createMyOperations(adapter, options.hooks),
17+
raw: (adapter) => ({
18+
prefetchForRoute: createItemPrefetchForRoute(adapter),
19+
}),
20+
routes: (_adapter, _context, operations) => {
21+
const createItem = createEndpoint(
22+
"/items",
23+
{ method: "POST", body: CreateItemSchema, requireRequest: true },
24+
operations.createItem.route((ctx) => ctx.body),
25+
)
26+
return { createItem } as const
27+
},
28+
})
7329

74-
---
30+
/** Inferred router contract imported by the client plugin. */
31+
export type MyApiRouter = ReturnType<
32+
ReturnType<typeof myBackendPlugin>["routes"]
33+
>
34+
```
7535
7636
## getters.ts
7737
78-
Pure DB functions — no HTTP context, no lifecycle hooks, always accept `adapter` as first arg:
38+
Lower-level DB functions — no HTTP context or lifecycle composition, always accept `adapter` as first arg:
7939
8040
```typescript
8141
import type { DBAdapter as Adapter } from "@btst/db"
8242
import type { Item } from "./types"
8343

84-
// Authorization hooks are NOT called — callers are responsible for access control
44+
// Lower-level primitive: callers own validation and lifecycle composition.
8545
export async function listItems(adapter: Adapter): Promise<Item[]> {
8646
return adapter.findMany({ model: "item" })
8747
}
@@ -95,15 +55,15 @@ export async function getItemById(adapter: Adapter, id: string): Promise<Item |
9555

9656
## mutations.ts
9757

98-
Write operations — no auth hooks, no HTTP context. JSDoc disclaimer required:
58+
Write operations — no HTTP context or lifecycle composition. JSDoc disclaimer required:
9959

10060
```typescript
10161
import type { DBAdapter as Adapter } from "@btst/db"
10262
import type { CreateItemInput, Item } from "./types"
10363

10464
/**
10565
* Create a new item directly in the database.
106-
* Authorization hooks are NOT called — caller is responsible for access control.
66+
* Lower-level primitive: caller owns validation and lifecycle composition.
10767
*/
10868
export async function createItem(adapter: Adapter, data: CreateItemInput): Promise<Item> {
10969
return adapter.create({
@@ -114,7 +74,7 @@ export async function createItem(adapter: Adapter, data: CreateItemInput): Promi
11474

11575
/**
11676
* Update an existing item.
117-
* Authorization hooks are NOT called — caller is responsible for access control.
77+
* Lower-level primitive: caller owns validation and lifecycle composition.
11878
*/
11979
export async function updateItem(
12080
adapter: Adapter,
@@ -126,7 +86,7 @@ export async function updateItem(
12686

12787
/**
12888
* Delete an item.
129-
* Authorization hooks are NOT called — caller is responsible for access control.
89+
* Lower-level primitive: caller owns validation and lifecycle composition.
13090
*/
13191
export async function deleteItem(adapter: Adapter, id: string): Promise<void> {
13292
await adapter.delete({ model: "item", where: { id } })
@@ -140,10 +100,10 @@ export async function deleteItem(adapter: Adapter, id: string): Promise<void> {
140100
Re-export getters and mutations for direct server-side import (SSG, scripts, AI tools):
141101

142102
```typescript
143-
// Getters — read-only, no auth hooks
103+
// Getters — read-only lower-level primitives
144104
export { listItems, getItemById } from "./getters"
145105

146-
// Mutations — write ops, no auth hooks
106+
// Mutations — write lower-level primitives
147107
export { createItem, updateItem, deleteItem } from "./mutations"
148108

149109
// Types for consumers
@@ -154,52 +114,26 @@ export { serializeItem } from "./serializers"
154114

155115
---
156116

157-
## Lifecycle hook implementation in routes
117+
## Lifecycle hooks in operations
158118

159-
```typescript
160-
routes: (adapter: Adapter) => {
161-
const createItemEndpoint = createEndpoint(
162-
"/items",
163-
{ method: "POST", body: CreateItemSchema },
164-
async (ctx) => {
165-
const context = { body: ctx.body, headers: ctx.headers }
166-
167-
// before hook — throw to deny
168-
await ctx.hooks?.onBeforeItemCreated?.(ctx.body, context)
169-
170-
const item = await createItem(adapter, ctx.body)
171-
172-
// after hook — fire and forget or await
173-
await ctx.hooks?.onAfterItemCreated?.(item, context)
174-
175-
return item
176-
},
177-
)
178-
179-
return { createItem: createItemEndpoint } as const
180-
},
181-
```
182-
183-
Hook naming always follows: `onBefore{Entity}{Action}`, `onAfter{Entity}{Action}`, `on{Entity}{Action}Error`.
184-
185-
---
119+
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.
186120

187-
## Plugin stack() wiring (in stack.ts)
121+
## Plugin `createBackendStack()` wiring (in stack.ts)
188122

189123
```typescript
190-
import { stack } from "@btst/stack"
124+
import { createBackendStack } from "@btst/stack/api"
191125
import { myBackendPlugin } from "./src/plugins/my-plugin/api/plugin"
192126

193-
export const myStack = stack({
127+
export const myStack = createBackendStack({
194128
basePath: "/api/data",
195129
plugins: {
196-
myPlugin: myBackendPlugin,
130+
myPlugin: myBackendPlugin(),
197131
},
198132
adapter: (db) => createDrizzleAdapter(schema, db, {}),
199133
})
200134

201135
export const { handler, dbSchema } = myStack
202136

203-
// Direct server-side access (bypasses auth hooks):
204-
const items = await myStack.api.myPlugin.listItems()
137+
// Explicitly trusted business access keeps validation and lifecycle hooks:
138+
const item = await myStack.trusted.myPlugin.createItem({ name: "Scheduled item" })
205139
```

‎.agents/skills/btst-backend-plugin-dev/SKILL.md‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: btst-backend-plugin-dev
3-
description: Patterns for writing BTST backend plugins inside the monorepo, including defineBackendPlugin structure, getters.ts/mutations.ts separation, the api factory, lifecycle hook naming conventions, and accessing the adapter in AI tool execute functions. Use when creating or modifying a backend plugin, adding DB getters or mutations, wiring the api factory, or implementing lifecycle hooks in src/plugins/{name}/api/.
3+
description: Patterns for writing BTST backend plugins inside the monorepo, including operation-first authorization, getters.ts/mutations.ts separation, lifecycle hooks, and narrow SSG raw factories. Use when creating or modifying a backend plugin, adding DB getters or mutations, operations, permission descriptors, or lifecycle hooks in src/plugins/{name}/api/.
44
---
55

66
# BTST Backend Plugin Development
@@ -12,19 +12,21 @@ src/plugins/{name}/
1212
api/
1313
plugin.ts ← defineBackendPlugin entry
1414
getters.ts ← read-only DB functions (no HTTP context)
15-
mutations.ts ← write DB functions (no auth hooks)
15+
mutations.ts ← lower-level write DB functions
16+
operations.ts ← validated operations, permission facts, lifecycle
1617
index.ts ← re-exports getters + mutations + types
1718
query-keys.ts ← React Query key factory
1819
```
1920

2021
## Rules
2122

2223
- **`getters.ts`** — pure async DB functions only. No HTTP context, no lifecycle hooks. Always takes `adapter` as first arg.
23-
- **`mutations.ts`** — write operations (create/update/delete). No auth hooks, no HTTP context. Add JSDoc: "Authorization hooks are NOT called."
24+
- **`mutations.ts`** — lower-level write primitives. No HTTP context or lifecycle composition. Document that callers own validation and lifecycle.
2425
- **`api/index.ts`** — re-export everything from getters + mutations for direct server-side import.
25-
- The `api` factory and `routes` factory share the same adapter instance — bind getters inside the factory, don't pass adapter at call site.
26-
- If the plugin has a one-time init step (e.g. `syncContentTypes`), call it inside each getter/mutation wrapper — not only inside `routes`.
27-
- **Never** use `myStack.api.*` as a substitute for authenticated HTTP endpoints — auth hooks are not called.
26+
- **`operations.ts`** — define the one maintained business inventory with input validation, exact permission descriptors, authoritative facts, domain execution, and lifecycle hooks.
27+
- Bind HTTP routes to same-key operations. Use `operationRouteMap` only for real route-name mismatches.
28+
- The optional `raw` factory is narrow: first-party plugins expose only `prefetchForRoute` for SSG. Do not duplicate business getters or mutations on `createBackendStack().raw`.
29+
- Use `myStack.forRequest(request).operations.*` for request work and `myStack.trusted.*` for explicitly trusted jobs.
2830

2931
## Key patterns
3032

@@ -38,30 +40,37 @@ src/plugins/{name}/
3840

3941
## Lifecycle hook naming
4042

41-
Pattern: `onBefore{Entity}{Action}`, `onAfter{Entity}{Action}`, `on{Entity}{Action}Error`
43+
Pattern: `onBefore{Action}{Entity}`, `onAfter{Action}{Entity}`, `onError{Action}{Entity}`
4244

4345
```typescript
44-
// Examples from existing plugins:
45-
onBeforeListPosts, onPostsRead, onListPostsError
46-
onBeforeCreatePost, onPostCreated, onCreatePostError
47-
onBeforeUpdatePost, onPostUpdated, onUpdatePostError
48-
onBeforeDeletePost, onPostDeleted, onDeletePostError
49-
onBeforePost, onAfterPost // comments plugin (create comment)
50-
onBeforeEdit, onAfterEdit // comments plugin (edit comment)
51-
onBeforeDelete, onAfterDelete // comments plugin (delete comment)
52-
onBeforeStatusChange, onAfterApprove
46+
onBeforeListPosts, onAfterListPosts, onErrorListPosts
47+
onBeforeCreatePost, onAfterCreatePost, onErrorCreatePost
48+
onBeforeUpdatePost, onAfterUpdatePost, onErrorUpdatePost
49+
onBeforeDeletePost, onAfterDeletePost, onErrorDeletePost
50+
51+
// Preserve meaningful domain events instead of inventing CRUD phases.
52+
onBeforeChat, onAfterChat, onErrorChat
53+
onBeforeActivateTools
5354
```
5455

55-
## Adapter in AI tool execute functions
56+
Normalize names without adding lifecycle phases that the plugin does not
57+
already support.
58+
59+
## Trusted operations in AI tool execute functions
5660

5761
`myStack` is a module-level const. The `execute` closure runs lazily (only on HTTP request), so `myStack` is always initialised by then:
5862

5963
```typescript
60-
export const myStack = stack({ ... })
64+
import { createBackendStack } from "@btst/stack/api"
65+
66+
export const myStack = createBackendStack({ ... })
6167

6268
const myTool = tool({
6369
execute: async (params) => {
64-
await createKanbanTask(myStack.adapter, { title: params.title, columnId: "col-id" })
70+
await myStack.trusted.kanban.createTask({
71+
title: params.title,
72+
columnId: "col-id",
73+
})
6574
return { success: true }
6675
}
6776
})
@@ -73,8 +82,8 @@ const myTool = tool({
7382
- **Wrong adapter type** — use `import type { DBAdapter as Adapter } from "@btst/db"` in getters/mutations/plugin files.
7483
- **`"GET /path"` string keys** — routes use `createEndpoint()`, not string-keyed method/path objects.
7584
- **`ctx.json()`** — does not exist; return data directly from route handlers.
76-
- **`stack().api` bypasses auth hooks** — never use for authenticated data access; enforce auth at the call site.
77-
- **Plugin init not called via `api`** — if `routes` factory runs a setup (e.g. `syncContentTypes`), also await it inside each `api` getter wrapper.
85+
- **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.
86+
- **Authorization in lifecycle hooks** — routine access control belongs in operation descriptors and the one shared rule. Hooks receive already-authorized context.
7887
- **Write ops in `getters.ts`** — write functions belong in `mutations.ts`, not `getters.ts`.
7988

8089
## Full code patterns

0 commit comments

Comments
 (0)