# 领域事件 DSL 扩展规划（Domain Event）

> 状态：**部分落地（步骤 1 声明层 ✅ / 步骤 3 @Trans afterCommit ✅ / 步骤 4 outbox DDL ✅ / 发布 API ✅，其余待实现）**
> 关联代码：`dsl/src/event.ts`（UI 事件，非领域事件）、`dsl/src/flow.ts`（flow IR）、`pylon-dao/src/trans.ts`（`@Trans()`）
> 背景对话：ts-libs 会话「dd DDD 扩展讨论」；姊妹篇：[aggregate.md](./aggregate.md)（聚合/仓储规划）

## 1. 背景：pylon 事件基础设施现状

- 整个 pylon 体系目前**零事件基础设施**：flow.ts 无 event，仓库无 publish/subscribe/EventEmitter；
- `dsl/src/event.ts` 是 **UI 组件事件**（Tab onChange 的 `e.detail`），与领域事件完全无关；
- flow 只有 `invoke`（调用方法）/ `write`（写槽）/ `THROW` / `RETURN`，**没有"发布事件"动作，也没有事件声明**。

**命令 vs 事件**（必须分清）：

| | Command（命令） | Event（事件） |
|---|---|---|
| 语义 | "去做某事"（意图） | "某事已经发生"（事实） |
| 时态 | 未来/祈使 | 过去/陈述 |
| 命名 | `PlaceOrder`、`CancelOrder` | `OrderPlaced`、`OrderCancelled` |
| 接收方 | 有且一个（命令处理器） | 零到多个订阅者 |
| 失败处理 | 调用方要感知失败 | 发布方不关心谁处理 |
| dsl 对应 | flow 的 `invoke(m)` | **新概念** |

**事件驱动的解耦价值**：命令式让 flow 依赖所有下游（扣库存、发短信、加积分都要在 flow 里 invoke）；事件式让 flow 只依赖"事实"（事件名 + 数据），下游变化不影响 flow。

## 2. 事件生命周期（五步）

```
① 发生：Order.cancel() 状态 CREATED → CANCELLED
② 收集：聚合根在内存记录"我取消了"
③ 发布：事务提交成功后投递 OrderCancelled
④ 订阅：库存上下文、通知服务各自注册监听
⑤ 处理：各订阅者做自己的事（扣库存、发短信）
```

关键在 ②→③ 的**发布时机**，决定一致性。

## 3. 发布时机：三种策略

| 策略 | 做法 | 问题 |
|------|------|------|
| A. 事务内发 | tx { 改状态; 发事件; } | 订阅者失败回滚主事务；跨服务时事务管不到对方 |
| B. 事务后发 | tx { 改状态; } → 发事件 | 发的时候进程崩了 → 事件丢失，状态改了没人知道 |
| **C. Transactional Outbox（生产标准）** | tx { 改状态; INSERT outbox; } → 后台 relay 投递 | **保证：状态和事件要么都成要么都败，事件最终必达** |

**pylon 现状对照**：knex 透明代理 + `@Trans()` 让"更新状态 + 写 outbox 表"同事务天然可行（都是 DAO 调用），outbox 在 pylon 技术上现成，缺的是声明层。

## 4. dsl 扩展方向：三件新东西

```ts
// ① 事件声明（一等 schema，携带数据快照）
defineDomainEvent({
  name: 'OrderCancelled',
  fields: { orderNo: str, reason: str },   // 快照，不是引用
});

// ② flow 里加 publish action（编译成同事务写 outbox 表）
flowScript('cancelOrder', { args, slots }, ({ next, slots }) => {
  next(
    invoke(orderService.load, { id: slots.orderId }, 'order'),
    invoke(order.cancel),                                    // 状态流转
    publish('OrderCancelled', { orderNo: slots.order.orderNo, reason: 'user' }),  // 新动作
  );
  return flowEnd.ok;
});

// ③ 订阅声明（复用 flow 编排处理逻辑）
defineEventHandler({
  name: 'onOrderCancelled',
  event: 'OrderCancelled',
  flow: handleOrderCancelledFlow,   // 处理流程复用现有 flow 模型
});
```

- 发布是**声明不是副作用**：`publish(...)` 在 flow 里显式可见，审查时一眼看到"此流程发什么事件"；
- 不用引入消息队列：outbox 表 + 后台 relay 即够（BLE 案例规模不需要 RabbitMQ）；
- 订阅：启动时扫描 handler 注册到总线 / 消费组。

## 5. 发布技术选型（结论）

**发布侧：已定死 —— MySQL outbox 表**（与业务同事务，knex 现成）：

```sql
CREATE TABLE event_outbox (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  event_name VARCHAR(100) NOT NULL,      -- 'OrderCancelled'
  payload JSON NOT NULL,                 -- 事件数据快照
  status ENUM('pending','delivered') DEFAULT 'pending',
  created_at DATETIME DEFAULT NOW()
);
```

**投递侧：按部署形态分档**：

| 方案 | 传输介质 | 延迟 | 依赖 | 适用 |
|------|---------|------|------|------|
| **A. 进程内总线** | Node 内存（EventEmitter） | 0 | 无 | **单进程（pylon 默认）** |
| **B. MySQL 轮询 relay** | 扫 outbox 表 | ~轮询间隔 | 无（现成） | 单进程但要持久化兜底 |
| **C. Redis Stream** | Redis | 毫秒 | ioredis（sign-redis-driver 已有） | 多进程/多服务 |
| **D. 消息队列** | RabbitMQ / Kafka | 毫秒 | 重型中间件 | 生产大规模，超出 pylon 定位 |

**推荐路径**：

- **单进程（pylon 默认，先做）**：A + B 结合，零新依赖——事务提交后（afterCommit）进程内直接分发（延迟 0），outbox 兜底补投失败/崩溃遗留的 pending 记录（不丢事件）。`@Trans()` 加 afterCommit 钩子即可：

  ```ts
  // trans.ts 扩展：事务提交成功后执行注册的回调
  export function Trans() {
    return function (target, key, descriptor) {
      const original = descriptor.value;
      descriptor.value = async function (...args) {
        return knex.transaction(async (trx) => {
          const afterCommits: Array<() => Promise<void>> = [];
          return txStorage.run(trx, async () => {
            const result = await original.apply(this, args);
            await trx.executionPromise;                 // 等事务真正提交
            await Promise.all(afterCommits.map(fn => fn()));
            return result;
          });
        });
      };
    };
  }
  ```

- **多进程/多服务（未来升级）**：方案 C Redis Stream——pylon 已有 ioredis（sign-redis-driver），Redis 不算新基建；outbox relay 从进程内分发换成 `XADD`，订阅服务 `XREADGROUP` 消费。
- **方案 D 不做**：超出 pylon"薄封装、快速开发"定位。

**订阅侧**：`defineEventHandler` 声明 + 启动扫描注册；复用 flow 编排处理逻辑，不需要新执行模型。

## 6. 落地步骤（待办，未开工）

1. `DomainEventSchema` 类型 + `defineDomainEvent` + 定义期校验（字段类型、事件名唯一）✅；
2. flow 加 `publish` action：编译成"同事务插 outbox 表"的 DAO 调用 + afterCommit 回调注册；
3. `@Trans()` 扩展 afterCommit 钩子（`pylon-dao/src/trans.ts`）✅；4. `event_outbox` 表：DDL 由 gen 生成（`defineDomainEvent` 自动建表）✅（独立包 `@pylonts/event` 已含发布 API：`publishEvent` + `@EventNotifier`，outbox DDL 由 `gen-outbox.ts` + `pylonts gen sql init` 追加）；
5. relay：单进程（进程内分发 + 启动/定时补投）→ 跨进程（Redis Stream）；
6. `defineEventHandler` + 启动扫描注册订阅者。