feat(11.1-04): add the Database section and the core Services pages

- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
This commit is contained in:
Jakub Zych
2026-09-30 22:59:25 +02:00
parent 9d37d56486
commit efb35a2d35
28 changed files with 3138 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
---
title: Transactions
description: Run writes in lagoon.Transaction, defer side effects with lagoon.AfterCommit until the commit, and install GORM callbacks from Boot with lagoon.OnDatabase.
section: database
order: 70
---
# Transactions
Laravel's `DB::transaction` runs a closure in a transaction, and `DB::afterCommit` defers work until it commits. [lagoon](../../modules/lagoon/README.md) has the same pair, `lagoon.Transaction` and `lagoon.AfterCommit`, and the rest of the framework relies on them: realtime broadcasts, search index updates and blob deletions wait for the commit, so no client hears about a row that was rolled back.
## Running a transaction
`lagoon.Transaction` runs a function in a transaction and commits when it returns `nil`. Inside the function, use the `ctx` and `tx` it receives for every write. Work registered with `lagoon.AfterCommit` runs, in registration order, only after the commit succeeds; when the function returns an error, the transaction rolls back and the work is dropped:
```go src=modules/lagoon/example_test.go#publish
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil {
return err
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
*log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail...
})
if fail {
return errors.New("rolled back") // the AfterCommit work never runs
}
return nil
})
```
The callback receives a database handle with an empty statement, so a query it runs never continues from the written model's statement. A panicking callback is logged and does not turn a committed write into an error.
`lagoon.AfterCommit` behaves differently depending on where it is called:
| Called | The work runs |
|--------|---------------|
| Inside `lagoon.Transaction` | After the outermost transaction commits; never after a rollback. |
| In a GORM callback of a single-statement write (GORM's own implicit transaction) | After GORM commits that write; never when the write fails. |
| Inside a plain `gorm.DB.Transaction` or another transaction lagoon did not open | Never. lagoon cannot see whether that transaction commits, so it logs a warning and skips the work. |
| Outside any transaction | Immediately. |
The third row is deliberate: running the work early could announce a write that later rolls back. When code in a transaction needs after-commit work, open the transaction with `lagoon.Transaction`.
## Nested transactions
A `lagoon.Transaction` inside another becomes a savepoint. Its after-commit work joins the outer transaction's only when its own function succeeds, so work dropped with a failed savepoint never runs:
```go src=modules/lagoon/example_test.go#nested
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") })
// A nested Transaction is a savepoint. Pass it the outer tx: given
// the root db handle it returns an error instead.
_ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") })
return errors.New("savepoint rolled back")
})
return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") })
return nil
})
})
```
Pass the nested call the outer transaction's `tx`. Given the root database handle instead, the nested `lagoon.Transaction` returns an error without running its function. Otherwise it would open a second, independent transaction whose after-commit work would wait for the outer one.
## Callbacks registered at boot
A hook that calls a service, such as a broadcast or a job dispatch, is a GORM callback rather than a model method (see [Models](models.md)). A plugin registers it from `Boot`, but `Boot` runs before the `serve` command opens the database. `lagoon.OnDatabase` bridges the gap: it runs your function as soon as the database is published, immediately when it already is.
Register the callback before GORM's `gorm:commit_or_rollback_transaction` step and defer its side effect with `lagoon.AfterCommit`:
```go src=modules/lagoon/example_test.go#on-database
return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) {
post, ok := db.Statement.Dest.(*Post)
if db.Error != nil || !ok {
return
}
lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) {
*log = append(*log, "created "+post.Slug) // runs only once the insert is committed
})
})
})
```
> [!WARNING]
> Register such a callback with a `Before("gorm:commit_or_rollback_transaction")` constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run, without an error.
## Boot-time work that needs the database
`lagoon.OnDatabase` is also the place for anything else a plugin must do with the database handle at start-up, such as registering a join table with `lagoon.RegisterJoinTable`. The error of a queued function is returned by `lagoon.Publish`, so a failing callback stops the start-up.