事件的两种来源:触发器与 Record

更新于 · 在 sijie.xyz 查看原条目 ↗

状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。

事件只有两种来源:被监视的行变化时由数据库触发器写入,或者由用例为“没有哪一行单独表达的事实”调用 Record。两者都和变更在同一事务里写进同一张 events 表,消费方分不出来,也不需要分。

行变化触发器 经 Record 的域事件
谁写 Postgres,AFTER INSERT/UPDATE/DELETE 用例,recorder.With(tx).Record(ctx, ownerID, type, subject, data)
什么时候用 被监视的行变了 没有哪一行单独表达的事实
例子 corpus.note.changed,data.op ∈ {created, updated, deleted} access_request.created、booking.created、application.committed
覆盖 结构上完整:写入路径不可能忘了发 显式调用;可接受,因为每个事件恰好只有一个产出它的用例
证明 触发器 UT 断言每个被监视列都出事件、空更新不出、payload 形状固定 每个用例一条 UT,断言事件在写入的同一事务里记下

行变化触发器

  • 表:只有 corpus_notes 发事件。其它表都声明 -- events: none (原因);关于它们的语义事实(writing.published、code.issued 等)走 Record。
  • 两个触发器:AFTER INSERT OR DELETE,以及 AFTER UPDATE … WHEN 被监视的列变了。被监视的列:genre、title、body、tags、parent_id、published、show_as_source、aliases、excerpt、slug、archived、css_classes、lang。只刷了 updated_at 的更新不出事件。
  • 主体:笔记 URI,在 SQL 里算(corpus_note_uri、corpus_path_segment)。SQL 这份路径规则由 UT TestSQLPathSegmentMatchesGo 对齐 Go 的规则。
  • data:op(created、updated、deleted)、note_id、genre、parent_id、published、was_published(取消发布也看得见)和 path_changed(标题或父节点变了,索引要重建子树)。
  • 触发器同时 pg_notify('standmeet_events') 唤醒 relay(relay-claims-rows-not-cursor)。
  • 捕获的字段定义在一个迁移和 schema.sql 里;schema 一致性 UT(TestMigrationsAddNothingToASchemaSQLDatabase)保证两者一致。
  • 这让 CreateWiki 这一类 bug 不可能发生,而不是修一次:原来 CreateWiki、CreateOutput 从没调索引钩子。

已决定(2026-09-26):行变化捕获用带 WHEN 的数据库触发器;语义事件仍显式 Record。

每张表都要做决定

门禁 check-table-event-policy.sh:schema.sql 里任何 CREATE TABLE 既没声明 -- events: emit 也没声明 -- events: none (原因) 就报错;声明 emit 但没有触发器也报错。每张新表强制做决定,是必填声明不是排除清单。实现后 58 张表全部标注,只有 corpus_notes 发事件。见 no-bypass-by-structure。

数据模型(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
  }

收拢前后:搜索索引

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

相关:event-model(两种来源写的是同一形状)、storage-bounds(WHEN 作为一条边界)。