# Transactions and post-commit hooks

Run related writes in one PostgreSQL transaction with `database.Atomic`, and
schedule work that must happen only after those writes are committed — sending
an email, enqueueing a job on another system, invalidating a cache — with
`database.OnCommit`. A rolled-back transaction never sends the email.

## Ask your agent

> 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

`database.Atomic(ctx, db, fn)` begins a transaction, passes it to `fn` and
commits when `fn` returns nil. An error or panic rolls everything back; the
error (or panic) propagates unchanged. Use the supplied `tx` for every query.

```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
})
```

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

### Nested calls are savepoints

Called with a `tx` from an enclosing `Atomic`, `Atomic` creates a savepoint
instead of a new transaction. If the inner function fails, only its writes roll
back and 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))
	})
})
```

Model form saves (`Bound.Create`/`Update`) and administration saves run
through `database.Atomic`, so model-form hooks 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, instead of capturing models that later
code may change.

## 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 is returned 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`.

Because hooks run after commit, a crash between commit and hook leaves the work
undone. When the effect must not be lost, insert a job in the same transaction
with `jobs.InsertTx` (a transactional outbox) instead of a hook.

### Transactions started elsewhere

Atomic tracks the transactions it begins. Within a transaction that GORM or
your code began directly, `Atomic` still creates savepoints, but `OnCommit`
returns `ErrNoTransaction`. Start the outermost transaction with
`database.Atomic` wherever hooks may be registered — including before
`model.Form.InTransaction(tx)`. If GORM's `DisableNestedTransaction` is set,
nested calls create no savepoint, so their writes and hooks stand or fall with
the enclosing transaction.

The formsets module still begins its own transaction with GORM, so a model-form
hook that calls `OnCommit` during a formset `Save` gets `ErrNoTransaction` and
the save fails without writing. Formsets move to `database.Atomic` once that
module requires this core release.

## Test hooks

Hooks run on commit, so test them with a committed transaction in a disposable
database from `apptest/postgres.New`, not inside `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.3/topics/model-forms/index.md), [email](https://cicadel.fauzaanu.com/en/0.2.3/topics/email/index.md) and [testing](https://cicadel.fauzaanu.com/en/0.2.3/topics/testing/index.md).
