Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
---
title: Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
impact: HIGH
impactDescription: Individual rules are atomic; this composite example shows which app instance applies at each layer and how errors propagate
tags: extending, composition, transactions, hooks, enrich, routing, mental-model
---
## Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
The atomic rules (`ext-hooks-chain`, `ext-transactions`, `ext-routing-custom`, `ext-hooks-record-vs-request`, `ext-filesystem`, `ext-filter-binding-server`) each teach a single trap. Real extending code touches **all of them in the same handler**. This rule walks through one complete request flow and annotates **which app instance is active at each layer** - the single most common source of extending bugs is reaching for the wrong one.
### The flow
`POST /api/myapp/posts` that: authenticates the caller, validates uniqueness with a bound filter, creates a record inside a transaction, uploads a thumbnail through a scoped filesystem, writes an audit log from an `OnRecordAfterCreateSuccess` hook, and shapes the response (including the realtime broadcast) in `OnRecordEnrich`.
```
HTTP request
[group middleware] apis.RequireAuth("users") ◄── e.Auth is set after this
[route handler] se.App.RunInTransaction(func(txApp) {
│ // ⚠️ inside the block, use ONLY txApp, never se.App or outer `app`
│ FindFirstRecordByFilter(txApp, ...) // bound {:slug}
│ txApp.Save(post) // fires OnRecord*Create / *Request
│ │
│ ▼
│ [OnRecordAfterCreateSuccess hook] ◄── e.App IS txApp here
│ │ (hook fires inside the tx)
│ e.App.Save(auditRecord) → participates in rollback
│ e.Next() → REQUIRED
│ │
│ ▼
│ return to route handler
│ fs := txApp.NewFilesystem()
│ defer fs.Close()
│ post.Set("thumb", file); txApp.Save(post)
│ return nil // commit
│ })
[enrich pass] OnRecordEnrich fires ◄── RUNS AFTER the tx committed
│ (also fires for realtime SSE and list responses)
│ e.App is the outer app; tx is already closed
[response serialization] e.JSON(...)
```
### The code
```go
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
g := se.Router.Group("/api/myapp")
g.Bind(apis.RequireAuth("users"))
g.POST("/posts", func(e *core.RequestEvent) error {
// ── Layer 1: route handler ────────────────────────────────────────
// e.App is the top-level app. e.Auth is populated by RequireAuth.
// e.RequestInfo holds headers/body/query.
body := struct {
Slug string `json:"slug"`
Title string `json:"title"`
}{}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid body", err)
}
var created *core.Record
// ── Layer 2: transaction ──────────────────────────────────────────
txErr := e.App.RunInTransaction(func(txApp core.App) error {
// ⚠️ From here until the closure returns, every DB call MUST go
// through txApp. Capturing e.App or the outer `app` deadlocks
// on the writer lock.
// Bound filter - see ext-filter-binding-server
existing, _ := txApp.FindFirstRecordByFilter(
"posts",
"slug = {:slug}",
dbx.Params{"slug": body.Slug},
)
if existing != nil {
return apis.NewBadRequestError("slug already taken", nil)
}
col, err := txApp.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
post := core.NewRecord(col)
post.Set("slug", body.Slug)
post.Set("title", body.Title)
post.Set("author", e.Auth.Id)
// txApp.Save fires record hooks INSIDE the tx
if err := txApp.Save(post); err != nil {
return err
}
// ── Layer 3: filesystem (scoped to this request) ─────────────
fs, err := txApp.NewFilesystem()
if err != nil {
return err
}
defer fs.Close() // REQUIRED - see ext-filesystem
if uploaded, ok := e.RequestInfo.Body["thumb"].(*filesystem.File); ok {
post.Set("thumb", uploaded)
if err := txApp.Save(post); err != nil {
return err
}
}
created = post
return nil // commit
})
if txErr != nil {
return txErr // framework maps it to a proper HTTP error
}
// ── Layer 5: response (enrich runs automatically) ────────────────
// e.App is the OUTER app again here - the tx has committed.
// OnRecordEnrich will fire during JSON serialization and for any
// realtime subscribers receiving the "create" event.
return e.JSON(http.StatusOK, created)
})
return se.Next()
})
// ── Layer 4: hooks ──────────────────────────────────────────────────────
// These are registered once at startup, NOT inside the route handler.
app.OnRecordAfterCreateSuccess("posts").Bind(&hook.Handler[*core.RecordEvent]{
Id: "audit-post-create",
Func: func(e *core.RecordEvent) error {
// ⚠️ e.App here is txApp when the parent Save happened inside a tx.
// Always use e.App - never a captured outer `app` - so that the
// audit record participates in the same transaction (and the
// same rollback) as the parent Save.
col, err := e.App.FindCollectionByNameOrId("audit")
if err != nil {
return err
}
audit := core.NewRecord(col)
audit.Set("action", "post.create")
audit.Set("record", e.Record.Id)
audit.Set("actor", e.Record.GetString("author"))
if err := e.App.Save(audit); err != nil {
return err // rolls back the whole request
}
return e.Next() // REQUIRED - see ext-hooks-chain
},
})
app.OnRecordEnrich("posts").BindFunc(func(e *core.RecordEnrichEvent) error {
// Runs for:
// - GET /api/collections/posts/records (list)
// - GET /api/collections/posts/records/{id} (view)
// - realtime SSE create/update broadcasts
// - any apis.EnrichRecord call in a custom route
// Does NOT run inside a transaction; e.App is the outer app.
e.Record.Hide("internalNotes")
if e.RequestInfo != nil && e.RequestInfo.Auth != nil {
e.Record.WithCustomData(true)
e.Record.Set("isMine", e.Record.GetString("author") == e.RequestInfo.Auth.Id)
}
return e.Next()
})
```
### The cheat sheet: "which app am I holding?"
| Where you are | Use | Why |
|---|---|---|
| Top of a route handler (`func(e *core.RequestEvent)`) | `e.App` | Framework's top-level app; same object the server started with |
| Inside `RunInTransaction(func(txApp) { ... })` | `txApp` **only** | Capturing the outer app deadlocks on the SQLite writer lock |
| Inside a record hook fired from a `Save` inside a tx | `e.App` | The framework has already rebound `e.App` to `txApp` for you |
| Inside a record hook fired from a non-tx `Save` | `e.App` | Same identifier, same rules, just points to the top-level app |
| Inside `OnRecordEnrich` | `e.App` | Runs during response serialization, **after** the tx has committed |
| Inside a `app.Cron()` callback | captured `app` / `se.App` | Cron has no per-run scoped app; wrap in `RunInTransaction` if you need atomicity |
| Inside a migration function | the `app` argument | `m.Register(func(app core.App) error { ... })` - already transactional |
### Error propagation in the chain
- `return err` inside `RunInTransaction`**rolls back everything**, including any audit records written by hooks that fired from nested `Save` calls.
- `return err` from a hook handler → propagates back through the `Save` call → propagates out of the tx closure → rolls back.
- **Not** calling `e.Next()` in a hook → the chain is broken **silently**. The framework's own post-save work (realtime broadcast, enrich pass, activity log) is skipped but no error is reported.
- A panic inside the tx closure is recovered by PocketBase, the tx rolls back, and the panic is converted to a 500 response.
- A panic inside a cron callback is recovered and logged - it does **not** take down the process.
### When NOT to compose this much
This example is realistic but also the ceiling of what should live in a single handler. If you find yourself stacking six concerns in one route, consider splitting the logic into a service function that takes `txApp` as a parameter and is called by the route. The same function is then reusable from cron jobs, migrations, and tests.
Reference: cross-references `ext-hooks-chain.md`, `ext-transactions.md`, `ext-routing-custom.md`, `ext-hooks-record-vs-request.md`, `ext-filesystem.md`, `ext-filter-binding-server.md`.