# Transactions and post-commit hooks

`database.Atomic` runs related writes in one PostgreSQL transaction, like Django's
`transaction.atomic`. `database.OnCommit` schedules work that must happen only after
those writes commit, like Django's `transaction.on_commit`.

Use `OnCommit` for sending an email, enqueueing a job on another system or
invalidating a cache. A rolled-back transaction never sends the email.

## Ask your agent

```text
In my Cicadel project, send a welcome email after a new member is saved. Use
the model form's `AfterSave` hook to register a `database.OnCommit` callback,
so the email goes out only after the transaction commits. Add a PostgreSQL
test showing that a rejected save sends nothing.
```

## Run writes atomically

```go
err := database.Atomic(r.Context(), db, func(tx *gorm.DB) error {
	if err := tx.Create(&order).Error; err != nil {
		return err
	}
	return tx.Model(&stock).Where("id = ?", order.ItemID).
		Update("count", gorm.Expr("count - 1")).Error
})
```

`database.Atomic(ctx, db, fn)` begins a transaction and passes it to `fn`.

- When `fn` returns nil, it commits.
- When `fn` returns an error or panics, everything rolls back. The error or panic
  propagates unchanged.
- Use the supplied `tx` for every query.

It wraps GORM's `db.Transaction`. It accepts the same optional `*sql.TxOptions`,
for example `&sql.TxOptions{Isolation: sql.LevelSerializable}`.

### Nested calls are savepoints

Pass a `tx` from an enclosing `Atomic`, and `Atomic` creates a savepoint instead of
a new transaction. If the inner function fails, only its writes roll back. The
outer function decides what to do with the error:

```go
err := database.Atomic(ctx, db, func(tx *gorm.DB) error {
	tx.Create(&invoice)
	if err := database.Atomic(ctx, tx, func(tx *gorm.DB) error {
		return tx.Create(&optionalDiscount).Error
	}); err != nil {
		log.Printf("discount skipped: %v", err) // invoice still commits
	}
	return nil
})
```

## Run code after commit

Register a hook inside the transaction:

```go
err := database.Atomic(ctx, db, func(tx *gorm.DB) error {
	if err := tx.Create(&member).Error; err != nil {
		return err
	}
	return database.OnCommit(tx, func(ctx context.Context) error {
		return sender.Send(ctx, welcomeMessage(member.Email))
	})
})
```

### Hooks in model forms

These saves already run through `database.Atomic`:

- model form saves (`Bound.Create` and `Update`),
- administration saves,
- formset saves (from v0.2.4),
- account service operations in `contrib/accounts`.

So a model-form hook can register directly:

```go
form, err := model.New[Member](db, model.Config[Member]{
	Fields: []model.Field{{Name: "Email", Required: true}},
	AfterSave: func(ctx context.Context, tx *gorm.DB, m *Member) error {
		to := m.Email
		return database.OnCommit(tx, func(ctx context.Context) error {
			return sender.Send(ctx, welcomeMessage(to))
		})
	},
})
```

Copy the values a hook needs, as above. Do not capture models that later code may
change.

In a formset `Save`, hooks run after the whole formset commits. They are dropped
if any row fails.

## Semantics

| Situation | Behavior |
| --- | --- |
| Outermost transaction commits | Hooks run in registration order, after commit and before `Atomic` returns. |
| `fn` returns an error, panics, or the commit fails | No hook runs; every hook registered in the transaction is discarded. |
| A savepoint (nested `Atomic`) fails | Hooks registered in it, and in savepoints inside it, are discarded. Hooks registered before or after it in the enclosing transaction survive. |
| A savepoint succeeds | Its hooks join the enclosing transaction and run only when the outermost transaction commits. |
| `OnCommit` outside an `Atomic` transaction | Returns `database.ErrNoTransaction`; the hook is never run. This includes transactions from `db.Begin`, a raw `db.Transaction` and `apptest/postgres.Transaction`, whose commit Cicadel cannot observe. |
| A hook returns an error or panics | The commit stands. The failure (with the panic's stack) is logged with `slog.Default()` at error level, later hooks still run and `Atomic` returns nil. |
| `ctx` is cancelled before commit | PostgreSQL rolls the transaction back; `Atomic` returns the error and no hook runs. |
| `ctx` is cancelled after commit | Hooks still run. They receive `context.WithoutCancel(ctx)`: request values (such as the request ID) remain, cancellation and deadline do not. Set your own timeout in a hook that needs one. |
| Concurrent transactions | Each transaction has its own hooks; they never mix. |

Hooks run after the connection returns to the pool, so they must not use `tx`. A
hook that needs to write starts a new `database.Atomic`. Registering on a finished
transaction returns `ErrNoTransaction`.

> [!WARNING]
> A crash between commit and hook leaves the hook's work undone. When the effect
> must not be lost, use a transactional outbox instead: insert a job in the same
> transaction with `jobs.InsertTx`.

### Transactions started elsewhere

`Atomic` tracks the transactions it begins. Inside a transaction that GORM or your
code began directly:

- `Atomic` still creates savepoints,
- `OnCommit` returns `ErrNoTransaction`.

So start the outermost transaction with `database.Atomic` wherever hooks may be
registered. This includes before `model.Form.InTransaction(tx)`.

If GORM's `DisableNestedTransaction` is set, nested calls create no savepoint.
Their writes and hooks stand or fall with the enclosing transaction.

## Test hooks

Hooks run on commit. Test them with a committed transaction in a disposable
database from `apptest/postgres.New`. Do not use `postgres.Transaction`, which
always rolls back.

```go
func TestWelcomeEmail(t *testing.T) {
	db := postgres.New(t).DB
	outbox := &mail.Capture{}
	// ... apply migrations, build the form with mail.Sender{Transport: outbox}
	if _, err := form.Bind(url.Values{"email": {"ada@example.com"}}).Create(ctx, Member{}); err != nil {
		t.Fatal(err)
	}
	if len(outbox.Messages()) != 1 {
		t.Fatal("welcome email not sent after commit")
	}
}
```

See [model forms](https://cicadel.fauzaanu.com/en/0.2.7/topics/model-forms/index.md), [email](https://cicadel.fauzaanu.com/en/0.2.7/topics/email/index.md) and [testing](https://cicadel.fauzaanu.com/en/0.2.7/topics/testing/index.md).
