forked from ChaiHao/BPOS-MVP
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,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user