# Events

Events let one app announce what happened and other installed apps react.
Payloads are typed, registration is explicit, and the application checks every
subscription before it starts. If you know Django, they fill the role of signals.

## How events differ from signals

- **Explicit.** An app lists the events it emits in `App.Events` and its handlers
  in `App.Subscriptions`.
- **Checked at startup.** `cicadel.New` validates both before the application starts.
- **No magic.** Nothing connects at import time, and there is no global registry.

The standard project subscribes in `project/events.go`. The accounts app emits
`user_created`, `logged_in`, `login_failed`, `logged_out` and `password_changed`.
A package made with `startpackage` declares and emits `greeted` as a template.

## Declare and emit an event

1. Declare each event once, as a package-level variable of the app that owns it.
   Use that app's label and a lowercase name.
2. Choose the payload type. Handlers receive it.
3. List the declaration in the app's `Events`.
4. Call `Emit` with the request, command or hook context.

<!-- example: installable/apps/orders/app.go -->
```go
package orders

import (
    "context"
    "errors"
    "net/http"

    "github.com/fauzaanu/cicadel"
    "github.com/fauzaanu/cicadel/database"
    "github.com/fauzaanu/cicadel/events"
    "github.com/fauzaanu/cicadel/response"
    "github.com/fauzaanu/cicadel/urls"
    "gorm.io/gorm"
)

// Placed is emitted inside the transaction that stores a new order.
var Placed = events.New[Placement]("orders", "placed")

// Placement is the Placed payload. Handlers write through Tx so their work
// commits or rolls back with the order.
type Placement struct {
    OrderID int64
    Tx      *gorm.DB
}

// App stores orders in an orders table created by its recorded migration.
// confirm runs after commit, so a rolled-back order is never confirmed.
func App(db *gorm.DB, confirm func(context.Context, int64) error) cicadel.App {
    return cicadel.App{
        Name:   "orders",
        Events: []events.Declaration{Placed},
        Subscriptions: []events.Subscription{
            Placed.Handle(func(ctx context.Context, e Placement) error {
                id := e.OrderID
                return database.OnCommit(e.Tx, func(ctx context.Context) error { return confirm(ctx, id) })
            }),
        },
        Routes: []urls.Route{urls.Path("POST", "/orders", "orders:place", func(w http.ResponseWriter, r *http.Request) {
            err := database.Atomic(r.Context(), db, func(tx *gorm.DB) error {
                var id int64
                if err := tx.Raw("INSERT INTO orders DEFAULT VALUES RETURNING id").Scan(&id).Error; err != nil {
                    return err
                }
                return Placed.Emit(r.Context(), Placement{OrderID: id, Tx: tx})
            })
            var refused *events.HandlerError
            switch {
            case errors.As(err, &refused):
                response.Error(w, http.StatusConflict) // a handler rejected the order; nothing was stored
            case err != nil:
                response.Error(w, http.StatusInternalServerError)
            default:
                w.WriteHeader(http.StatusCreated)
            }
        })},
    }
}
```

`events.New` panics on an invalid label, like `regexp.MustCompile`, because it
runs when the package is initialized.

## Subscribe

`Event.Handle(fn)` returns a subscription. List it in any installed app's
`Subscriptions`, including the declaring app's own. The project subscribes in
`project/events.go`:

```go
func Subscriptions() []events.Subscription {
    return []events.Subscription{
        orders.Placed.Handle(func(ctx context.Context, e orders.Placement) error {
            return e.Tx.Exec("INSERT INTO order_audit(order_id) VALUES (?)", e.OrderID).Error
        }),
    }
}
```

`go run manage.go apps` lists each app's emitted (`emits`) and handled
(`handles`) events.

## What the application checks

`cicadel.New` fails, naming the app, when:

- an app declares an event whose owner label is a different app;
- the same declaration is listed twice, or two declarations share a name;
- a subscription refers to an event that no installed app declares, for example
  after its owning app was removed (the error asks whether that app is installed);
- a subscription has a nil handler or was not created with `Handle`.

Subscriptions refer to the declared Go value, not its name. A handler always
receives the payload type the emitter sends.

## Delivery

| Rule | Behavior |
| :--- | :--- |
| Synchronous and ordered | `Emit` runs handlers on the calling goroutine. Apps run in dependency order (the order `manage.go apps` prints), then handlers in the order each app lists them. |
| No queue | There is no queue and no retry. Enqueue a job from a handler for slow or retried work. |
| Errors stop delivery | The first handler error stops the remaining handlers. `Emit` returns a `*events.HandlerError` naming the event and the subscribing app. `errors.Is` and `errors.As` reach the handler's own error. The emitter decides what the error means. |
| Panics | Handler panics propagate, as they would from an HTTP handler. |
| Cancellation | `Emit` checks the context before each handler and returns `ctx.Err()` once it is done. Handlers should honour the context too. |
| Nesting | A handler may emit other events. Nesting deeper than `events.MaxDepth` (16) levels fails with `events.ErrDepth` instead of recursing forever. |
| Concurrency | `cicadel.New` builds the dispatcher once and it never changes, so concurrent requests can emit freely. Handlers that share state must synchronize it. |

## Transactions

Emit inside the transaction that made the change, with `database.Atomic`. Pass the
transaction in the payload as `Tx` when handlers may need to write.

- A handler that writes through `Tx` commits or rolls back with the change.
- A handler error rolls the change back.
- Use `database.OnCommit(e.Tx, fn)` for email, cache invalidation and other
  effects that must happen only after commit. See [transactions](https://cicadel.fauzaanu.com/en/dev/howto/transactions/index.md).
- Handlers must not keep `Tx` after they return.

Emit events that do not describe a database change, such as a rejected request,
outside any transaction.

## Where events are delivered

`Emit` finds the application's dispatcher in the context. Cicadel attaches it for:

- every request served by the application;
- app `Start` and cleanup hooks;
- app commands, and every command run by `manage.go`, including project commands.

For other work the application owns, such as a River worker or a goroutine
started in `Start`, attach it with `Application.Context`. This also attaches the
signing keys and static-file manifest:

```go
ctx = app.Context(ctx)
```

> [!WARNING]
> With no dispatcher in the context, `Emit` has no subscribers and returns nil.
> This keeps services usable on their own, but events emitted from a bare
> `context.Background()` are not delivered.

## Test a package's events

In package tests, build a dispatcher directly:

```go
d, err := events.NewDispatcher(
    events.App{Name: "greet", Events: []events.Declaration{greet.Greeted}},
    events.App{Name: "listener", Subscriptions: []events.Subscription{greet.Greeted.Handle(record)}},
)
ctx := events.WithDispatcher(t.Context(), d)
```

Or install a listener app beside the package with `apptest.New`.

## Accounts events

| Event | Payload | Emitted |
| :--- | :--- | :--- |
| `accounts.UserCreated` | `UserCreation{User, Tx}` | by `Service.Create` (and `createuser`) after inserting the user |
| `accounts.LoggedIn` | `Login{User, Tx}` | by `Service.Login`, browser and JSON sign-in, after inserting the session |
| `accounts.LoginFailed` | `LoginFailure{Username}` | when sign-in is rejected with `ErrCredentials`; never for database or backend errors, and never with the password |
| `accounts.LoggedOut` | `Logout{UserID, Tx}` | by `Service.Logout`, browser and JSON sign-out, and when a browser signs in again over an older session |
| `accounts.PasswordChanged` | `PasswordChange{UserID, Reset, Tx}` | by `ChangePassword` and `ResetPassword` after replacing the hash and ending sessions |

Payloads with `Tx` are emitted inside the account transaction. A refused
`UserCreated` leaves no user, and a refused `LoggedIn` leaves no session. For
example, create a profile with every user from `project/events.go`:

```go
accounts.UserCreated.Handle(func(ctx context.Context, e accounts.UserCreation) error {
    return e.Tx.Exec("INSERT INTO profiles(user_id) VALUES (?)", e.User.ID).Error
})
```

`LoginFailure.Username` is the submitted, untrusted value. Escape it when you
display it and bound it when you store it. A `LoginFailed` handler error is
joined to `ErrCredentials`, so callers still see a rejected sign-in.

## Not covered

- **Model save and delete events** are not emitted automatically. Use GORM hooks
  or emit from the code that saves.
- **Request start and finish** are middleware concerns.
- **Cross-process delivery.** Events are in-process. They do not cross processes
  or survive a restart. Use the River integration in `jobs` for durable work.
