199 lines
9.7 KiB
Markdown
199 lines
9.7 KiB
Markdown
---
|
|
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`.
|