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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user