Files
shiftcraft/.claude/skills/pocketbase-best-practices/rules/ext-hooks-chain.md
2026-04-17 23:26:01 +00:00

3.4 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Always Call e.Next() and Use e.App Inside Hook Handlers CRITICAL Forgetting e.Next() silently breaks the execution chain; reusing parent-scope app causes deadlocks hooks, events, extending, transactions, deadlock

Always Call e.Next() and Use e.App Inside Hook Handlers

Every PocketBase event hook handler is part of an execution chain. If the handler does not call e.Next() (Go) or e.next() (JS), the remaining handlers and the core framework action are skipped silently. Also, hooks may run inside a DB transaction - any database call made through a captured parent-scope app/$app instead of the event's own e.App/e.app will deadlock against the transaction.

Incorrect (missing Next, captured parent-scope app, global mutex):

var mu sync.Mutex // ❌ global lock invoked recursively by cascade hooks = deadlock
app := pocketbase.New()

app.OnRecordAfterCreateSuccess("articles").BindFunc(func(e *core.RecordEvent) error {
    mu.Lock()
    defer mu.Unlock()

    // ❌ uses outer `app`, not `e.App` - deadlocks when the hook fires
    //    inside a transaction, because the outer app is blocked on the
    //    transaction's write lock
    _, err := app.FindRecordById("audit", e.Record.Id)
    if err != nil {
        return err
    }
    return nil // ❌ forgot e.Next() - framework never persists the record
})
// JSVM
onRecordAfterCreateSuccess((e) => {
    // ❌ no e.next() = downstream hooks and response serialization skipped
    console.log("created", e.record.id);
}, "articles");

Correct (call Next, use e.App, attach an Id for later unbinding):

app := pocketbase.New()

app.OnRecordAfterCreateSuccess("articles").Bind(&hook.Handler[*core.RecordEvent]{
    Id:       "audit-article-create",
    Priority: 10, // higher = later; default 0 = order of registration
    Func: func(e *core.RecordEvent) error {
        // Always use e.App - it is the transactional app when inside a tx
        audit := core.NewRecord(/* ... */)
        audit.Set("record", e.Record.Id)
        if err := e.App.Save(audit); err != nil {
            return err
        }
        return e.Next() // REQUIRED
    },
})

// Later: app.OnRecordAfterCreateSuccess("articles").Unbind("audit-article-create")
// JSVM - e.app is the transactional app instance
onRecordAfterCreateSuccess((e) => {
    const audit = new Record($app.findCollectionByNameOrId("audit"));
    audit.set("record", e.record.id);
    e.app.save(audit);

    e.next(); // REQUIRED
}, "articles");

Rules of the execution chain:

  • Bind(handler) vs BindFunc(func): Bind lets you set Id (for Unbind) and Priority; BindFunc auto-generates both.
  • Priority defaults to 0 = order of source registration. Lower numbers run first, negative priorities run before defaults (the built-in middlewares use priorities like -1010, -1000, -990).
  • Never hold a global mutex across e.Next() - cascade-delete and nested saves can re-enter the same hook and deadlock.
  • Unbind(id) removes a specific handler; UnbindAll() also removes system handlers, so only call it if you really mean to replace the default behavior.
  • Trigger(event, ...) is almost never needed in user code.

Reference: Go Event hooks · JS Event hooks