Archived
@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
219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
/**
|
|
* 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;
|
|
},
|
|
};
|
|
}
|