Two ways an event is born: triggers and Record

Updated · View the entry on sijie.xyz ↗

Status: released in v0.1.76 (2026-09-27) — design and as-built record in docs/design/event-bus-outbox-webhooks.md in the StandMeet repo.

An event is born one of two ways: a database trigger writes it when a watched row changes, or a use case calls Record for a fact no single row states. Both write the same events table in the same transaction as the change, so a consumer cannot tell them apart and does not need to.

Row-change trigger Domain event via Record
Who writes it Postgres, AFTER INSERT/UPDATE/DELETE the use case, recorder.With(tx).Record(ctx, ownerID, type, subject, data)
When to use it a watched row changed a fact no single row states
Example corpus.note.changed with data.op ∈ {created, updated, deleted} access_request.created, booking.created, application.committed
Coverage total by construction: a write path cannot forget to emit explicit, acceptable because each event has exactly one producing use case
Proof trigger UTs assert every watched column emits, no-op updates do not, and the payload shape a per-use-case UT asserts the event is recorded in the write's transaction

Row-change triggers

  • Table: corpus_notes is the only table that emits. The other tables declare -- events: none (reason); semantic facts about them (writing.published, code.issued, …) are Record calls.
  • Two triggers: AFTER INSERT OR DELETE, and AFTER UPDATE … WHEN a watched column changed. Watched columns: genre, title, body, tags, parent_id, published, show_as_source, aliases, excerpt, slug, archived, css_classes, lang. An update that only touches updated_at emits nothing.
  • Subject: the note URI, computed in SQL (corpus_note_uri, corpus_path_segment). The SQL copy of the path rule is held to the Go rule by the UT TestSQLPathSegmentMatchesGo.
  • Data: op (created, updated, deleted), note_id, genre, parent_id, published, was_published (so an unpublish is visible) and path_changed (title or parent changed, so the index rebuilds the subtree).
  • The trigger also calls pg_notify('standmeet_events') to wake the relay (relay-claims-rows-not-cursor).
  • The captured fields live in one migration and in schema.sql; a schema parity UT (TestMigrationsAddNothingToASchemaSQLDatabase) keeps the two in step.
  • This makes the CreateWiki bug class impossible, not fixed once: CreateWiki and CreateOutput never called the index hook.

Decided (2026-09-26): row-change capture uses database triggers with a WHEN clause; semantic events stay explicit Record calls.

Every table must decide

The gate check-table-event-policy.sh fails when a CREATE TABLE in schema.sql has neither -- events: emit nor -- events: none (reason), or when a table declared emit has no trigger. Every new table forces a decision. It is a required declaration, not an exclusion list. As built, all 58 tables are annotated and only corpus_notes emits. See no-bypass-by-structure.

Tables (ER)

erDiagram
  owners ||--o{ events : "owns"
  owners ||--o{ webhook_endpoints : "configures"
  embeds |o--o| webhook_endpoints : "update hook"
  access_codes ||--o| embeds : "exposed by"
  events ||--o{ river_job : "fans out to"
  webhook_endpoints ||--o{ river_job : "delivery jobs"
  corpus_notes ||..o{ events : "trigger writes"
  events {
    uuid id PK
    bigint seq "orders a claim, never a cursor"
    timestamptz fanned_out_at "null = not yet fanned out"
    jsonb fanout "subscriber and job per target"
    timestamptz poisoned_at "set after 5 relay failures"
    text type
    uuid owner_id FK
    text subject
    timestamptz occurred_at
    jsonb data
  }
  webhook_endpoints {
    uuid id PK
    uuid owner_id FK
    text url
    text_array event_types
    bytea secret_enc
    uuid embed_id FK "nullable"
    bool enabled
    text disabled_reason
    timestamptz failing_since "cooldown and 5-day disable"
    timestamptz busy_until "per-endpoint lease"
  }
  river_job {
    bigint id PK
    text kind "subscription name or job kind, e.g. corpus.index, webhook.deliver"
    jsonb args "event_id, endpoint_id"
    text state
    int attempt
    jsonb errors
  }

Before and after: the search index

sequenceDiagram
  participant C as corpus.CreateWiki
  participant DB as Postgres
  participant IX as Meili
  rect rgba(200,80,60,0.08)
  note over C,IX: Before: every write path had to remember the hook
  C->>DB: INSERT corpus_notes
  note over C: forgot to call indexNoteHook
  note over IX: not searchable, maybe fixed by the 8 s reconcile
  end
  rect rgba(80,140,90,0.08)
  note over C,IX: After: the trigger guarantees coverage
  C->>DB: INSERT corpus_notes
  DB->>DB: trigger → events
  DB-->>IX: relay → corpus.index → upsert
  end

Related: event-model (the shape both sources write), storage-bounds (the WHEN clause as a bound).