Archived
M1: 底座 —— @contracts 首版 + event / ledger / org / llm
@contracts 首版(§8.1 九个文件): - id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件 - card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿) - contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类 - manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约 模块(L1): - event 信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定 - ledger schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查 - org 岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种 - llm 四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一 app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封 ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密 (ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误) 门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cd664af3a
commit
14d4fa046a
@@ -0,0 +1,41 @@
|
||||
# module: event
|
||||
|
||||
职责: 统一事件标准:信封存储、归属解析、投递五诺、监听注册表与出站关联记录、
|
||||
三级消费的裁定(任务监听 / 触发器 / 消息兜底)、消费幂等、死信回执
|
||||
不负责: 任务如何消费事件(见 task / loop-engine / flow-engine)、触发器的命中规则求值
|
||||
(见 trigger,经 `TriggerMatcher` 端口)、消息的呈现(见 message,经 `MessageFallback` 端口)、
|
||||
S-FEEL 求值(见 @sfeel,经 `ConditionEvaluator` 端口)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`EventService` `PublishResult` `InboundResult` `ConsumptionPlan` `OwnershipKeyRules`
|
||||
`InboundFacts` `OutboundRef` 与 @contracts 的 `Envelope` / `EnvelopeDraft` /
|
||||
`Delivery` / `ListenerSpec` / `RegisteredListener` / `OwnershipResolution` 转出
|
||||
- 错误:`ListenerDeadlineError`
|
||||
- 端口类型:`EventStore` `TriggerMatcher` `MessageFallback` `ConditionEvaluator` `Clock` `IdGenerator`
|
||||
- 用例工厂:`createEventService(deps: EventServiceDeps): EventService`
|
||||
- 纯函数:`resolveOwnership` `extractKeys` `matchingListeners` `matchesListener` `matchesFilter`
|
||||
`scopeOf` `deadlineMillis` `planConsumption` `fanOutTargets` `decideRetry` `consumptionKey`
|
||||
- adapter:`createEventStore(db, ids)` `claimPendingDeliveries(db, limit)`
|
||||
- Drizzle 表:`eventTable` `deliveryTable` `listenerTable` `outboundRefTable` `consumptionTable`
|
||||
|
||||
层级: L1(编码规范 §4)—— 三级消费的三个下游(task L6 / trigger L7 / message L2)
|
||||
全部在其**上**,一律以端口声明、由 app 注入,event 不反向依赖上层
|
||||
依赖: @contracts;@shared/db
|
||||
被依赖: ledger, channel, memory, message, human-tool, release, task, process, trigger, app
|
||||
|
||||
不变量(投递五诺,§7.1):
|
||||
- **先持久后投递**:信封写 `sys.event` 与投递记录写 `sys.event_delivery` 在同一事务(ADR-008)
|
||||
- **同任务按序**:派发器按目标 ID 取 advisory lock 串行消费(`claimPendingDeliveries`)
|
||||
- **至少一次 + 幂等**:`sys.event_consumption(event_id, consumer)` 唯一约束;
|
||||
`claimConsumption` 返回 false 即已消费,重复投递无副作用
|
||||
- **处理有回执**:`reportFailure` 按退避阶梯重投,超限置 dead-letter 并经 `MessageFallback` 送通知
|
||||
- **扇出与幂等**:同一事件命中多任务 → 每任务一份独立投递(单目标);同一任务多条监听命中只一份
|
||||
|
||||
其它不变量:
|
||||
- 归属解析三级**逐级短路**;提取失败或歧义**一律降级为无主** —— 宁交人,不错投
|
||||
- **有主 ≠ 必须唤醒**:有主但零监听命中 → 仅记录(`plan.recordOnlyTask`),不降级为无主、不入消息兜底
|
||||
- **消息只兜无主**:`messageFallback` 仅在既无监听命中亦无触发命中且事件无主时为真;
|
||||
消息兜底在 `dispatch` 中**唯一**发生
|
||||
- 监听**时限必配**且必须可解析且大于零,否则 `ListenerDeadlineError`(悬空监听禁止,AM 4.4)
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 投递五诺的纯判定部分(OS 6.3 / 编码规范 §7.1) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { consumptionKey, decideRetry, fanOutTargets, planConsumption } from '../internal/domain/delivery.ts';
|
||||
import { deadlineMillis } from '../internal/domain/listener-match.ts';
|
||||
|
||||
describe('decideRetry —— 处理有回执', () => {
|
||||
it('未超限即重投,退避递增', () => {
|
||||
expect(decideRetry(1)).toEqual({ kind: 'retry', delayMs: 1_000, attempt: 2 });
|
||||
expect(decideRetry(2)).toEqual({ kind: 'retry', delayMs: 5_000, attempt: 3 });
|
||||
expect(decideRetry(4)).toEqual({ kind: 'retry', delayMs: 120_000, attempt: 5 });
|
||||
});
|
||||
|
||||
it('超限入死信', () => {
|
||||
expect(decideRetry(5)).toEqual({ kind: 'dead-letter', attempts: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fanOutTargets —— 扇出与幂等', () => {
|
||||
it('同一任务多条监听命中只产生一份投递', () => {
|
||||
expect(fanOutTargets([{ taskId: 'a' }, { taskId: 'a' }, { taskId: 'b' }])).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planConsumption —— 三级消费', () => {
|
||||
it('监听与触发独立判定:可既推进旧单又起新单', () => {
|
||||
const plan = planConsumption({
|
||||
listenerHits: [{ taskId: 't1' }],
|
||||
triggerHits: ['tr1'],
|
||||
ownedTaskId: 't1',
|
||||
});
|
||||
expect(plan.taskTargets).toEqual(['t1']);
|
||||
expect(plan.triggerTargets).toEqual(['tr1']);
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('皆未命中且无主 → 消息兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined });
|
||||
expect(plan.messageFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7' });
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBe('t7');
|
||||
expect(plan.taskTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it('有主且监听命中 → 不是仅记录', () => {
|
||||
const plan = planConsumption({ listenerHits: [{ taskId: 't7' }], triggerHits: [], ownedTaskId: 't7' });
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('无主但触发命中 → 不兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined });
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumptionKey —— 幂等键', () => {
|
||||
it('事件 ID × 消费者', () => {
|
||||
expect(consumptionKey('e1', 'task:t1')).toBe('e1::task:t1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deadlineMillis —— 监听时限必配', () => {
|
||||
it('解析 ISO-8601 duration', () => {
|
||||
expect(deadlineMillis('P3D')).toBe(3 * 86_400_000);
|
||||
expect(deadlineMillis('PT30M')).toBe(30 * 60_000);
|
||||
expect(deadlineMillis('P1DT2H30M')).toBe(86_400_000 + 2 * 3_600_000 + 30 * 60_000);
|
||||
});
|
||||
|
||||
it('非法表达式抛错', () => {
|
||||
expect(() => deadlineMillis('3天')).toThrow(/非法时限/);
|
||||
});
|
||||
|
||||
it('零时限抛错 —— 无死等', () => {
|
||||
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/** 归属解析 —— domain 测试零 mock(编码规范 §16;语义出处 OS 6.2) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { extractKeys, resolveOwnership } from '../internal/domain/ownership.ts';
|
||||
import type { InboundFacts, OwnershipKeyRules } from '../internal/domain/ownership.ts';
|
||||
|
||||
const T1 = 't-1' as TaskId;
|
||||
const T2 = 't-2' as TaskId;
|
||||
const now = new Date('2026-07-27T10:00:00Z');
|
||||
|
||||
const rules: OwnershipKeyRules = {
|
||||
patterns: [{ key: 'quoteNo', regex: 'Q\\d{6}', source: 'both' }],
|
||||
addressWindowMs: 3 * 86_400_000,
|
||||
};
|
||||
|
||||
const facts = (over: Partial<InboundFacts> = {}): InboundFacts => ({
|
||||
from: 'buyer@acme.com',
|
||||
to: ['bd@we.com'],
|
||||
subject: '回复:报价',
|
||||
body: '同意报价 Q202601',
|
||||
receivedAt: now,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('extractKeys', () => {
|
||||
it('提取线程 ID 与正则命中的关联键', () => {
|
||||
expect(extractKeys(facts({ threadId: 'thr-9' }), rules)).toEqual({
|
||||
threadId: 'thr-9',
|
||||
quoteNo: 'Q202601',
|
||||
});
|
||||
});
|
||||
|
||||
it('正则不命中即不产出该键', () => {
|
||||
expect(extractKeys(facts({ body: '没有单号' }), rules)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOwnership —— 三级逐级短路', () => {
|
||||
it('① 线程精确匹配优先', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts({ threadId: 'thr-9' }),
|
||||
rules,
|
||||
keys: { threadId: 'thr-9', quoteNo: 'Q202601' },
|
||||
outbound: [
|
||||
{ taskId: T1, threadId: 'thr-9', address: 'buyer@acme.com', sentAt: now },
|
||||
{ taskId: T2, address: 'buyer@acme.com', sentAt: now },
|
||||
],
|
||||
listeners: [{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } }],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'owned', rule: 'thread', taskId: T1 });
|
||||
});
|
||||
|
||||
it('② 关联键命中唯一监听', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts(),
|
||||
rules,
|
||||
keys: { quoteNo: 'Q202601' },
|
||||
outbound: [],
|
||||
listeners: [{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } }],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'owned', rule: 'listener-key', taskId: T2 });
|
||||
});
|
||||
|
||||
it('② 关联键命中多个任务 → 歧义降级无主', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts(),
|
||||
rules,
|
||||
keys: { quoteNo: 'Q202601' },
|
||||
outbound: [],
|
||||
listeners: [
|
||||
{ taskId: T1, keyFilter: { quoteNo: 'Q202601' } },
|
||||
{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } },
|
||||
],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'unowned', reason: 'ambiguous' });
|
||||
});
|
||||
|
||||
it('空过滤条件的监听不参与关联键匹配', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts(),
|
||||
rules,
|
||||
keys: { quoteNo: 'Q202601' },
|
||||
outbound: [],
|
||||
listeners: [{ taskId: T1, keyFilter: {} }],
|
||||
});
|
||||
expect(r.outcome).toBe('unowned');
|
||||
});
|
||||
|
||||
it('③ 地址 + 时间窗匹配唯一在途任务', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts({ body: '没有单号' }),
|
||||
rules,
|
||||
keys: {},
|
||||
outbound: [{ taskId: T1, address: 'buyer@acme.com', sentAt: new Date(now.getTime() - 86_400_000) }],
|
||||
listeners: [],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'owned', rule: 'address-window', taskId: T1 });
|
||||
});
|
||||
|
||||
it('③ 窗口外的出站记录不算', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts({ body: '没有单号' }),
|
||||
rules,
|
||||
keys: {},
|
||||
outbound: [{ taskId: T1, address: 'buyer@acme.com', sentAt: new Date(now.getTime() - 10 * 86_400_000) }],
|
||||
listeners: [],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'unowned', reason: 'no-match' });
|
||||
});
|
||||
|
||||
it('③ 命中多个即无主', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts({ body: '没有单号' }),
|
||||
rules,
|
||||
keys: {},
|
||||
outbound: [
|
||||
{ taskId: T1, address: 'buyer@acme.com', sentAt: now },
|
||||
{ taskId: T2, address: 'bd@we.com', sentAt: now },
|
||||
],
|
||||
listeners: [],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'unowned', reason: 'ambiguous' });
|
||||
});
|
||||
|
||||
it('无主时带上默认认领岗位', () => {
|
||||
const r = resolveOwnership({
|
||||
facts: facts({ body: '没有单号' }),
|
||||
rules: { ...rules, fallbackPosition: 'p-op' as never },
|
||||
keys: {},
|
||||
outbound: [],
|
||||
listeners: [],
|
||||
});
|
||||
expect(r).toEqual({ outcome: 'unowned', reason: 'no-match', fallbackPosition: 'p-op' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* module: event —— 对外类型(编码规范 §10.1)。
|
||||
*/
|
||||
import type {
|
||||
Delivery,
|
||||
Envelope,
|
||||
EnvelopeDraft,
|
||||
EventId,
|
||||
ListenerId,
|
||||
ListenerSpec,
|
||||
OwnershipResolution,
|
||||
PositionId,
|
||||
RegisteredListener,
|
||||
TaskId,
|
||||
} from '@contracts';
|
||||
import type { InboundFacts, OutboundRef, OwnershipKeyRules } from './internal/domain/ownership.ts';
|
||||
import type { ConsumptionPlan } from './internal/domain/delivery.ts';
|
||||
|
||||
export type { Delivery, Envelope, EnvelopeDraft, ListenerSpec, OwnershipResolution, RegisteredListener };
|
||||
export type { InboundFacts, OutboundRef, OwnershipKeyRules, ConsumptionPlan };
|
||||
|
||||
export class ListenerDeadlineError extends Error {
|
||||
constructor(readonly detail: string) {
|
||||
super(`监听时限非法:${detail} —— 无来源又无时限的监听是幽灵等待,一律拒绝(AM 4.4)`);
|
||||
this.name = 'ListenerDeadlineError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布结果 —— 三级消费的判定结果,ingest 路由据此回执 */
|
||||
export type PublishResult = {
|
||||
readonly eventId: EventId;
|
||||
readonly plan: ConsumptionPlan;
|
||||
};
|
||||
|
||||
export type InboundResult = PublishResult & {
|
||||
readonly resolution: OwnershipResolution;
|
||||
};
|
||||
|
||||
export type EventService = {
|
||||
/** 先持久后投递:信封落 sys.event 与入队在同一事务 */
|
||||
publish(draft: EnvelopeDraft): Promise<PublishResult>;
|
||||
/** 通道入站:归属解析 → 归一 → 抛数据事件 */
|
||||
ingestInbound(input: {
|
||||
readonly facts: InboundFacts;
|
||||
readonly rules: OwnershipKeyRules;
|
||||
readonly draft: EnvelopeDraft;
|
||||
}): Promise<InboundResult>;
|
||||
|
||||
getEvent(id: EventId): Promise<Envelope | undefined>;
|
||||
listByTask(taskId: TaskId, limit?: number): Promise<Envelope[]>;
|
||||
|
||||
/** 监听注册表 —— 时限必配,注册即校验 */
|
||||
registerListener(taskId: TaskId, spec: ListenerSpec): Promise<RegisteredListener>;
|
||||
cancelListener(id: ListenerId): Promise<void>;
|
||||
listListeners(taskId: TaskId): Promise<RegisteredListener[]>;
|
||||
/** 到期扫描:抛时间事件,按阶梯催办 */
|
||||
sweepExpiredListeners(): Promise<RegisteredListener[]>;
|
||||
|
||||
/** 出站关联记录 —— 归属解析①③级的对表来源 */
|
||||
recordOutbound(ref: OutboundRef): Promise<void>;
|
||||
|
||||
/** 消费幂等:事件 ID × 消费者;已消费返回 false */
|
||||
claimConsumption(eventId: EventId, consumer: string): Promise<boolean>;
|
||||
/** 投递失败回执:重试或入死信 */
|
||||
reportFailure(deliveryId: string, error: string): Promise<Delivery>;
|
||||
};
|
||||
|
||||
/** 无主兜底的收件岗位 —— 由通道实例配置给出 */
|
||||
export type FallbackPosition = PositionId;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* module: event —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
ConsumptionPlan,
|
||||
Delivery,
|
||||
Envelope,
|
||||
EnvelopeDraft,
|
||||
EventService,
|
||||
InboundFacts,
|
||||
InboundResult,
|
||||
ListenerSpec,
|
||||
OutboundRef,
|
||||
OwnershipKeyRules,
|
||||
OwnershipResolution,
|
||||
PublishResult,
|
||||
RegisteredListener,
|
||||
} from './contract.ts';
|
||||
export { ListenerDeadlineError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
Clock,
|
||||
ConditionEvaluator,
|
||||
EventStore,
|
||||
IdGenerator,
|
||||
MessageFallback,
|
||||
TriggerMatcher,
|
||||
} from './port.ts';
|
||||
|
||||
export type { EventServiceDeps } from './internal/usecase/event-service.ts';
|
||||
export { createEventService } from './internal/usecase/event-service.ts';
|
||||
|
||||
export type { RetryDecision, RetryPolicy } from './internal/domain/delivery.ts';
|
||||
export {
|
||||
consumptionKey,
|
||||
DEFAULT_RETRY,
|
||||
decideRetry,
|
||||
fanOutTargets,
|
||||
planConsumption,
|
||||
} from './internal/domain/delivery.ts';
|
||||
export { deadlineMillis, matchesFilter, matchesListener, matchingListeners, scopeOf } from './internal/domain/listener-match.ts';
|
||||
export { extractKeys, resolveOwnership } from './internal/domain/ownership.ts';
|
||||
|
||||
export { claimPendingDeliveries, createEventStore } from './internal/adapter/event-store.ts';
|
||||
export {
|
||||
consumptionTable,
|
||||
deliveryTable,
|
||||
eventTable,
|
||||
listenerTable,
|
||||
outboundRefTable,
|
||||
} from './internal/adapter/event-schema.ts';
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* event 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-008)。
|
||||
*/
|
||||
import { boolean, index, integer, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
/** 事件存储 —— 「先持久后投递」的底座 */
|
||||
export const eventTable = sys.table(
|
||||
'event',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
type: text('type').notNull(),
|
||||
correlation: jsonb('correlation').notNull(),
|
||||
payload: jsonb('payload').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
source: jsonb('source').notNull(),
|
||||
trace: jsonb('trace').notNull(),
|
||||
/** 冗余出的单目标,便于按任务查事件流 */
|
||||
targetKind: text('target_kind'),
|
||||
targetId: text('target_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('event_target_idx').on(t.targetKind, t.targetId, t.occurredAt),
|
||||
index('event_occurred_idx').on(t.occurredAt),
|
||||
],
|
||||
);
|
||||
|
||||
/** 投递记录 = 队列本体(ADR-008) */
|
||||
export const deliveryTable = sys.table(
|
||||
'event_delivery',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id')
|
||||
.notNull()
|
||||
.references(() => eventTable.id, { onDelete: 'cascade' }),
|
||||
targetKind: text('target_kind').notNull(),
|
||||
targetId: text('target_id').notNull(),
|
||||
attempt: integer('attempt').notNull().default(1),
|
||||
state: text('state').notNull().default('pending'),
|
||||
lastError: text('last_error'),
|
||||
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('delivery_pending_idx').on(t.state, t.nextAttemptAt),
|
||||
index('delivery_target_idx').on(t.targetKind, t.targetId),
|
||||
],
|
||||
);
|
||||
|
||||
/** 监听注册表 —— 时限必配(AM 4.4) */
|
||||
export const listenerTable = sys.table(
|
||||
'listener',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
origin: text('origin').notNull(),
|
||||
eventType: text('event_type').notNull(),
|
||||
keyFilter: jsonb('key_filter').notNull().default({}),
|
||||
condition: text('condition'),
|
||||
semantic: text('semantic').notNull(),
|
||||
deadline: text('deadline').notNull(),
|
||||
escalation: jsonb('escalation').notNull().default([]),
|
||||
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
state: text('state').notNull().default('armed'),
|
||||
},
|
||||
(t) => [index('listener_armed_idx').on(t.state, t.expiresAt), index('listener_task_idx').on(t.taskId)],
|
||||
);
|
||||
|
||||
/** 出站关联记录 —— 归属解析①③级的对表来源(OS 6.2) */
|
||||
export const outboundRefTable = sys.table(
|
||||
'outbound_ref',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
threadId: text('thread_id'),
|
||||
address: text('address').notNull(),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [index('outbound_thread_idx').on(t.threadId), index('outbound_addr_idx').on(t.address, t.sentAt)],
|
||||
);
|
||||
|
||||
/** 消费幂等表:事件 ID × 消费者(§7.1「至少一次 + 幂等」) */
|
||||
export const consumptionTable = sys.table(
|
||||
'event_consumption',
|
||||
{
|
||||
eventId: text('event_id').notNull(),
|
||||
consumer: text('consumer').notNull(),
|
||||
consumedAt: timestamp('consumed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
ok: boolean('ok').notNull().default(true),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.eventId, t.consumer] }), uniqueIndex('consumption_key').on(t.eventId, t.consumer)],
|
||||
);
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* event 存储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3)。
|
||||
*
|
||||
* 「先持久后投递」:信封与投递记录在**同一事务**落库(ADR-008)。
|
||||
*/
|
||||
import { and, asc, desc, eq, lte, sql } from 'drizzle-orm';
|
||||
import type { Delivery, Envelope, EventId, RegisteredListener, TaskId } from '@contracts';
|
||||
import { deliverySchema, envelopeSchema, registeredListenerSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { EventStore } from '../../port.ts';
|
||||
import type { OutboundRef } from '../domain/ownership.ts';
|
||||
import { consumptionTable, deliveryTable, eventTable, listenerTable, outboundRefTable } from './event-schema.ts';
|
||||
|
||||
type EventRow = typeof eventTable.$inferSelect;
|
||||
type DeliveryRow = typeof deliveryTable.$inferSelect;
|
||||
type ListenerRow = typeof listenerTable.$inferSelect;
|
||||
|
||||
function toEnvelope(row: EventRow): Envelope {
|
||||
return envelopeSchema.parse({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
correlation: row.correlation,
|
||||
payload: row.payload,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
source: row.source,
|
||||
trace: row.trace,
|
||||
});
|
||||
}
|
||||
|
||||
function toDelivery(row: DeliveryRow): Delivery {
|
||||
return deliverySchema.parse({
|
||||
id: row.id,
|
||||
eventId: row.eventId,
|
||||
target: { kind: row.targetKind, id: row.targetId },
|
||||
attempt: row.attempt,
|
||||
state: row.state,
|
||||
...(row.lastError === null ? {} : { lastError: row.lastError }),
|
||||
});
|
||||
}
|
||||
|
||||
function toListener(row: ListenerRow): RegisteredListener {
|
||||
return registeredListenerSchema.parse({
|
||||
id: row.id,
|
||||
taskId: row.taskId,
|
||||
origin: row.origin,
|
||||
eventType: row.eventType,
|
||||
keyFilter: row.keyFilter,
|
||||
...(row.condition === null ? {} : { condition: row.condition }),
|
||||
semantic: row.semantic,
|
||||
deadline: row.deadline,
|
||||
escalation: row.escalation,
|
||||
registeredAt: row.registeredAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
state: row.state,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEventStore(db: Db, ids: { next(): string }): EventStore {
|
||||
return {
|
||||
async persistAndEnqueue(envelope, targets) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(eventTable).values({
|
||||
id: envelope.id,
|
||||
type: envelope.type,
|
||||
correlation: envelope.correlation,
|
||||
payload: envelope.payload,
|
||||
occurredAt: new Date(envelope.occurredAt),
|
||||
source: envelope.source,
|
||||
trace: envelope.trace,
|
||||
targetKind: envelope.correlation.target?.kind ?? null,
|
||||
targetId: envelope.correlation.target?.id ?? null,
|
||||
});
|
||||
if (targets.length === 0) return;
|
||||
await tx.insert(deliveryTable).values(
|
||||
targets.map((d) => ({
|
||||
id: d.id,
|
||||
eventId: d.eventId,
|
||||
targetKind: d.target.kind,
|
||||
targetId: d.target.id,
|
||||
attempt: d.attempt,
|
||||
state: d.state,
|
||||
lastError: d.lastError ?? null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async get(id: EventId) {
|
||||
const rows = await db.select().from(eventTable).where(eq(eventTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toEnvelope(rows[0]);
|
||||
},
|
||||
|
||||
async listByTask(taskId: TaskId, limit: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(eventTable)
|
||||
.where(and(eq(eventTable.targetKind, 'task'), eq(eventTable.targetId, taskId)))
|
||||
.orderBy(asc(eventTable.occurredAt))
|
||||
.limit(limit);
|
||||
return rows.map(toEnvelope);
|
||||
},
|
||||
|
||||
async listenersOf(taskId: TaskId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(listenerTable)
|
||||
.where(eq(listenerTable.taskId, taskId))
|
||||
.orderBy(desc(listenerTable.registeredAt));
|
||||
return rows.map(toListener);
|
||||
},
|
||||
|
||||
async armedListeners() {
|
||||
const rows = await db.select().from(listenerTable).where(eq(listenerTable.state, 'armed'));
|
||||
return rows.map(toListener);
|
||||
},
|
||||
|
||||
async saveListener(listener) {
|
||||
await db.insert(listenerTable).values({
|
||||
id: listener.id,
|
||||
taskId: listener.taskId,
|
||||
origin: listener.origin,
|
||||
eventType: listener.eventType,
|
||||
keyFilter: listener.keyFilter,
|
||||
condition: listener.condition ?? null,
|
||||
semantic: listener.semantic,
|
||||
deadline: listener.deadline,
|
||||
escalation: listener.escalation,
|
||||
registeredAt: new Date(listener.registeredAt),
|
||||
expiresAt: new Date(listener.expiresAt),
|
||||
state: listener.state,
|
||||
});
|
||||
},
|
||||
|
||||
async setListenerState(id, state) {
|
||||
await db.update(listenerTable).set({ state }).where(eq(listenerTable.id, id));
|
||||
},
|
||||
|
||||
async expiredListeners(now: Date) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(listenerTable)
|
||||
.where(and(eq(listenerTable.state, 'armed'), lte(listenerTable.expiresAt, now)));
|
||||
return rows.map(toListener);
|
||||
},
|
||||
|
||||
async saveOutbound(ref: OutboundRef) {
|
||||
await db.insert(outboundRefTable).values({
|
||||
id: ids.next(),
|
||||
taskId: ref.taskId,
|
||||
threadId: ref.threadId ?? null,
|
||||
address: ref.address,
|
||||
sentAt: ref.sentAt,
|
||||
});
|
||||
},
|
||||
|
||||
async recentOutbound(sinceMs: number, now: Date) {
|
||||
const since = new Date(now.getTime() - sinceMs);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(outboundRefTable)
|
||||
.where(sql`${outboundRefTable.sentAt} >= ${since}`)
|
||||
.orderBy(desc(outboundRefTable.sentAt))
|
||||
.limit(500);
|
||||
return rows.map((r) => ({
|
||||
taskId: r.taskId as TaskId,
|
||||
...(r.threadId === null ? {} : { threadId: r.threadId }),
|
||||
address: r.address,
|
||||
sentAt: r.sentAt,
|
||||
}));
|
||||
},
|
||||
|
||||
async claimConsumption(eventId: EventId, consumer: string) {
|
||||
const inserted = await db
|
||||
.insert(consumptionTable)
|
||||
.values({ eventId, consumer })
|
||||
.onConflictDoNothing()
|
||||
.returning({ eventId: consumptionTable.eventId });
|
||||
return inserted.length > 0;
|
||||
},
|
||||
|
||||
async getDelivery(id: string) {
|
||||
const rows = await db.select().from(deliveryTable).where(eq(deliveryTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toDelivery(rows[0]);
|
||||
},
|
||||
|
||||
async updateDelivery(delivery) {
|
||||
await db
|
||||
.update(deliveryTable)
|
||||
.set({ attempt: delivery.attempt, state: delivery.state, lastError: delivery.lastError ?? null })
|
||||
.where(eq(deliveryTable.id, delivery.id));
|
||||
},
|
||||
|
||||
async scheduleRetry(deliveryId: string, delayMs: number) {
|
||||
await db
|
||||
.update(deliveryTable)
|
||||
.set({ nextAttemptAt: sql`now() + make_interval(secs => ${delayMs / 1000})` })
|
||||
.where(eq(deliveryTable.id, deliveryId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 派发器的取件语句(ADR-008 #2):按目标 ID 取 advisory lock,**同任务按序**。
|
||||
* 由 app 层的 worker 调用;放在 adapter 是因为它是 SQL。
|
||||
*/
|
||||
export async function claimPendingDeliveries(db: Db, limit: number): Promise<Delivery[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(deliveryTable)
|
||||
.where(and(eq(deliveryTable.state, 'pending'), lte(deliveryTable.nextAttemptAt, sql`now()`)))
|
||||
.orderBy(asc(deliveryTable.nextAttemptAt))
|
||||
.limit(limit)
|
||||
.for('update', { skipLocked: true });
|
||||
return rows.map(toDelivery);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 投递五诺的纯判定部分 —— 零 IO(编码规范 §5.3;语义出处 OS 6.3 / §7.1)。
|
||||
*
|
||||
* 先持久后投递 —— 信封写存储与入队**同一事务**(adapter 承担)
|
||||
* 同任务按序 —— 任务级串行消费(adapter 以 advisory lock 承担)
|
||||
* 至少一次+幂等 —— 消费侧幂等表(事件 ID × 消费者)唯一约束 + 本文件的去重判定
|
||||
* 处理有回执 —— 失败重投,超限入死信(本文件给出「该不该再投」)
|
||||
* 扇出与幂等 —— 同一事件命中多任务即多份独立投递,各带投递 ID;
|
||||
* 同一事件对同一触发器**至多起一单**,以事件 ID 为幂等键
|
||||
*/
|
||||
|
||||
export type RetryPolicy = {
|
||||
readonly maxAttempts: number;
|
||||
readonly backoffMs: readonly number[];
|
||||
};
|
||||
|
||||
export const DEFAULT_RETRY: RetryPolicy = { maxAttempts: 5, backoffMs: [1_000, 5_000, 30_000, 120_000] };
|
||||
|
||||
export type RetryDecision =
|
||||
| { readonly kind: 'retry'; readonly delayMs: number; readonly attempt: number }
|
||||
| { readonly kind: 'dead-letter'; readonly attempts: number };
|
||||
|
||||
export function decideRetry(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): RetryDecision {
|
||||
if (attempt >= policy.maxAttempts) return { kind: 'dead-letter', attempts: attempt };
|
||||
const idx = Math.min(attempt - 1, policy.backoffMs.length - 1);
|
||||
return { kind: 'retry', delayMs: policy.backoffMs[Math.max(idx, 0)] ?? 1_000, attempt: attempt + 1 };
|
||||
}
|
||||
|
||||
/** 幂等键:事件 ID × 消费者 —— 消费侧唯一约束的形态 */
|
||||
export function consumptionKey(eventId: string, consumer: string): string {
|
||||
return `${eventId}::${consumer}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扇出:同一事件命中多个任务监听 → 每个命中任务一份独立投递(信封关联保持单目标)。
|
||||
* 同一任务命中多条监听只产生**一份**投递 —— 任务侧按事件 ID 幂等,重复投递无意义。
|
||||
*/
|
||||
export function fanOutTargets(hits: ReadonlyArray<{ taskId: string }>): string[] {
|
||||
return [...new Set(hits.map((h) => h.taskId))];
|
||||
}
|
||||
|
||||
/**
|
||||
* 三级消费的裁定(OS 6.3):
|
||||
* 任务监听与触发器**相互独立**判定 —— 同一事件可既推进旧单又起新单;
|
||||
* 消息兜底**只在**既无监听命中亦无触发命中时发生。
|
||||
*
|
||||
* 注:有主 ≠ 必须唤醒 —— 归属解析命中的任务,事件一律写入其任务数据与任务记忆(记录);
|
||||
* 是否推进由监听命中决定;零命中时仅记录,**不降级为无主、不入消息兜底**。
|
||||
*/
|
||||
export type ConsumptionPlan = {
|
||||
readonly taskTargets: readonly string[];
|
||||
readonly triggerTargets: readonly string[];
|
||||
readonly messageFallback: boolean;
|
||||
readonly recordOnlyTask: string | undefined;
|
||||
};
|
||||
|
||||
export function planConsumption(input: {
|
||||
readonly listenerHits: ReadonlyArray<{ taskId: string }>;
|
||||
readonly triggerHits: readonly string[];
|
||||
readonly ownedTaskId: string | undefined;
|
||||
}): ConsumptionPlan {
|
||||
const taskTargets = fanOutTargets(input.listenerHits);
|
||||
const triggerTargets = [...new Set(input.triggerHits)];
|
||||
const nothingHit = taskTargets.length === 0 && triggerTargets.length === 0;
|
||||
return {
|
||||
taskTargets,
|
||||
triggerTargets,
|
||||
// 有主但零监听命中 → 仅记录,不兜底
|
||||
messageFallback: nothingHit && input.ownedTaskId === undefined,
|
||||
recordOnlyTask:
|
||||
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
|
||||
? input.ownedTaskId
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 监听过滤匹配 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.3 / AM 4.4)。
|
||||
*
|
||||
* 每条监听 = 过滤条件(事件类型 + 关联键)+ 处理语义 + 时限(必配)。
|
||||
* 内容条件用 S-FEEL,求值经 port 注入(@sfeel 在 M3 落地,event 不直接依赖它)。
|
||||
*/
|
||||
import type { Envelope, EventType, RegisteredListener } from '@contracts';
|
||||
|
||||
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
|
||||
/** 求值输入:事件信封与载荷字段(OS 03「声明条件的表达语言」) */
|
||||
export function scopeOf(envelope: Envelope): Record<string, unknown> {
|
||||
return {
|
||||
type: envelope.type,
|
||||
keys: envelope.correlation.keys,
|
||||
payload: envelope.payload.data,
|
||||
occurredAt: envelope.occurredAt,
|
||||
source: envelope.source.kind,
|
||||
...envelope.payload.data,
|
||||
};
|
||||
}
|
||||
|
||||
/** 类型与关联键的确定性过滤 —— 零求值,先跑这层 */
|
||||
export function matchesFilter(listener: RegisteredListener, type: EventType, keys: Readonly<Record<string, string>>): boolean {
|
||||
if (listener.eventType !== type) return false;
|
||||
return Object.entries(listener.keyFilter).every(([k, v]) => keys[k] === v);
|
||||
}
|
||||
|
||||
export function matchesListener(
|
||||
listener: RegisteredListener,
|
||||
envelope: Envelope,
|
||||
evaluate: ConditionEvaluator,
|
||||
): boolean {
|
||||
if (listener.state !== 'armed') return false;
|
||||
if (!matchesFilter(listener, envelope.type, envelope.correlation.keys)) return false;
|
||||
if (listener.condition === undefined) return true;
|
||||
return evaluate(listener.condition, scopeOf(envelope));
|
||||
}
|
||||
|
||||
export function matchingListeners(
|
||||
listeners: readonly RegisteredListener[],
|
||||
envelope: Envelope,
|
||||
evaluate: ConditionEvaluator,
|
||||
): RegisteredListener[] {
|
||||
return listeners.filter((l) => matchesListener(l, envelope, evaluate));
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬空监听禁止(AM 4.4):无来源又无时限的监听是「幽灵等待」——
|
||||
* 时限由 schema 强制必填,本函数校验的是**时限可解析**且大于零。
|
||||
*/
|
||||
export function deadlineMillis(iso8601Duration: string): number {
|
||||
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso8601Duration);
|
||||
if (m === null) throw new Error(`非法时限表达式:${iso8601Duration}`);
|
||||
const [, d, h, min, s] = m;
|
||||
const ms =
|
||||
Number(d ?? 0) * 86_400_000 + Number(h ?? 0) * 3_600_000 + Number(min ?? 0) * 60_000 + Number(s ?? 0) * 1_000;
|
||||
if (ms <= 0) throw new Error(`时限必须大于零:${iso8601Duration}`);
|
||||
return ms;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 归属解析 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.2)。
|
||||
*
|
||||
* 通道入站到统一事件流之间由归属解析完成路由。匹配次序确定、**逐级短路**:
|
||||
* ① 线程精确匹配 —— 会话/线程 ID 精确匹配某在途任务的出站记录
|
||||
* ② 关联键匹配监听 —— 提取的关联键命中某任务监听的过滤条件
|
||||
* ③ 地址 + 时间窗 —— 往来地址在时间窗内匹配唯一在途任务;命中多个即无主
|
||||
* 提取失败或歧义**一律降级为无主** —— 宁交人,不错投。
|
||||
*/
|
||||
import type { OwnershipResolution, PositionId, TaskId } from '@contracts';
|
||||
|
||||
/** 通道实例配置的归属键提取规则(声明式) */
|
||||
export type OwnershipKeyRules = {
|
||||
/** 从正文/主题提取关联键的正则(带一个捕获组),如单号 */
|
||||
readonly patterns: ReadonlyArray<{ key: string; regex: string; source: 'subject' | 'body' | 'both' }>;
|
||||
/** 地址 + 时间窗的窗口长度(毫秒) */
|
||||
readonly addressWindowMs: number;
|
||||
/** 无主入站的默认认领岗位 */
|
||||
readonly fallbackPosition?: PositionId;
|
||||
};
|
||||
|
||||
export type InboundFacts = {
|
||||
readonly threadId?: string;
|
||||
readonly from: string;
|
||||
readonly to: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly receivedAt: Date;
|
||||
};
|
||||
|
||||
/** 出站关联记录:任务发过什么,线程 ID 与往来地址由此登记 */
|
||||
export type OutboundRef = {
|
||||
readonly taskId: TaskId;
|
||||
readonly threadId?: string;
|
||||
readonly address: string;
|
||||
readonly sentAt: Date;
|
||||
};
|
||||
|
||||
/** 在途任务的监听过滤面 */
|
||||
export type ListenerKeyIndex = {
|
||||
readonly taskId: TaskId;
|
||||
readonly keyFilter: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
/** 从入站事实里按规则提取关联键 */
|
||||
export function extractKeys(facts: InboundFacts, rules: OwnershipKeyRules): Record<string, string> {
|
||||
const keys: Record<string, string> = {};
|
||||
if (facts.threadId !== undefined) keys['threadId'] = facts.threadId;
|
||||
for (const rule of rules.patterns) {
|
||||
const haystack =
|
||||
rule.source === 'subject' ? facts.subject : rule.source === 'body' ? facts.body : `${facts.subject}\n${facts.body}`;
|
||||
const match = new RegExp(rule.regex).exec(haystack);
|
||||
const captured = match?.[1] ?? match?.[0];
|
||||
if (captured !== undefined) keys[rule.key] = captured;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export type ResolveInput = {
|
||||
readonly facts: InboundFacts;
|
||||
readonly rules: OwnershipKeyRules;
|
||||
readonly keys: Readonly<Record<string, string>>;
|
||||
readonly outbound: readonly OutboundRef[];
|
||||
readonly listeners: readonly ListenerKeyIndex[];
|
||||
};
|
||||
|
||||
export function resolveOwnership(input: ResolveInput): OwnershipResolution {
|
||||
const { facts, rules, keys, outbound, listeners } = input;
|
||||
|
||||
// ① 线程精确匹配
|
||||
if (facts.threadId !== undefined) {
|
||||
const hits = outbound.filter((o) => o.threadId === facts.threadId);
|
||||
const first = hits[0];
|
||||
if (first !== undefined) return { outcome: 'owned', rule: 'thread', taskId: first.taskId };
|
||||
}
|
||||
|
||||
// ② 关联键命中监听的过滤条件
|
||||
const keyHits = listeners.filter((l) => {
|
||||
const entries = Object.entries(l.keyFilter);
|
||||
if (entries.length === 0) return false;
|
||||
return entries.every(([k, v]) => keys[k] === v);
|
||||
});
|
||||
const uniqueKeyTasks = [...new Set(keyHits.map((h) => h.taskId))];
|
||||
if (uniqueKeyTasks.length === 1) {
|
||||
return { outcome: 'owned', rule: 'listener-key', taskId: uniqueKeyTasks[0]! };
|
||||
}
|
||||
if (uniqueKeyTasks.length > 1) return unowned('ambiguous', rules);
|
||||
|
||||
// ③ 地址 + 时间窗
|
||||
const windowStart = facts.receivedAt.getTime() - rules.addressWindowMs;
|
||||
const addresses = new Set([facts.from, ...facts.to].map((a) => a.toLowerCase()));
|
||||
const windowHits = outbound.filter(
|
||||
(o) => o.sentAt.getTime() >= windowStart && addresses.has(o.address.toLowerCase()),
|
||||
);
|
||||
const uniqueWindowTasks = [...new Set(windowHits.map((h) => h.taskId))];
|
||||
if (uniqueWindowTasks.length === 1) {
|
||||
return { outcome: 'owned', rule: 'address-window', taskId: uniqueWindowTasks[0]! };
|
||||
}
|
||||
if (uniqueWindowTasks.length > 1) return unowned('ambiguous', rules);
|
||||
|
||||
return unowned('no-match', rules);
|
||||
}
|
||||
|
||||
function unowned(
|
||||
reason: 'no-match' | 'ambiguous' | 'extract-failed',
|
||||
rules: OwnershipKeyRules,
|
||||
): OwnershipResolution {
|
||||
return {
|
||||
outcome: 'unowned',
|
||||
reason,
|
||||
...(rules.fallbackPosition === undefined ? {} : { fallbackPosition: rules.fallbackPosition }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* event 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
|
||||
*/
|
||||
import type { Delivery, Envelope, EnvelopeDraft, EventId, ListenerSpec, RegisteredListener, TaskId } from '@contracts';
|
||||
import { deliveryIdSchema, eventIdSchema, listenerIdSchema, taskIdSchema, triggerIdSchema } from '@contracts';
|
||||
import type { EventService, InboundResult, PublishResult } from '../../contract.ts';
|
||||
import { ListenerDeadlineError } from '../../contract.ts';
|
||||
import type {
|
||||
Clock,
|
||||
ConditionEvaluator,
|
||||
EventStore,
|
||||
IdGenerator,
|
||||
MessageFallback,
|
||||
TriggerMatcher,
|
||||
} from '../../port.ts';
|
||||
import { decideRetry, planConsumption } from '../domain/delivery.ts';
|
||||
import { deadlineMillis, matchingListeners } from '../domain/listener-match.ts';
|
||||
import { extractKeys, resolveOwnership } from '../domain/ownership.ts';
|
||||
|
||||
export type EventServiceDeps = {
|
||||
readonly store: EventStore;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
readonly evaluate: ConditionEvaluator;
|
||||
readonly triggers: TriggerMatcher;
|
||||
readonly messages: MessageFallback;
|
||||
/** 地址+时间窗的默认窗口(毫秒);通道实例配置可覆盖 */
|
||||
readonly addressWindowMs?: number;
|
||||
};
|
||||
|
||||
export function createEventService(deps: EventServiceDeps): EventService {
|
||||
function seal(draft: EnvelopeDraft, id: EventId): Envelope {
|
||||
return { ...draft, id, trace: { storeRef: `sys.event/${id}` } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 三级消费的裁定与投递。消息兜底在此**唯一**发生 —— 调用方传入无主兜底岗位,
|
||||
* 避免 publish / ingestInbound 两处各发一次(OS 6.3:消息只兜无主)。
|
||||
*/
|
||||
async function dispatch(envelope: Envelope, fallbackPosition?: string): Promise<PublishResult> {
|
||||
const [armed, triggerHits] = await Promise.all([
|
||||
deps.store.armedListeners(),
|
||||
deps.triggers.match(envelope),
|
||||
]);
|
||||
const hits = matchingListeners(armed, envelope, deps.evaluate).map((l) => ({ taskId: String(l.taskId) }));
|
||||
const ownedTaskId =
|
||||
envelope.correlation.target?.kind === 'task' ? String(envelope.correlation.target.id) : undefined;
|
||||
|
||||
const plan = planConsumption({ listenerHits: hits, triggerHits, ownedTaskId });
|
||||
|
||||
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
|
||||
const deliveries: Delivery[] = [
|
||||
...plan.taskTargets.map(
|
||||
(taskId): Delivery => ({
|
||||
id: deliveryIdSchema.parse(deps.ids.next()),
|
||||
eventId: envelope.id,
|
||||
target: { kind: 'task', id: taskIdSchema.parse(taskId) },
|
||||
attempt: 1,
|
||||
state: 'pending',
|
||||
}),
|
||||
),
|
||||
...plan.triggerTargets.map(
|
||||
(triggerId): Delivery => ({
|
||||
id: deliveryIdSchema.parse(deps.ids.next()),
|
||||
eventId: envelope.id,
|
||||
target: { kind: 'trigger', id: triggerIdSchema.parse(triggerId) },
|
||||
attempt: 1,
|
||||
state: 'pending',
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// 先持久后投递 —— 落库与入队同一事务
|
||||
await deps.store.persistAndEnqueue(envelope, deliveries);
|
||||
|
||||
// 消息兜底:仅当既无监听命中亦无触发命中、且事件无主
|
||||
if (plan.messageFallback) {
|
||||
await deps.messages.unowned(envelope, fallbackPosition);
|
||||
}
|
||||
|
||||
return { eventId: envelope.id, plan };
|
||||
}
|
||||
|
||||
return {
|
||||
async publish(draft) {
|
||||
const id = eventIdSchema.parse(draft.id ?? deps.ids.next());
|
||||
return dispatch(seal(draft, id));
|
||||
},
|
||||
|
||||
async ingestInbound({ facts, rules, draft }): Promise<InboundResult> {
|
||||
const keys = extractKeys(facts, rules);
|
||||
const windowMs = rules.addressWindowMs || deps.addressWindowMs || 7 * 86_400_000;
|
||||
const [outbound, armed] = await Promise.all([
|
||||
deps.store.recentOutbound(windowMs, facts.receivedAt),
|
||||
deps.store.armedListeners(),
|
||||
]);
|
||||
|
||||
const resolution = resolveOwnership({
|
||||
facts,
|
||||
rules: { ...rules, addressWindowMs: windowMs },
|
||||
keys,
|
||||
outbound,
|
||||
listeners: armed.map((l) => ({ taskId: l.taskId, keyFilter: l.keyFilter })),
|
||||
});
|
||||
|
||||
const id = eventIdSchema.parse(draft.id ?? deps.ids.next());
|
||||
const envelope = seal(
|
||||
{
|
||||
...draft,
|
||||
correlation: {
|
||||
...draft.correlation,
|
||||
...(resolution.outcome === 'owned'
|
||||
? { target: { kind: 'task' as const, id: resolution.taskId } }
|
||||
: {}),
|
||||
keys: { ...keys, ...draft.correlation.keys },
|
||||
},
|
||||
},
|
||||
id,
|
||||
);
|
||||
|
||||
// 无主入站的相关人 = 通道实例配置的默认认领岗位(OS 6.2)
|
||||
const result = await dispatch(
|
||||
envelope,
|
||||
resolution.outcome === 'unowned' ? resolution.fallbackPosition : undefined,
|
||||
);
|
||||
return { ...result, resolution };
|
||||
},
|
||||
|
||||
async getEvent(id) {
|
||||
return deps.store.get(id);
|
||||
},
|
||||
|
||||
async listByTask(taskId, limit = 200) {
|
||||
return deps.store.listByTask(taskId, limit);
|
||||
},
|
||||
|
||||
async registerListener(taskId: TaskId, spec: ListenerSpec): Promise<RegisteredListener> {
|
||||
let ms: number;
|
||||
try {
|
||||
ms = deadlineMillis(spec.deadline);
|
||||
} catch (error) {
|
||||
throw new ListenerDeadlineError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const now = deps.clock.now();
|
||||
const listener: RegisteredListener = {
|
||||
...spec,
|
||||
id: listenerIdSchema.parse(deps.ids.next()),
|
||||
taskId,
|
||||
registeredAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + ms).toISOString(),
|
||||
state: 'armed',
|
||||
};
|
||||
await deps.store.saveListener(listener);
|
||||
return listener;
|
||||
},
|
||||
|
||||
async cancelListener(id) {
|
||||
await deps.store.setListenerState(id, 'cancelled');
|
||||
},
|
||||
|
||||
async listListeners(taskId) {
|
||||
return deps.store.listenersOf(taskId);
|
||||
},
|
||||
|
||||
async sweepExpiredListeners() {
|
||||
const now = deps.clock.now();
|
||||
const expired = await deps.store.expiredListeners(now);
|
||||
for (const listener of expired) {
|
||||
await deps.store.setListenerState(listener.id, 'expired');
|
||||
// 到点抛时间事件 —— 无死等(OS 6.3)
|
||||
await this.publish({
|
||||
type: 'time',
|
||||
correlation: {
|
||||
target: { kind: 'task', id: listener.taskId },
|
||||
cause: { kind: 'timer', ref: listener.id },
|
||||
keys: listener.keyFilter,
|
||||
},
|
||||
payload: {
|
||||
data: {
|
||||
listenerId: listener.id,
|
||||
timerRef: listener.id,
|
||||
escalation: listener.escalation[0] ?? 'nudge',
|
||||
},
|
||||
},
|
||||
occurredAt: now.toISOString(),
|
||||
source: { kind: 'engine', detail: 'listener-sweep' },
|
||||
});
|
||||
}
|
||||
return expired;
|
||||
},
|
||||
|
||||
async recordOutbound(ref) {
|
||||
await deps.store.saveOutbound(ref);
|
||||
},
|
||||
|
||||
async claimConsumption(eventId, consumer) {
|
||||
return deps.store.claimConsumption(eventId, consumer);
|
||||
},
|
||||
|
||||
async reportFailure(deliveryId, error) {
|
||||
const delivery = await deps.store.getDelivery(deliveryId);
|
||||
if (delivery === undefined) throw new Error(`投递不存在:${deliveryId}`);
|
||||
const decision = decideRetry(delivery.attempt);
|
||||
if (decision.kind === 'retry') {
|
||||
const next: Delivery = { ...delivery, attempt: decision.attempt, state: 'pending', lastError: error };
|
||||
await deps.store.updateDelivery(next);
|
||||
await deps.store.scheduleRetry(deliveryId, decision.delayMs);
|
||||
return next;
|
||||
}
|
||||
const dead: Delivery = { ...delivery, state: 'dead-letter', lastError: error };
|
||||
await deps.store.updateDelivery(dead);
|
||||
const envelope = await deps.store.get(delivery.eventId);
|
||||
// 死信以系统通知送达相关人的消息要素与治理台(OS 6.3)
|
||||
if (envelope !== undefined) await deps.messages.deadLetter(envelope, error);
|
||||
return dead;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/event",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* module: event —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*
|
||||
* 注意层级:event 是 L1,而任务(L6)、触发器(L7)、消息(L2)都在其上 —— 三级消费的
|
||||
* 三个下游一律以**端口**声明、由 app 装配注入,event 不反向依赖上层(§4 / §5.2)。
|
||||
*/
|
||||
import type { Delivery, Envelope, EventId, RegisteredListener, TaskId } from '@contracts';
|
||||
import type { OutboundRef } from './internal/domain/ownership.ts';
|
||||
import type { ConditionEvaluator } from './internal/domain/listener-match.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
export type { ConditionEvaluator };
|
||||
|
||||
/** 事件存储 —— 「先持久后投递」的底座 */
|
||||
export type EventStore = {
|
||||
/** 信封落库与入队同一事务 */
|
||||
persistAndEnqueue(envelope: Envelope, targets: readonly Delivery[]): Promise<void>;
|
||||
get(id: EventId): Promise<Envelope | undefined>;
|
||||
listByTask(taskId: TaskId, limit: number): Promise<Envelope[]>;
|
||||
|
||||
listenersOf(taskId: TaskId): Promise<RegisteredListener[]>;
|
||||
/** 全部 armed 监听 —— 事件到达时的匹配面 */
|
||||
armedListeners(): Promise<RegisteredListener[]>;
|
||||
saveListener(listener: RegisteredListener): Promise<void>;
|
||||
setListenerState(id: string, state: RegisteredListener['state']): Promise<void>;
|
||||
expiredListeners(now: Date): Promise<RegisteredListener[]>;
|
||||
|
||||
saveOutbound(ref: OutboundRef): Promise<void>;
|
||||
recentOutbound(sinceMs: number, now: Date): Promise<OutboundRef[]>;
|
||||
|
||||
/** 消费幂等表:唯一约束成功返回 true,冲突返回 false */
|
||||
claimConsumption(eventId: EventId, consumer: string): Promise<boolean>;
|
||||
|
||||
getDelivery(id: string): Promise<Delivery | undefined>;
|
||||
updateDelivery(delivery: Delivery): Promise<void>;
|
||||
scheduleRetry(deliveryId: string, delayMs: number): Promise<void>;
|
||||
};
|
||||
|
||||
/** 触发器判定 —— app 注入 `trigger`(L7)的只读用例;返回命中的触发器 ID */
|
||||
export type TriggerMatcher = { match(envelope: Envelope): Promise<string[]> };
|
||||
|
||||
/** 死信与无主兜底 —— app 注入 `message`(L2) */
|
||||
export type MessageFallback = {
|
||||
deadLetter(envelope: Envelope, error: string): Promise<void>;
|
||||
unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise<void>;
|
||||
};
|
||||
Reference in New Issue
Block a user