事件模型:字符串类型 + JSON,只声明一次

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

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

事件 = 类型字符串 + JSON。底座没有“每种事件一个 Go 类型”,每种事件类型都由拥有它的域以数据形式只声明一次。

event = { id (uuid), seq, type (string), owner_id, subject (string URI or id),
          occurred_at, data (json) }
  • id 是 gen_random_uuid()。seq 是自增列,只用来给领取排序,没有游标读它(relay-claims-rows-not-cursor)。
  • outbox 行还带着 relay 的记账字段:fanned_out_at、fanout(每个订阅方拿到了哪个任务)、relay_failures、poisoned_at、last_error。

类型命名

  • type 是点分字符串:名词(本身可以带点)+ 动词。例:corpus.note.changed、access_request.created、microsite.build.settled。
  • 底座只认字符串和 JSON。这和 seam 是同一条规则:seam 是名字 + verb + JSON,从不是 typed 契约。
  • 事件从哪来(触发器还是 Record)不体现在形状上:见 two-sources-of-events。

声明是数据

字段 含义
Type 点分类型字符串
Description 给人看的说明
Subject 主体的模式,如 <genre>://<note path> 或 webhook://<endpoint id>
Exposure 谁能收到:Internal 或 Webhook
  • 各域声明自己的类型,经 facade 暴露;组合根在 cmd/server/wire/periodic.go 里每个来源一行收集。
  • 后台 UI 和 MCP 的 webhooks.event_types 读的就是收集出的列表,没有手工维护的清单。
  • Record 一个没声明的类型 → 返回 ErrUndeclaredType,不是静默写进去。订阅的通配匹配不到任何已声明类型 → 启动失败。
  • 注册表 UT(cmd/server/wire/event_types_test.go)逐条检查声明:只声明一次、点分的名词加动词命名、有描述、有主体模式、exposure 是有意选的。

Exposure 默认 Internal

取值 谁能收到
Internal (零值) 只有进程内订阅方
Webhook 还可以经 webhooks 出实例

新事件类型不会意外出实例。这回应了生成式界面的误暴露风险:忘了归类,绝不默认公开。类型停在零值却没被有意列为 internal,注册表 UT 就失败。

已声明的 39 个类型

全部 thin,全部 Webhook 可见,默认都不订阅。

来源 类型
corpus(触发器) corpus.note.changed
corpus(writings) writing.published、writing.unpublished
owner:webhook webhook.test
access access_request.created、.approved、.status_changed;code.issued、.revoked、.redeemed;api_key.issued、.revoked
conversation conversation.started、.message、.pruned;ghost.accepted
owner:预约 booking.created、.cancelled、.rescheduled
owner:微站 microsite.build.settled、page.promoted_live、.rolled_back、.unpublished、microsite.store.doc_inserted
owner:账户与 vault owner.login、.email_changed、.recovery_requested;gas.exhausted、.refilled;vault.imported
stats instance.upgrade_requested
security ip_ban.added
block 与 supplier(block 模型) block.installed、.failed;supplier.connected、.disconnected、.activated
求职闭环 application.committed、jobs.fetched
  • instance.upgrade_requested 在自己的事务里提交,而且只在 updater 存在时才记。
  • owner.login 在自己的事务里提交(登录不写行,会话在 Redis 里)。用恢复短语登录也会记它,和被用掉的短语一起提交。
  • jobs.fetched 每个抓取成功的源记一条。

payload:thin

payload 只带类型、主体和 id(Stripe thin events 的形状)。消费方回它本来就用的 API 取资源。出实例的数据最少,事实只在生产方一处。

核心类图

classDiagram
  direction LR
  class Event {
    +uuid id
    +bigint seq
    +string type
    +uuid owner_id
    +string subject
    +time occurred_at
    +json data
    +time fanned_out_at
    +time poisoned_at
  }
  class EventType {
    «declaration»
    +string Type
    +string Description
    +string Subject
    +Exposure Exposure
  }
  class Exposure {
    «enumeration»
    Internal  ← zero value
    Webhook
  }
  class Recorder {
    «infra/events»
    +With(tx) Recorder
    +Record(ctx, ownerID, type, subject, data)
  }
  class RowTrigger {
    «postgres trigger»
    corpus_notes AFTER I/D, U WHEN changed
  }
  class Relay {
    «loop in every process»
    +FanOut(ctx)  «SKIP LOCKED, ≤ 200 rows»
  }
  class Subscription {
    «data»
    +string Name
    +string[] Types  «glob»
    +bool Coalesce
    +Handle(ctx, Event) error  «idempotent»
  }
  class WebhookFanout {
    «subscription · owner domain»
    globs, scope, one delivery per endpoint
  }
  class WebhookEndpoint {
    +uuid id
    +string url
    +string[] event_types
    +secret_enc
    +uuid embed_id?
    +bool enabled
    +time failing_since
    +time busy_until
  }
  class Delivery {
    «webhook.deliver job»
    +uuid endpoint_id
    +uuid event_id
  }
  class EmbedAdmits {
    «access domain, handed in»
    AllowsCorpusEntry(scope, entry)
  }
  class Embed
  EventType "1" --> "*" Event : types
  EventType --> Exposure
  Recorder ..> Event : writes in caller tx
  RowTrigger ..> Event : writes in same tx
  Relay --> Event : claims rows with fanned_out_at null
  Relay ..> Subscription : one job per match
  WebhookFanout --|> Subscription
  WebhookFanout ..> EmbedAdmits : embed-attached endpoints
  WebhookFanout ..> Delivery : enqueues, unique by args
  Delivery --> WebhookEndpoint
  WebhookEndpoint "0..1" --> "1" Embed : scope from its code
  • 底座只认 type 字符串和 JSON。
  • Exposure 默认 Internal,忘了归类的事件出不了实例。
  • embed 范围复用唯一的 ACL 判定 entity.AllowsCorpusEntry。组合根把 access 域的回答交给 owner 域的扇出,所以 infra 不 import 任何域。

相关:relay-claims-rows-not-cursor(行怎么变成任务)、queue-behind-ports(接口)、webhooks。