/** * 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 { 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 { 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 { 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; }, }; }