@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
216 lines
7.1 KiB
TypeScript
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);
|
|
}
|