This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/modules/event/internal/adapter/event-store.ts
T
JCAI Main AgentandClaude Opus 5 14d4fa046a 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
2026-07-27 00:59:14 +08:00

216 lines
7.1 KiB
TypeScript

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