# festival

> Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch.

`import "git.golem15.com/golem15/summercms/modules/festival"`

## Overview

`festival` is the SummerCMS counterpart of WinterCMS's `Event::listen` and `Event::fire`, the mechanism plugins use to extend each other without direct calls. Events are routed by their Go type rather than by a string name, so a listener registered for `*PostPublished` receives exactly that type, and a payload mismatch is a compile error. Each application owns one bus: [backpack](/docs/api/backpack.md) creates it in `backpack.New` and exposes it as `backpack.App.Events`, and plugins register listeners from their Boot step.

## Features

- Listener registration with `festival.Bus.Listen` (priority 0) and `festival.Bus.ListenPriority`. Higher priorities run first; listeners with equal priority run in registration order. Every listener carries the ID of the plugin that owns it.
- Three dispatch modes, all synchronous on the caller's goroutine:
  - `festival.Bus.Fire` runs every listener and returns the joined errors of all that failed (`errors.Join`).
  - `festival.Bus.Collect` runs every listener and, after each one, merges the event's `festival.Collectable.Collected` map into a single payload (later keys win). It returns the payload gathered so far together with the joined errors.
  - `festival.Bus.UntilHandled` stops at the first error or as soon as the event's `festival.Handleable.IsHandled` reports true, and returns whether the event was handled (WinterCMS's halting fire).
- Panic isolation: a panicking listener is recovered and reported as an error that names its owner plugin, so one faulty plugin cannot take down the dispatch.
- Safe for concurrent use: registration is locked and dispatch works on a snapshot of the listener list.
- Value and pointer types are distinct event types; events that listeners modify (for `Collect` and `UntilHandled`) are usually pointers.

## Usage

```go
package blog

import (
	"context"

	"git.golem15.com/golem15/summercms/modules/festival"
)

// PostPublished is fired after a post goes live. Listeners add payload
// entries and may mark the event handled.
type PostPublished struct {
	PostID  uint
	payload map[string]any
	handled bool
}

func (e *PostPublished) Collected() map[string]any { return e.payload }
func (e *PostPublished) IsHandled() bool           { return e.handled }

func publish(ctx context.Context, bus *festival.Bus) (map[string]any, error) {
	bus.ListenPriority("acme.search", 10, func(ctx context.Context, e *PostPublished) error {
		if e.payload == nil {
			e.payload = map[string]any{}
		}
		e.payload["indexed"] = true
		return nil
	})
	return bus.Collect(ctx, &PostPublished{PostID: 42})
}
```

The event type is inferred from the listener's parameter, so this listener only receives `*PostPublished` events. In a plugin, the bus is `app.Events` on the `backpack.App` passed to Boot; `festival.New` is for tests and standalone use.

## API reference

| Identifier | Description |
|------------|-------------|
| `festival.Bus` | Application-owned, type-keyed event dispatcher. |
| `festival.New` | Returns an empty bus. |
| `festival.Bus.Listen` | Registers a listener for event type T at priority 0. |
| `festival.Bus.ListenPriority` | Registers a listener for event type T at an explicit priority. |
| `festival.Bus.Fire` | Runs every listener and joins their errors. |
| `festival.Bus.Collect` | Runs every listener and merges the event's collected payload. |
| `festival.Bus.UntilHandled` | Runs listeners until one handles the event or fails. |
| `festival.Collectable` | Implemented by events that expose a mergeable payload for `Collect`. |
| `festival.Handleable` | Implemented by events that can stop `UntilHandled`. |

## Dependencies

- SummerCMS modules: none.
- Third-party: none.
- Standard library: `context`, `errors`, `fmt`, `reflect`, `sort`, `sync`.

## Testing

```sh
go test ./modules/festival/...
```

The tests use in-process listeners and need no external services.
