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:
JCAI Main Agent
2026-07-27 00:59:14 +08:00
co-authored by Claude Opus 5
parent 7cd664af3a
commit 14d4fa046a
120 changed files with 15236 additions and 78 deletions
+41
View File
@@ -0,0 +1,41 @@
# module: event
职责: 统一事件标准:信封存储、归属解析、投递五诺、监听注册表与出站关联记录、
三级消费的裁定(任务监听 / 触发器 / 消息兜底)、消费幂等、死信回执
不负责: 任务如何消费事件(见 task / loop-engine / flow-engine)、触发器的命中规则求值
(见 trigger,经 `TriggerMatcher` 端口)、消息的呈现(见 message,经 `MessageFallback` 端口)、
S-FEEL 求值(见 @sfeel,经 `ConditionEvaluator` 端口)
公开出口: index.ts
- 类型:`EventService` `PublishResult` `InboundResult` `ConsumptionPlan` `OwnershipKeyRules`
`InboundFacts` `OutboundRef` 与 @contracts 的 `Envelope` / `EnvelopeDraft` /
`Delivery` / `ListenerSpec` / `RegisteredListener` / `OwnershipResolution` 转出
- 错误:`ListenerDeadlineError`
- 端口类型:`EventStore` `TriggerMatcher` `MessageFallback` `ConditionEvaluator` `Clock` `IdGenerator`
- 用例工厂:`createEventService(deps: EventServiceDeps): EventService`
- 纯函数:`resolveOwnership` `extractKeys` `matchingListeners` `matchesListener` `matchesFilter`
`scopeOf` `deadlineMillis` `planConsumption` `fanOutTargets` `decideRetry` `consumptionKey`
- adapter:`createEventStore(db, ids)` `claimPendingDeliveries(db, limit)`
- Drizzle 表:`eventTable` `deliveryTable` `listenerTable` `outboundRefTable` `consumptionTable`
层级: L1(编码规范 §4)—— 三级消费的三个下游(task L6 / trigger L7 / message L2)
全部在其**上**,一律以端口声明、由 app 注入,event 不反向依赖上层
依赖: @contracts;@shared/db
被依赖: ledger, channel, memory, message, human-tool, release, task, process, trigger, app
不变量(投递五诺,§7.1):
- **先持久后投递**:信封写 `sys.event` 与投递记录写 `sys.event_delivery` 在同一事务(ADR-008)
- **同任务按序**:派发器按目标 ID 取 advisory lock 串行消费(`claimPendingDeliveries`)
- **至少一次 + 幂等**:`sys.event_consumption(event_id, consumer)` 唯一约束;
`claimConsumption` 返回 false 即已消费,重复投递无副作用
- **处理有回执**:`reportFailure` 按退避阶梯重投,超限置 dead-letter 并经 `MessageFallback` 送通知
- **扇出与幂等**:同一事件命中多任务 → 每任务一份独立投递(单目标);同一任务多条监听命中只一份
其它不变量:
- 归属解析三级**逐级短路**;提取失败或歧义**一律降级为无主** —— 宁交人,不错投
- **有主 ≠ 必须唤醒**:有主但零监听命中 → 仅记录(`plan.recordOnlyTask`),不降级为无主、不入消息兜底
- **消息只兜无主**:`messageFallback` 仅在既无监听命中亦无触发命中且事件无主时为真;
消息兜底在 `dispatch` 中**唯一**发生
- 监听**时限必配**且必须可解析且大于零,否则 `ListenerDeadlineError`(悬空监听禁止,AM 4.4)
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,79 @@
/** 投递五诺的纯判定部分(OS 6.3 / 编码规范 §7.1) */
import { describe, expect, it } from 'vitest';
import { consumptionKey, decideRetry, fanOutTargets, planConsumption } from '../internal/domain/delivery.ts';
import { deadlineMillis } from '../internal/domain/listener-match.ts';
describe('decideRetry —— 处理有回执', () => {
it('未超限即重投,退避递增', () => {
expect(decideRetry(1)).toEqual({ kind: 'retry', delayMs: 1_000, attempt: 2 });
expect(decideRetry(2)).toEqual({ kind: 'retry', delayMs: 5_000, attempt: 3 });
expect(decideRetry(4)).toEqual({ kind: 'retry', delayMs: 120_000, attempt: 5 });
});
it('超限入死信', () => {
expect(decideRetry(5)).toEqual({ kind: 'dead-letter', attempts: 5 });
});
});
describe('fanOutTargets —— 扇出与幂等', () => {
it('同一任务多条监听命中只产生一份投递', () => {
expect(fanOutTargets([{ taskId: 'a' }, { taskId: 'a' }, { taskId: 'b' }])).toEqual(['a', 'b']);
});
});
describe('planConsumption —— 三级消费', () => {
it('监听与触发独立判定:可既推进旧单又起新单', () => {
const plan = planConsumption({
listenerHits: [{ taskId: 't1' }],
triggerHits: ['tr1'],
ownedTaskId: 't1',
});
expect(plan.taskTargets).toEqual(['t1']);
expect(plan.triggerTargets).toEqual(['tr1']);
expect(plan.messageFallback).toBe(false);
});
it('皆未命中且无主 → 消息兜底', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined });
expect(plan.messageFallback).toBe(true);
});
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7' });
expect(plan.messageFallback).toBe(false);
expect(plan.recordOnlyTask).toBe('t7');
expect(plan.taskTargets).toEqual([]);
});
it('有主且监听命中 → 不是仅记录', () => {
const plan = planConsumption({ listenerHits: [{ taskId: 't7' }], triggerHits: [], ownedTaskId: 't7' });
expect(plan.recordOnlyTask).toBeUndefined();
});
it('无主但触发命中 → 不兜底', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined });
expect(plan.messageFallback).toBe(false);
});
});
describe('consumptionKey —— 幂等键', () => {
it('事件 ID × 消费者', () => {
expect(consumptionKey('e1', 'task:t1')).toBe('e1::task:t1');
});
});
describe('deadlineMillis —— 监听时限必配', () => {
it('解析 ISO-8601 duration', () => {
expect(deadlineMillis('P3D')).toBe(3 * 86_400_000);
expect(deadlineMillis('PT30M')).toBe(30 * 60_000);
expect(deadlineMillis('P1DT2H30M')).toBe(86_400_000 + 2 * 3_600_000 + 30 * 60_000);
});
it('非法表达式抛错', () => {
expect(() => deadlineMillis('3天')).toThrow(/非法时限/);
});
it('零时限抛错 —— 无死等', () => {
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
});
});
@@ -0,0 +1,135 @@
/** 归属解析 —— domain 测试零 mock(编码规范 §16;语义出处 OS 6.2) */
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { extractKeys, resolveOwnership } from '../internal/domain/ownership.ts';
import type { InboundFacts, OwnershipKeyRules } from '../internal/domain/ownership.ts';
const T1 = 't-1' as TaskId;
const T2 = 't-2' as TaskId;
const now = new Date('2026-07-27T10:00:00Z');
const rules: OwnershipKeyRules = {
patterns: [{ key: 'quoteNo', regex: 'Q\\d{6}', source: 'both' }],
addressWindowMs: 3 * 86_400_000,
};
const facts = (over: Partial<InboundFacts> = {}): InboundFacts => ({
from: 'buyer@acme.com',
to: ['bd@we.com'],
subject: '回复:报价',
body: '同意报价 Q202601',
receivedAt: now,
...over,
});
describe('extractKeys', () => {
it('提取线程 ID 与正则命中的关联键', () => {
expect(extractKeys(facts({ threadId: 'thr-9' }), rules)).toEqual({
threadId: 'thr-9',
quoteNo: 'Q202601',
});
});
it('正则不命中即不产出该键', () => {
expect(extractKeys(facts({ body: '没有单号' }), rules)).toEqual({});
});
});
describe('resolveOwnership —— 三级逐级短路', () => {
it('① 线程精确匹配优先', () => {
const r = resolveOwnership({
facts: facts({ threadId: 'thr-9' }),
rules,
keys: { threadId: 'thr-9', quoteNo: 'Q202601' },
outbound: [
{ taskId: T1, threadId: 'thr-9', address: 'buyer@acme.com', sentAt: now },
{ taskId: T2, address: 'buyer@acme.com', sentAt: now },
],
listeners: [{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } }],
});
expect(r).toEqual({ outcome: 'owned', rule: 'thread', taskId: T1 });
});
it('② 关联键命中唯一监听', () => {
const r = resolveOwnership({
facts: facts(),
rules,
keys: { quoteNo: 'Q202601' },
outbound: [],
listeners: [{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } }],
});
expect(r).toEqual({ outcome: 'owned', rule: 'listener-key', taskId: T2 });
});
it('② 关联键命中多个任务 → 歧义降级无主', () => {
const r = resolveOwnership({
facts: facts(),
rules,
keys: { quoteNo: 'Q202601' },
outbound: [],
listeners: [
{ taskId: T1, keyFilter: { quoteNo: 'Q202601' } },
{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } },
],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'ambiguous' });
});
it('空过滤条件的监听不参与关联键匹配', () => {
const r = resolveOwnership({
facts: facts(),
rules,
keys: { quoteNo: 'Q202601' },
outbound: [],
listeners: [{ taskId: T1, keyFilter: {} }],
});
expect(r.outcome).toBe('unowned');
});
it('③ 地址 + 时间窗匹配唯一在途任务', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules,
keys: {},
outbound: [{ taskId: T1, address: 'buyer@acme.com', sentAt: new Date(now.getTime() - 86_400_000) }],
listeners: [],
});
expect(r).toEqual({ outcome: 'owned', rule: 'address-window', taskId: T1 });
});
it('③ 窗口外的出站记录不算', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules,
keys: {},
outbound: [{ taskId: T1, address: 'buyer@acme.com', sentAt: new Date(now.getTime() - 10 * 86_400_000) }],
listeners: [],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'no-match' });
});
it('③ 命中多个即无主', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules,
keys: {},
outbound: [
{ taskId: T1, address: 'buyer@acme.com', sentAt: now },
{ taskId: T2, address: 'bd@we.com', sentAt: now },
],
listeners: [],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'ambiguous' });
});
it('无主时带上默认认领岗位', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules: { ...rules, fallbackPosition: 'p-op' as never },
keys: {},
outbound: [],
listeners: [],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'no-match', fallbackPosition: 'p-op' });
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* module: event —— 对外类型(编码规范 §10.1)。
*/
import type {
Delivery,
Envelope,
EnvelopeDraft,
EventId,
ListenerId,
ListenerSpec,
OwnershipResolution,
PositionId,
RegisteredListener,
TaskId,
} from '@contracts';
import type { InboundFacts, OutboundRef, OwnershipKeyRules } from './internal/domain/ownership.ts';
import type { ConsumptionPlan } from './internal/domain/delivery.ts';
export type { Delivery, Envelope, EnvelopeDraft, ListenerSpec, OwnershipResolution, RegisteredListener };
export type { InboundFacts, OutboundRef, OwnershipKeyRules, ConsumptionPlan };
export class ListenerDeadlineError extends Error {
constructor(readonly detail: string) {
super(`监听时限非法:${detail} —— 无来源又无时限的监听是幽灵等待,一律拒绝(AM 4.4)`);
this.name = 'ListenerDeadlineError';
}
}
/** 发布结果 —— 三级消费的判定结果,ingest 路由据此回执 */
export type PublishResult = {
readonly eventId: EventId;
readonly plan: ConsumptionPlan;
};
export type InboundResult = PublishResult & {
readonly resolution: OwnershipResolution;
};
export type EventService = {
/** 先持久后投递:信封落 sys.event 与入队在同一事务 */
publish(draft: EnvelopeDraft): Promise<PublishResult>;
/** 通道入站:归属解析 → 归一 → 抛数据事件 */
ingestInbound(input: {
readonly facts: InboundFacts;
readonly rules: OwnershipKeyRules;
readonly draft: EnvelopeDraft;
}): Promise<InboundResult>;
getEvent(id: EventId): Promise<Envelope | undefined>;
listByTask(taskId: TaskId, limit?: number): Promise<Envelope[]>;
/** 监听注册表 —— 时限必配,注册即校验 */
registerListener(taskId: TaskId, spec: ListenerSpec): Promise<RegisteredListener>;
cancelListener(id: ListenerId): Promise<void>;
listListeners(taskId: TaskId): Promise<RegisteredListener[]>;
/** 到期扫描:抛时间事件,按阶梯催办 */
sweepExpiredListeners(): Promise<RegisteredListener[]>;
/** 出站关联记录 —— 归属解析①③级的对表来源 */
recordOutbound(ref: OutboundRef): Promise<void>;
/** 消费幂等:事件 ID × 消费者;已消费返回 false */
claimConsumption(eventId: EventId, consumer: string): Promise<boolean>;
/** 投递失败回执:重试或入死信 */
reportFailure(deliveryId: string, error: string): Promise<Delivery>;
};
/** 无主兜底的收件岗位 —— 由通道实例配置给出 */
export type FallbackPosition = PositionId;
+51
View File
@@ -0,0 +1,51 @@
/**
* module: event —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ConsumptionPlan,
Delivery,
Envelope,
EnvelopeDraft,
EventService,
InboundFacts,
InboundResult,
ListenerSpec,
OutboundRef,
OwnershipKeyRules,
OwnershipResolution,
PublishResult,
RegisteredListener,
} from './contract.ts';
export { ListenerDeadlineError } from './contract.ts';
export type {
Clock,
ConditionEvaluator,
EventStore,
IdGenerator,
MessageFallback,
TriggerMatcher,
} from './port.ts';
export type { EventServiceDeps } from './internal/usecase/event-service.ts';
export { createEventService } from './internal/usecase/event-service.ts';
export type { RetryDecision, RetryPolicy } from './internal/domain/delivery.ts';
export {
consumptionKey,
DEFAULT_RETRY,
decideRetry,
fanOutTargets,
planConsumption,
} from './internal/domain/delivery.ts';
export { deadlineMillis, matchesFilter, matchesListener, matchingListeners, scopeOf } from './internal/domain/listener-match.ts';
export { extractKeys, resolveOwnership } from './internal/domain/ownership.ts';
export { claimPendingDeliveries, createEventStore } from './internal/adapter/event-store.ts';
export {
consumptionTable,
deliveryTable,
eventTable,
listenerTable,
outboundRefTable,
} from './internal/adapter/event-schema.ts';
@@ -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;
},
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/event",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+47
View File
@@ -0,0 +1,47 @@
/**
* module: event —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*
* 注意层级:event 是 L1,而任务(L6)、触发器(L7)、消息(L2)都在其上 —— 三级消费的
* 三个下游一律以**端口**声明、由 app 装配注入,event 不反向依赖上层(§4 / §5.2)。
*/
import type { Delivery, Envelope, EventId, RegisteredListener, TaskId } from '@contracts';
import type { OutboundRef } from './internal/domain/ownership.ts';
import type { ConditionEvaluator } from './internal/domain/listener-match.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type { ConditionEvaluator };
/** 事件存储 —— 「先持久后投递」的底座 */
export type EventStore = {
/** 信封落库与入队同一事务 */
persistAndEnqueue(envelope: Envelope, targets: readonly Delivery[]): Promise<void>;
get(id: EventId): Promise<Envelope | undefined>;
listByTask(taskId: TaskId, limit: number): Promise<Envelope[]>;
listenersOf(taskId: TaskId): Promise<RegisteredListener[]>;
/** 全部 armed 监听 —— 事件到达时的匹配面 */
armedListeners(): Promise<RegisteredListener[]>;
saveListener(listener: RegisteredListener): Promise<void>;
setListenerState(id: string, state: RegisteredListener['state']): Promise<void>;
expiredListeners(now: Date): Promise<RegisteredListener[]>;
saveOutbound(ref: OutboundRef): Promise<void>;
recentOutbound(sinceMs: number, now: Date): Promise<OutboundRef[]>;
/** 消费幂等表:唯一约束成功返回 true,冲突返回 false */
claimConsumption(eventId: EventId, consumer: string): Promise<boolean>;
getDelivery(id: string): Promise<Delivery | undefined>;
updateDelivery(delivery: Delivery): Promise<void>;
scheduleRetry(deliveryId: string, delayMs: number): Promise<void>;
};
/** 触发器判定 —— app 注入 `trigger`(L7)的只读用例;返回命中的触发器 ID */
export type TriggerMatcher = { match(envelope: Envelope): Promise<string[]> };
/** 死信与无主兜底 —— app 注入 `message`(L2) */
export type MessageFallback = {
deadLetter(envelope: Envelope, error: string): Promise<void>;
unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise<void>;
};
+37
View File
@@ -0,0 +1,37 @@
# module: ledger
职责: 台账:三级寻址、通用事务接口、schema 自生长(最大化合并 / 最宽类型 / 只增不破坏)、
任务级草稿层与任务视图、写三分、数据冲突检查
不负责: 放行时机与落地预检的编排(见 release)、数据事件的投递与三级消费(见 event)、
动作意向的执行(见 toolbox / release)
公开出口: index.ts
- 类型:`LedgerService` `SystemWrite` `DraftWrite` `ReleaseWriteResult` `SchemaApplyResult`
`ObjectAddress` `MergedObject` `MergeNotice` `MergeConflict` `ConflictFinding`
- 错误:`DraftConflictError` `SchemaConflictError` `ObjectNotMaterializedError`
- 端口类型:`LedgerStore` `DraftStore` `EventPublisher` `Clock` `IdGenerator` `ObjectRef`
- 用例工厂:`createLedgerService(deps: LedgerServiceDeps): LedgerService`
- 纯函数(供 activity-registry 静态审复用):`mergeDeclarations` `planSchemaChanges`
`planIsNonDestructive` `widen` `overlay` `detectConflicts` `keyOf` `pickKey`
- adapter 工厂:`createLedgerStore(pool)` `createDraftStore(runner)` `tableNameOf`
`DRAFT_DDL` `DRAFT_TABLE`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db(LEDGER_SCHEMA)
port:`EventPublisher`(app 注入 event)、`Clock`、`IdGenerator`、`LedgerStore`、`DraftStore`
被依赖: channel, release, activity-registry, loop-engine, flow-engine, task, trigger,
app(治理台 · 数据管理路由)
不变量:
- **写三分是三个方法**:`systemWrite` / `draftWrite` / `releaseWrite` —— 无第四条写入路径,
调用方拿不到带 flag 的「绕门」形态(§12.5#9)
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
- 合并只增不破坏:`planSchemaChanges` 永不产出删列 / 缩窄;主键一经建立不得由声明变更
- 同名异型取能兼容双方的最宽类型(integer→number→text;date→timestamp→text);
`json` 与标量语义互斥 —— 拦下,交数据管理裁决
- 脱敏取或:任一声明要求脱敏即脱敏(安全方向单调)
- `ledger` schema 的唯一写者是本模块 adapter;SQL 拼接只在 `ledger-store.ts`,
标识符过 `IDENT` 正则 + `quoteIdent`
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,205 @@
/**
* ledger 集成测试 —— 对真实 PG 断言 schema 自生长与写三分(编码规范 §1 / §7.2)。
* 系统内部机制不得 mock:这里跑的是真 DDL、真 DML、真事务。
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Pool } from 'pg';
import type { EnvelopeDraft, ObjectDeclaration, TaskId } from '@contracts';
import { startItDatabase } from '@testing';
import type { ItDatabase } from '@testing';
import { createDraftStore, createLedgerStore, DRAFT_DDL } from '../internal/adapter/ledger-store.ts';
import { createLedgerService } from '../internal/usecase/ledger-service.ts';
import { DraftConflictError } from '../contract.ts';
import type { LedgerService } from '../contract.ts';
// 顶层 await:可用性必须在**收集期**就定下来,否则 it.skip 的判定早于 beforeAll
const database: ItDatabase | undefined = await startItDatabase();
let pool: Pool | undefined;
let ledger: LedgerService;
let published: EnvelopeDraft[] = [];
const DOMAIN = 'it_quote';
const OBJECT = 'intake';
const T1 = 't-it-1' as TaskId;
const T2 = 't-it-2' as TaskId;
const declaration = (fields: ObjectDeclaration['fields']): ObjectDeclaration => ({
domain: DOMAIN,
object: OBJECT,
fields,
});
beforeAll(async () => {
if (database === undefined) return;
pool = new Pool({ connectionString: database.url, max: 4 });
await pool.query(`drop table if exists ledger."${DOMAIN}__${OBJECT}"`);
await pool.query(DRAFT_DDL);
await pool.query(`delete from ledger.__draft where task_id in ($1, $2)`, [T1, T2]);
let counter = 0;
ledger = createLedgerService({
store: createLedgerStore(pool),
drafts: createDraftStore(pool),
events: {
publish: async (envelope) => {
published.push(envelope);
},
},
clock: { now: () => new Date('2026-07-27T12:00:00Z') },
ids: { next: () => `it-${(counter += 1)}` },
});
}, 120_000);
afterAll(async () => {
if (pool !== undefined) {
await pool.query(`drop table if exists ledger."${DOMAIN}__${OBJECT}"`).catch(() => undefined);
await pool.query(`delete from ledger.__draft where task_id in ($1, $2)`, [T1, T2]).catch(() => undefined);
await pool.end();
}
await database?.stop();
});
const itDb = () => (database === undefined ? it.skip : it);
describe('ledger · 真实 PG', () => {
itDb()('声明 → 建表;再声明 → 加列与加宽,只增不破坏', async () => {
const first = await ledger.applyDeclarations([
declaration([
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'eau', type: 'integer', primaryKey: false, masked: false },
]),
]);
expect(first[0]?.applied).toBe(true);
const described = await ledger.describe({ domain: DOMAIN, object: OBJECT });
expect(described?.primaryKey).toEqual(['pn']);
expect(described?.columns.map((c) => c.name).sort()).toEqual(['eau', 'pn']);
// 第二个活动声明:多一个字段,且 eau 用更宽的 number
const second = await ledger.applyDeclarations([
declaration([
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
{ name: 'eau', type: 'number', primaryKey: false, masked: false },
{ name: 'grade', type: 'text', primaryKey: false, masked: false },
]),
]);
expect(second[0]?.applied).toBe(true);
expect(second[0]?.notices.some((n) => n.kind === 'type-widened')).toBe(true);
const after = await ledger.describe({ domain: DOMAIN, object: OBJECT });
expect(after?.columns.map((c) => c.name).sort()).toEqual(['eau', 'grade', 'pn']);
expect(after?.columns.find((c) => c.name === 'eau')?.type).toBe('number');
}, 60_000);
itDb()('系统写:落正式版并即时抛数据事件', async () => {
published = [];
await ledger.systemWrite({
address: { domain: DOMAIN, object: OBJECT },
key: { pn: 'LM358' },
values: { eau: 1000, grade: 'A' },
cause: { kind: 'inbound', ref: 'mail-1' },
});
expect(published).toHaveLength(1);
expect(published[0]?.type).toBe('data');
const page = await ledger.query({
address: { domain: DOMAIN, object: OBJECT },
where: {},
orderBy: [],
limit: 50,
offset: 0,
});
expect(page.rows).toHaveLength(1);
expect(page.rows[0]).toMatchObject({ pn: 'LM358', grade: 'A' });
}, 60_000);
itDb()('草稿写:读己之写,正式版不可见,零事件', async () => {
published = [];
await ledger.draftWrite({
taskId: T1,
address: { domain: DOMAIN, object: OBJECT },
key: { pn: 'LM358' },
patch: { grade: 'C' },
});
expect(published).toHaveLength(0);
const view = await ledger.taskView(T1, {
address: { domain: DOMAIN, object: OBJECT },
where: {},
orderBy: [],
limit: 50,
offset: 0,
});
expect(view.rows[0]).toMatchObject({ pn: 'LM358', grade: 'C' });
// 正式版仍是 A —— 别的任务只见过门的事实
const official = await ledger.query({
address: { domain: DOMAIN, object: OBJECT },
where: {},
orderBy: [],
limit: 50,
offset: 0,
});
expect(official.rows[0]).toMatchObject({ grade: 'A' });
// 另一任务的任务视图也看不见 T1 的草稿
const otherView = await ledger.taskView(T2, {
address: { domain: DOMAIN, object: OBJECT },
where: {},
orderBy: [],
limit: 50,
offset: 0,
});
expect(otherView.rows[0]).toMatchObject({ grade: 'A' });
}, 60_000);
itDb()('放行写:原子合并入正式版,此刻才抛事件,草稿清空', async () => {
published = [];
const result = await ledger.releaseWrite(T1);
expect(result.mergedRows).toBe(1);
expect(published).toHaveLength(1);
const official = await ledger.query({
address: { domain: DOMAIN, object: OBJECT },
where: {},
orderBy: [],
limit: 50,
offset: 0,
});
expect(official.rows[0]).toMatchObject({ grade: 'C' });
// 草稿已清空:再放行是空操作
expect(await ledger.releaseWrite(T1)).toEqual({ mergedRows: 0, emittedEvents: 0 });
}, 60_000);
itDb()('数据冲突检查:他单已改同一字段 → 放行写打回,不做部分合并', async () => {
await ledger.draftWrite({
taskId: T2,
address: { domain: DOMAIN, object: OBJECT },
key: { pn: 'LM358' },
patch: { grade: 'B' },
});
// 他单(系统写)抢先改了同一字段
await ledger.systemWrite({
address: { domain: DOMAIN, object: OBJECT },
key: { pn: 'LM358' },
values: { grade: 'D' },
cause: { kind: 'engine', ref: 'other-task' },
});
expect(await ledger.checkConflicts(T2)).toHaveLength(1);
await expect(ledger.releaseWrite(T2)).rejects.toBeInstanceOf(DraftConflictError);
// 正式版没被部分改动
const official = await ledger.query({
address: { domain: DOMAIN, object: OBJECT },
where: {},
orderBy: [],
limit: 50,
offset: 0,
});
expect(official.rows[0]).toMatchObject({ grade: 'D' });
}, 60_000);
});
@@ -0,0 +1,219 @@
/**
* schema 自生长 —— 零 mock 全覆盖(编码规范 §7.2 明令)。
*/
import { describe, expect, it } from 'vitest';
import type { LedgerFieldType, ObjectDeclaration } from '@contracts';
import {
mergeDeclarations,
planIsNonDestructive,
planSchemaChanges,
widen,
} from '../internal/domain/schema-merge.ts';
import type { MaterializedObject } from '../internal/domain/schema-merge.ts';
const f = (
name: string,
type: LedgerFieldType,
extra: Partial<{ primaryKey: boolean; masked: boolean; label: string }> = {},
) => ({
name,
type,
primaryKey: extra.primaryKey ?? false,
masked: extra.masked ?? false,
...(extra.label === undefined ? {} : { label: extra.label }),
});
const decl = (fields: ReturnType<typeof f>[]): ObjectDeclaration => ({
domain: 'quote',
object: 'intake',
fields,
});
describe('widen —— 类型格', () => {
it('同型不加宽', () => {
expect(widen('text', 'text')).toEqual({ ok: true, type: 'text', widened: false });
});
it('integer + number → number', () => {
expect(widen('integer', 'number')).toEqual({ ok: true, type: 'number', widened: true });
expect(widen('number', 'integer')).toEqual({ ok: true, type: 'number', widened: true });
});
it('date + timestamp → timestamp', () => {
expect(widen('date', 'timestamp')).toEqual({ ok: true, type: 'timestamp', widened: true });
});
it('跨链落到 text', () => {
expect(widen('number', 'boolean')).toEqual({ ok: true, type: 'text', widened: true });
expect(widen('date', 'integer')).toEqual({ ok: true, type: 'text', widened: true });
});
it('json 与标量语义互斥,拦下', () => {
expect(widen('json', 'text').ok).toBe(false);
expect(widen('number', 'json').ok).toBe(false);
});
it('json + json 可合', () => {
expect(widen('json', 'json')).toEqual({ ok: true, type: 'json', widened: false });
});
it('加宽可交换', () => {
const types: LedgerFieldType[] = ['text', 'number', 'integer', 'boolean', 'date', 'timestamp'];
for (const a of types)
for (const b of types) {
expect(widen(a, b)).toEqual(widen(b, a));
}
});
});
describe('mergeDeclarations —— 最大化合并', () => {
it('空声明集返回 undefined', () => {
expect(mergeDeclarations([]).merged).toBeUndefined();
});
it('并集取全部字段,首个声明定顺序', () => {
const r = mergeDeclarations([
decl([f('pn', 'text', { primaryKey: true }), f('eau', 'integer')]),
decl([f('pn', 'text', { primaryKey: true }), f('target_price', 'number')]),
]);
expect(r.merged?.fields.map((x) => x.name)).toEqual(['pn', 'eau', 'target_price']);
expect(r.conflicts).toEqual([]);
});
it('同名异型取最宽类型,并留静态审提示', () => {
const r = mergeDeclarations([decl([f('eau', 'integer')]), decl([f('eau', 'number')])]);
expect(r.merged?.fields[0]?.type).toBe('number');
expect(r.notices).toHaveLength(1);
expect(r.notices[0]?.kind).toBe('type-widened');
expect(r.conflicts).toEqual([]);
});
it('互斥类型拦下报错', () => {
const r = mergeDeclarations([decl([f('spec', 'json')]), decl([f('spec', 'text')])]);
expect(r.conflicts).toHaveLength(1);
expect(r.conflicts[0]?.kind).toBe('type-incompatible');
});
it('主键取或', () => {
const r = mergeDeclarations([decl([f('pn', 'text')]), decl([f('pn', 'text', { primaryKey: true })])]);
expect(r.merged?.primaryKey).toEqual(['pn']);
});
it('脱敏取或,方向单调', () => {
const r = mergeDeclarations([decl([f('phone', 'text')]), decl([f('phone', 'text', { masked: true })])]);
expect(r.merged?.fields[0]?.masked).toBe(true);
expect(r.notices.some((n) => n.kind === 'masked-propagated')).toBe(true);
});
it('名称冲突保留首个声明并提示', () => {
const r = mergeDeclarations([
decl([f('pn', 'text', { label: '料号' })]),
decl([f('pn', 'text', { label: '型号' })]),
]);
expect(r.merged?.fields[0]?.label).toBe('料号');
expect(r.notices.some((n) => n.kind === 'label-kept-first')).toBe(true);
});
it('合并结果与声明顺序无关(类型与主键部分)', () => {
const a = decl([f('x', 'integer'), f('y', 'date')]);
const b = decl([f('x', 'number', { primaryKey: true }), f('y', 'timestamp')]);
const ab = mergeDeclarations([a, b]).merged;
const ba = mergeDeclarations([b, a]).merged;
expect(ab?.fields.find((v) => v.name === 'x')?.type).toBe(ba?.fields.find((v) => v.name === 'x')?.type);
expect(ab?.primaryKey).toEqual(ba?.primaryKey);
});
it('拒绝混入别的对象', () => {
expect(() =>
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', fields: [f('a', 'text')] }]),
).toThrow(/只接受同一对象/);
});
});
describe('planSchemaChanges —— 只增不破坏', () => {
const target = mergeDeclarations([
decl([f('pn', 'text', { primaryKey: true }), f('eau', 'number'), f('grade', 'text')]),
]).merged!;
it('未物化 → 建表', () => {
const plan = planSchemaChanges(target, undefined);
expect(plan.changes).toHaveLength(1);
expect(plan.changes[0]?.kind).toBe('create-table');
expect(planIsNonDestructive(plan)).toBe(true);
});
it('已物化缺列 → 加列', () => {
const current: MaterializedObject = {
domain: 'quote',
object: 'intake',
columns: [
{ name: 'pn', type: 'text' },
{ name: 'eau', type: 'number' },
],
primaryKey: ['pn'],
};
const plan = planSchemaChanges(target, current);
expect(plan.changes).toEqual([
{ kind: 'add-column', field: expect.objectContaining({ name: 'grade' }) },
]);
});
it('已物化窄类型 → 加宽,不缩窄', () => {
const current: MaterializedObject = {
domain: 'quote',
object: 'intake',
columns: [
{ name: 'pn', type: 'text' },
{ name: 'eau', type: 'integer' },
{ name: 'grade', type: 'text' },
],
primaryKey: ['pn'],
};
const plan = planSchemaChanges(target, current);
expect(plan.changes).toHaveLength(1);
expect(plan.changes[0]).toMatchObject({ kind: 'widen-column', from: 'integer' });
expect(planIsNonDestructive(plan)).toBe(true);
});
it('已物化更宽 → 不产出任何变更(声明永不缩表)', () => {
const current: MaterializedObject = {
domain: 'quote',
object: 'intake',
columns: [
{ name: 'pn', type: 'text' },
{ name: 'eau', type: 'text' },
{ name: 'grade', type: 'text' },
],
primaryKey: ['pn'],
};
expect(planSchemaChanges(target, current).changes).toEqual([]);
});
it('声明试图新增主键字段 → 拦下', () => {
const twoKeys = mergeDeclarations([
decl([f('pn', 'text', { primaryKey: true }), f('rev', 'text', { primaryKey: true })]),
]).merged!;
const current: MaterializedObject = {
domain: 'quote',
object: 'intake',
columns: [
{ name: 'pn', type: 'text' },
{ name: 'rev', type: 'text' },
],
primaryKey: ['pn'],
};
const plan = planSchemaChanges(twoKeys, current);
expect(plan.conflicts[0]?.kind).toBe('primary-key-divergent');
});
it('互斥类型在差分阶段也拦下', () => {
const jsonTarget = mergeDeclarations([decl([f('spec', 'json')])]).merged!;
const current: MaterializedObject = {
domain: 'quote',
object: 'intake',
columns: [{ name: 'spec', type: 'text' }],
primaryKey: [],
};
expect(planSchemaChanges(jsonTarget, current).conflicts[0]?.kind).toBe('type-incompatible');
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type { LedgerRow } from '@contracts';
import { detectConflicts, keyOf, overlay, pickKey } from '../internal/domain/task-view.ts';
import type { DraftEntry } from '../internal/domain/task-view.ts';
const PK = ['pn'];
const draft = (pn: string, patch: LedgerRow, baseline: LedgerRow = {}, tombstone = false): DraftEntry => ({
key: { pn },
patch,
baseline,
tombstone,
});
describe('keyOf —— 主键规范化', () => {
it('字段顺序不影响键', () => {
expect(keyOf({ a: 1, b: 'x' })).toBe(keyOf({ b: 'x', a: 1 }));
});
});
describe('pickKey', () => {
it('只取主键字段', () => {
expect(pickKey({ pn: 'LM358', eau: 1000 }, PK)).toEqual({ pn: 'LM358' });
});
it('缺主键字段时跳过', () => {
expect(pickKey({ eau: 1000 }, PK)).toEqual({});
});
});
describe('overlay —— 任务视图 = 正式版 + 本任务草稿层', () => {
const official: LedgerRow[] = [
{ pn: 'LM358', eau: 1000, grade: 'A' },
{ pn: 'NE555', eau: 500, grade: 'B' },
];
it('无草稿时视图 = 正式版', () => {
expect(overlay(official, [], PK)).toEqual(official);
});
it('读己之写:草稿字段覆盖正式版', () => {
const rows = overlay(official, [draft('LM358', { grade: 'C' })], PK);
expect(rows[0]).toEqual({ pn: 'LM358', eau: 1000, grade: 'C' });
expect(rows[1]).toEqual(official[1]);
});
it('草稿新增的行出现在视图里', () => {
const rows = overlay(official, [draft('TL072', { eau: 200, grade: 'B' })], PK);
expect(rows).toHaveLength(3);
expect(rows[2]).toEqual({ pn: 'TL072', eau: 200, grade: 'B' });
});
it('tombstone 的行从视图中消失', () => {
const rows = overlay(official, [draft('NE555', {}, {}, true)], PK);
expect(rows.map((r) => r['pn'])).toEqual(['LM358']);
});
it('tombstone 的新增行不出现', () => {
const rows = overlay(official, [draft('XX', { eau: 1 }, {}, true)], PK);
expect(rows).toHaveLength(2);
});
});
describe('detectConflicts —— 落地预检第五项', () => {
it('基线与正式版一致 → 无冲突', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', grade: 'A' }]]);
expect(detectConflicts([draft('LM358', { grade: 'C' }, { grade: 'A' })], officialByKey)).toEqual([]);
});
it('他单已改同一字段 → 冲突', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', grade: 'B' }]]);
const findings = detectConflicts([draft('LM358', { grade: 'C' }, { grade: 'A' })], officialByKey);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({ field: 'grade', baseline: 'A', official: 'B', draft: 'C' });
});
it('没碰的字段被别人改了不算冲突', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', grade: 'A', eau: 2000 }]]);
expect(detectConflicts([draft('LM358', { grade: 'C' }, { grade: 'A' })], officialByKey)).toEqual([]);
});
it('草稿新增的行无从冲突', () => {
expect(detectConflicts([draft('NEW', { grade: 'C' }, {})], new Map())).toEqual([]);
});
it('null 与 undefined 视为同值', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', grade: null }]]);
expect(detectConflicts([draft('LM358', { grade: 'C' }, { grade: undefined })], officialByKey)).toEqual([]);
});
it('数字与其字符串形态视为同值(PG 回读的宽容口径)', () => {
const officialByKey = new Map([[keyOf({ pn: 'LM358' }), { pn: 'LM358', eau: '1000' }]]);
expect(detectConflicts([draft('LM358', { eau: 2000 }, { eau: 1000 })], officialByKey)).toEqual([]);
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* module: ledger —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
*/
import type {
LedgerAddress,
LedgerKey,
LedgerPage,
LedgerQuery,
LedgerRow,
ObjectDeclaration,
TaskId,
WriteKind,
} from '@contracts';
import type { MaterializedObject, MergeConflict, MergeNotice, MergedObject } from './internal/domain/schema-merge.ts';
import type { ConflictFinding } from './internal/domain/task-view.ts';
export type { LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, ObjectDeclaration, WriteKind };
export type { MaterializedObject, MergeConflict, MergeNotice, MergedObject, ConflictFinding };
export class DraftConflictError extends Error {
constructor(readonly findings: readonly ConflictFinding[]) {
super(`草稿与正式版并发冲突:${findings.length} 处字段已被他单改动`);
this.name = 'DraftConflictError';
}
}
export class SchemaConflictError extends Error {
constructor(readonly conflicts: readonly MergeConflict[]) {
super(`台账声明语义互斥,交数据管理裁决:${conflicts.map((c) => `${c.field}(${c.message})`).join('; ')}`);
this.name = 'SchemaConflictError';
}
}
export class ObjectNotMaterializedError extends Error {
constructor(readonly address: string) {
super(`台账对象未物化:${address}`);
this.name = 'ObjectNotMaterializedError';
}
}
/** 声明合并与物化的结果 —— 安装静态审读它:conflicts 非空即不上架 */
export type SchemaApplyResult = {
readonly merged: MergedObject | undefined;
readonly notices: readonly MergeNotice[];
readonly conflicts: readonly MergeConflict[];
readonly applied: boolean;
};
export type ObjectAddress = { readonly domain: string; readonly object: string };
export type SystemWrite = {
readonly address: ObjectAddress;
readonly key: LedgerKey;
readonly values: LedgerRow;
/** 归因:哪条入站 / 哪次流转产生了这次系统写 */
readonly cause: { readonly kind: 'inbound' | 'engine' | 'governance'; readonly ref: string };
/** 系统写也可挂到任务上(入站归一到某任务),数据事件的目标即它 */
readonly taskId?: TaskId;
};
export type DraftWrite = {
readonly taskId: TaskId;
readonly address: ObjectAddress;
readonly key: LedgerKey;
readonly patch: LedgerRow;
readonly tombstone?: boolean;
};
export type ReleaseWriteResult = {
readonly mergedRows: number;
readonly emittedEvents: number;
};
/**
* 写三分是**三个不同方法**,不是一个带 flag 的方法 —— 调用方拿不到「绕门」的形态(§7.2)。
* systemWrite —— 登记世界已发生的事实,不过放行门,写正式版并**即时**抛数据事件
* draftWrite —— 任务级草稿层,读己之写,**不抛**事件
* releaseWrite —— 放行通过,草稿合并入正式版,**此刻**抛数据事件;原子且过冲突检查
*/
export type LedgerService = {
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectAddress[]>;
systemWrite(write: SystemWrite): Promise<void>;
draftWrite(write: DraftWrite): Promise<void>;
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
/** 正式版查询 —— 跨任务只见过门的事实 */
query(query: LedgerQuery): Promise<LedgerPage>;
/** 任务视图 = 正式版 + 本任务草稿层 */
taskView(taskId: TaskId, query: LedgerQuery): Promise<LedgerPage>;
/** 落地预检第五项:数据冲突检查 */
checkConflicts(taskId: TaskId): Promise<ConflictFinding[]>;
discardDrafts(taskId: TaskId): Promise<void>;
};
+47
View File
@@ -0,0 +1,47 @@
/**
* module: ledger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ConflictFinding,
DraftWrite,
LedgerAddress,
LedgerKey,
LedgerPage,
LedgerQuery,
LedgerRow,
LedgerService,
MaterializedObject,
MergeConflict,
MergeNotice,
MergedObject,
ObjectAddress,
ObjectDeclaration,
ReleaseWriteResult,
SchemaApplyResult,
SystemWrite,
WriteKind,
} from './contract.ts';
export { DraftConflictError, ObjectNotMaterializedError, SchemaConflictError } from './contract.ts';
export type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from './port.ts';
export type { LedgerServiceDeps } from './internal/usecase/ledger-service.ts';
export { createLedgerService } from './internal/usecase/ledger-service.ts';
export type {
MergedField,
SchemaChange,
SchemaPlan,
WidenResult,
} from './internal/domain/schema-merge.ts';
export {
mergeDeclarations,
planIsNonDestructive,
planSchemaChanges,
widen,
} from './internal/domain/schema-merge.ts';
export type { DraftEntry } from './internal/domain/task-view.ts';
export { detectConflicts, keyOf, overlay, pickKey } from './internal/domain/task-view.ts';
export { createDraftStore, createLedgerStore, DRAFT_DDL, DRAFT_TABLE, tableNameOf } from './internal/adapter/ledger-store.ts';
@@ -0,0 +1,303 @@
/**
* 台账存储 adapter —— node-postgres 直写(编码规范 §1 / ADR-002)。
*
* 运行时 DDL/DML 由声明生成,ORM 静态 schema 不适用;`ledger` schema 的**唯一写者**就是本文件。
* 标识符全部来自 @contracts 的 identifier 正则(小写字母开头、字母数字下划线),
* 落库前再过一道 `quoteIdent` —— 拼接 SQL 的地方只有这里。
*/
import type { Pool, PoolClient } from 'pg';
import type { LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
import { LEDGER_SCHEMA } from '@shared/db.ts';
import type { DraftStore, LedgerStore, ObjectRef } from '../../port.ts';
import type { MaterializedObject, MergedField } from '../domain/schema-merge.ts';
import type { DraftEntry } from '../domain/task-view.ts';
import { keyOf } from '../domain/task-view.ts';
const IDENT = /^[a-z][a-z0-9_]*$/;
function quoteIdent(name: string): string {
if (!IDENT.test(name)) throw new Error(`非法标识符:${name}`);
return `"${name}"`;
}
/** 物理表名:<域>__<对象>;域与对象都过 IDENT,拼接安全 */
export function tableNameOf(ref: ObjectRef): string {
if (!IDENT.test(ref.domain) || !IDENT.test(ref.object)) {
throw new Error(`非法台账地址:${ref.domain}.${ref.object}`);
}
return `${ref.domain}__${ref.object}`;
}
const PG_TYPE: Readonly<Record<MergedField['type'], string>> = {
text: 'text',
number: 'double precision',
integer: 'bigint',
boolean: 'boolean',
date: 'date',
timestamp: 'timestamptz',
json: 'jsonb',
};
const FROM_PG: Readonly<Record<string, MergedField['type']>> = {
text: 'text',
'character varying': 'text',
'double precision': 'number',
numeric: 'number',
bigint: 'integer',
integer: 'integer',
boolean: 'boolean',
date: 'date',
'timestamp with time zone': 'timestamp',
'timestamp without time zone': 'timestamp',
jsonb: 'json',
json: 'json',
};
type Queryable = Pick<Pool | PoolClient, 'query'>;
export function createLedgerStore(pool: Pool): LedgerStore {
return buildStore(pool, pool);
}
function buildStore(runner: Queryable, pool: Pool): LedgerStore {
const store: LedgerStore = {
async describe(ref) {
const table = tableNameOf(ref);
const cols = await runner.query<{ column_name: string; data_type: string }>(
`select column_name, data_type from information_schema.columns
where table_schema = $1 and table_name = $2 order by ordinal_position`,
[LEDGER_SCHEMA, table],
);
if (cols.rows.length === 0) return undefined;
const pk = await runner.query<{ attname: string }>(
`select a.attname from pg_index i
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
where i.indrelid = $1::regclass and i.indisprimary`,
[`${LEDGER_SCHEMA}.${quoteIdent(table)}`],
);
const materialized: MaterializedObject = {
domain: ref.domain,
object: ref.object,
columns: cols.rows.map((c) => ({ name: c.column_name, type: FROM_PG[c.data_type] ?? 'text' })),
primaryKey: pk.rows.map((p) => p.attname),
};
return materialized;
},
async listObjects() {
const res = await runner.query<{ table_name: string }>(
`select table_name from information_schema.tables
where table_schema = $1 and table_name not like '\\_\\_%' order by table_name`,
[LEDGER_SCHEMA],
);
return res.rows.flatMap((r) => {
const idx = r.table_name.indexOf('__');
if (idx <= 0) return [];
return [{ domain: r.table_name.slice(0, idx), object: r.table_name.slice(idx + 2) }];
});
},
async applyChanges(ref, changes) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
for (const change of changes) {
switch (change.kind) {
case 'create-table': {
const cols = change.object.fields.map((f) => `${quoteIdent(f.name)} ${PG_TYPE[f.type]}`);
const pk =
change.object.primaryKey.length > 0
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
: '';
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
break;
}
case 'add-column':
await runner.query(
`alter table ${table} add column if not exists ${quoteIdent(change.field.name)} ${PG_TYPE[change.field.type]}`,
);
break;
case 'widen-column':
await runner.query(
`alter table ${table} alter column ${quoteIdent(change.field.name)} type ${PG_TYPE[change.field.type]} ` +
`using ${quoteIdent(change.field.name)}::${PG_TYPE[change.field.type]}`,
);
break;
}
}
},
async select(query: LedgerQuery) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(query.address))}`;
const params: unknown[] = [];
const clauses = Object.entries(query.where).map(([field, value]) => {
params.push(value);
return `${quoteIdent(field)} = $${params.length}`;
});
const where = clauses.length > 0 ? ` where ${clauses.join(' and ')}` : '';
const order =
query.orderBy.length > 0
? ` order by ${query.orderBy.map((o) => `${quoteIdent(o.field)} ${o.dir === 'desc' ? 'desc' : 'asc'}`).join(', ')}`
: '';
const countRes = await runner.query<{ n: string }>(`select count(*)::text as n from ${table}${where}`, params);
params.push(query.limit, query.offset);
const rows = await runner.query<LedgerRow>(
`select * from ${table}${where}${order} limit $${params.length - 1} offset $${params.length}`,
params,
);
return { rows: rows.rows, total: Number(countRes.rows[0]?.n ?? '0') };
},
async selectByKeys(ref, keys) {
if (keys.length === 0) return [];
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
const out: LedgerRow[] = [];
for (const key of keys) {
const params: unknown[] = [];
const clauses = Object.entries(key).map(([field, value]) => {
params.push(value);
return `${quoteIdent(field)} = $${params.length}`;
});
if (clauses.length === 0) continue;
const res = await runner.query<LedgerRow>(
`select * from ${table} where ${clauses.join(' and ')} limit 1`,
params,
);
out.push(...res.rows);
}
return out;
},
async upsert(ref, key, values) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
const merged: LedgerRow = { ...key, ...values };
const fields = Object.keys(merged);
if (fields.length === 0) return;
const params = fields.map((f) => merged[f]);
const placeholders = fields.map((_, i) => `$${i + 1}`);
const keyFields = Object.keys(key);
const updatable = fields.filter((f) => !keyFields.includes(f));
const onConflict =
keyFields.length === 0
? ''
: updatable.length === 0
? ` on conflict (${keyFields.map(quoteIdent).join(', ')}) do nothing`
: ` on conflict (${keyFields.map(quoteIdent).join(', ')}) do update set ` +
updatable.map((f) => `${quoteIdent(f)} = excluded.${quoteIdent(f)}`).join(', ');
await runner.query(
`insert into ${table} (${fields.map(quoteIdent).join(', ')}) values (${placeholders.join(', ')})${onConflict}`,
params,
);
},
async deleteRow(ref, key) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
const params: unknown[] = [];
const clauses = Object.entries(key).map(([field, value]) => {
params.push(value);
return `${quoteIdent(field)} = $${params.length}`;
});
if (clauses.length === 0) return;
await runner.query(`delete from ${table} where ${clauses.join(' and ')}`, params);
},
async transaction(fn) {
const client = await pool.connect();
try {
await client.query('begin');
const scoped = buildStore(client, pool);
const result = await fn(scoped, createDraftStore(client));
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
},
};
return store;
}
/* ── 任务级草稿层:ledger.__draft ─────────────────────────────────────── */
export const DRAFT_TABLE = `${LEDGER_SCHEMA}.__draft`;
export const DRAFT_DDL = `
create table if not exists ${DRAFT_TABLE} (
task_id text not null,
domain text not null,
object text not null,
key_hash text not null,
key jsonb not null,
patch jsonb not null,
baseline jsonb not null,
tombstone boolean not null default false,
updated_at timestamptz not null default now(),
primary key (task_id, domain, object, key_hash)
);`;
type DraftRow = {
task_id: string;
domain: string;
object: string;
key: Record<string, string | number>;
patch: LedgerRow;
baseline: LedgerRow;
tombstone: boolean;
};
function toEntry(row: DraftRow): DraftEntry & ObjectRef {
return {
domain: row.domain,
object: row.object,
key: row.key,
patch: row.patch,
baseline: row.baseline,
tombstone: row.tombstone,
};
}
export function createDraftStore(runner: Queryable): DraftStore {
return {
async list(taskId) {
const res = await runner.query<DraftRow>(
`select * from ${DRAFT_TABLE} where task_id = $1 order by updated_at`,
[taskId],
);
return res.rows.map(toEntry);
},
async listForObject(taskId, ref) {
const res = await runner.query<DraftRow>(
`select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by updated_at`,
[taskId, ref.domain, ref.object],
);
return res.rows.map(toEntry);
},
async put(taskId, ref, entry) {
await runner.query(
`insert into ${DRAFT_TABLE} (task_id, domain, object, key_hash, key, patch, baseline, tombstone)
values ($1,$2,$3,$4,$5,$6,$7,$8)
on conflict (task_id, domain, object, key_hash) do update
set patch = ${DRAFT_TABLE}.patch || excluded.patch,
tombstone = excluded.tombstone,
updated_at = now()`,
[
taskId,
ref.domain,
ref.object,
keyOf(entry.key as LedgerKey),
JSON.stringify(entry.key),
JSON.stringify(entry.patch),
JSON.stringify(entry.baseline),
entry.tombstone,
],
);
},
async clear(taskId) {
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
},
};
}
@@ -0,0 +1,267 @@
/**
* 台账 schema 自生长 —— 纯算法,零 IO(编码规范 §7.2:落此文件,零 mock 全覆盖测试)。
*
* 语义出处 OS 04 / AM 11.1:
* 声明的是**活动要用什么字段**,不是对象该定义什么字段;
* 对象的实际 schema = 全部使用声明的**最大化合并**(并集);
* 定义**只增不破坏**:声明永不缩表、不删改已有字段;
* 同名字段类型不一致时,以**能兼容双方的最宽类型**落地(放宽即增,不破坏),静态审提示、不阻塞;
* **语义互斥**不可兼容的极端情形才拦下,交数据管理裁决。
*/
import type { FieldDeclaration, LedgerFieldType, ObjectDeclaration } from '@contracts';
/* ── 类型格:widen(a, b) = 能同时容纳 a 与 b 的最窄类型 ────────────────── */
/**
* 标量的加宽链:
* integer → number → text
* date → timestamp → text
* boolean → text
* `json` 是容器类型,与任何标量**语义互斥** —— 不加宽,直接拦下。
*/
const SCALAR_CHAIN: Readonly<Record<LedgerFieldType, readonly LedgerFieldType[]>> = {
integer: ['integer', 'number', 'text'],
number: ['number', 'text'],
date: ['date', 'timestamp', 'text'],
timestamp: ['timestamp', 'text'],
boolean: ['boolean', 'text'],
text: ['text'],
json: ['json'],
};
export type WidenResult =
| { readonly ok: true; readonly type: LedgerFieldType; readonly widened: boolean }
| { readonly ok: false; readonly reason: string };
export function widen(a: LedgerFieldType, b: LedgerFieldType): WidenResult {
if (a === b) return { ok: true, type: a, widened: false };
if (a === 'json' || b === 'json') {
return { ok: false, reason: `json 与 ${a === 'json' ? b : a} 语义互斥,不可兼容` };
}
// 取两条链的最靠前的公共上界
for (const candidate of SCALAR_CHAIN[a]) {
if (SCALAR_CHAIN[b].includes(candidate)) {
return { ok: true, type: candidate, widened: true };
}
}
/* istanbul ignore next —— text 是标量的公共顶,理论不可达 */
return { ok: false, reason: `${a} 与 ${b} 无公共上界` };
}
/* ── 合并结果 ───────────────────────────────────────────────────────── */
export type MergedField = {
readonly name: string;
readonly type: LedgerFieldType;
readonly label: string | undefined;
readonly primaryKey: boolean;
readonly masked: boolean;
};
export type MergeNotice = {
readonly kind: 'type-widened' | 'masked-propagated' | 'label-kept-first';
readonly field: string;
readonly message: string;
};
export type MergeConflict = {
readonly kind: 'type-incompatible' | 'primary-key-divergent';
readonly field: string;
readonly message: string;
};
export type MergedObject = {
readonly domain: string;
readonly object: string;
readonly label: string | undefined;
readonly fields: readonly MergedField[];
readonly primaryKey: readonly string[];
};
export type MergeResult = {
readonly merged: MergedObject | undefined;
/** 静态审提示,不阻塞 */
readonly notices: readonly MergeNotice[];
/** 语义互斥 —— 拦下,交数据管理裁决 */
readonly conflicts: readonly MergeConflict[];
};
function mergeField(
acc: MergedField,
next: FieldDeclaration,
notices: MergeNotice[],
conflicts: MergeConflict[],
): MergedField {
const w = widen(acc.type, next.type);
if (!w.ok) {
conflicts.push({ kind: 'type-incompatible', field: acc.name, message: w.reason });
return acc;
}
if (w.widened) {
notices.push({
kind: 'type-widened',
field: acc.name,
message: `同名异型 ${acc.type} + ${next.type} → 取最宽类型 ${w.type}(放宽即增,不破坏)`,
});
}
// 脱敏取或:任一声明要求脱敏即脱敏(安全方向单调)
const masked = acc.masked || next.masked;
if (masked && !acc.masked) {
notices.push({ kind: 'masked-propagated', field: acc.name, message: '任一声明要求脱敏即脱敏' });
}
if (next.label !== undefined && acc.label !== undefined && next.label !== acc.label) {
notices.push({
kind: 'label-kept-first',
field: acc.name,
message: `字段名称冲突(${acc.label} / ${next.label}),保留首个声明`,
});
}
return {
name: acc.name,
type: w.type,
label: acc.label ?? next.label,
// 主键参与取或:任一声明视其为主键即入主键集
primaryKey: acc.primaryKey || next.primaryKey,
masked,
};
}
/**
* 最大化合并:把同一个「域.对象」的**全部**使用声明合成一份对象 schema。
* 声明顺序不影响结果(合并是可交换、可结合的)——除标签保留首个声明外。
*/
export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): MergeResult {
const notices: MergeNotice[] = [];
const conflicts: MergeConflict[] = [];
const first = declarations[0];
if (first === undefined) return { merged: undefined, notices, conflicts };
for (const d of declarations) {
if (d.domain !== first.domain || d.object !== first.object) {
throw new Error(`mergeDeclarations 只接受同一对象的声明:${d.domain}.${d.object} ≠ ${first.domain}.${first.object}`);
}
}
const byName = new Map<string, MergedField>();
const order: string[] = [];
for (const declaration of declarations) {
for (const field of declaration.fields) {
const existing = byName.get(field.name);
if (existing === undefined) {
order.push(field.name);
byName.set(field.name, {
name: field.name,
type: field.type,
label: field.label,
primaryKey: field.primaryKey,
masked: field.masked,
});
continue;
}
byName.set(field.name, mergeField(existing, field, notices, conflicts));
}
}
const fields = order.map((name) => byName.get(name)!);
const primaryKey = fields.filter((f) => f.primaryKey).map((f) => f.name);
return {
merged: {
domain: first.domain,
object: first.object,
label: declarations.find((d) => d.label !== undefined)?.label,
fields,
primaryKey,
},
notices,
conflicts,
};
}
/* ── 与已物化 schema 的差分:生成「只增不破坏」的变更计划 ─────────────── */
export type MaterializedColumn = {
readonly name: string;
readonly type: LedgerFieldType;
};
export type MaterializedObject = {
readonly domain: string;
readonly object: string;
readonly columns: readonly MaterializedColumn[];
readonly primaryKey: readonly string[];
};
export type SchemaChange =
| { readonly kind: 'create-table'; readonly object: MergedObject }
| { readonly kind: 'add-column'; readonly field: MergedField }
| { readonly kind: 'widen-column'; readonly field: MergedField; readonly from: LedgerFieldType };
export type SchemaPlan = {
readonly changes: readonly SchemaChange[];
readonly notices: readonly MergeNotice[];
readonly conflicts: readonly MergeConflict[];
};
/**
* 差分:目标 schema(合并结果)相对已物化 schema 的增量。
* **永不产出删列、缩窄或改主键的变更** —— 那三样是破坏,由数据管理的治理动作承担,不经声明发生。
*/
export function planSchemaChanges(
target: MergedObject,
current: MaterializedObject | undefined,
): SchemaPlan {
const notices: MergeNotice[] = [];
const conflicts: MergeConflict[] = [];
if (current === undefined) {
return { changes: [{ kind: 'create-table', object: target }], notices, conflicts };
}
// 主键一经建立不得由声明变更 —— 新增主键字段是破坏性变更
const currentPk = [...current.primaryKey].sort();
const targetPk = [...target.primaryKey].sort();
const addedPk = targetPk.filter((k) => !currentPk.includes(k));
if (addedPk.length > 0) {
conflicts.push({
kind: 'primary-key-divergent',
field: addedPk.join(','),
message: `主键已建立为 (${currentPk.join(', ')}),声明试图新增主键字段 ${addedPk.join(', ')} —— 只增不破坏,交数据管理裁决`,
});
}
const columns = new Map(current.columns.map((c) => [c.name, c]));
const changes: SchemaChange[] = [];
for (const field of target.fields) {
const existing = columns.get(field.name);
if (existing === undefined) {
changes.push({ kind: 'add-column', field });
continue;
}
const w = widen(existing.type, field.type);
if (!w.ok) {
conflicts.push({ kind: 'type-incompatible', field: field.name, message: w.reason });
continue;
}
if (w.type !== existing.type) {
notices.push({
kind: 'type-widened',
field: field.name,
message: `已物化 ${existing.type},声明需要 ${field.type} → 加宽为 ${w.type}`,
});
changes.push({ kind: 'widen-column', field: { ...field, type: w.type }, from: existing.type });
}
}
return { changes, notices, conflicts };
}
/** 声明只增不破坏的机检:计划里不得出现任何缩表动作 */
export function planIsNonDestructive(plan: SchemaPlan): boolean {
return plan.changes.every(
(c) => c.kind === 'create-table' || c.kind === 'add-column' || c.kind === 'widen-column',
);
}
@@ -0,0 +1,119 @@
/**
* 任务视图与草稿层叠加 —— 纯逻辑,零 IO(编码规范 §7.2 / §5.3)。
*
* 语义出处 OS 09 / AM 10.3:
* 任务视图 = 正式版 + 本任务草稿层(**读己之写**:本任务可见、正式版不可见、不产生数据事件);
* 环内推进读任务视图,不依赖事件;跨任务监听只认正式版事件。
*/
import type { LedgerKey, LedgerRow } from '@contracts';
export type DraftEntry = {
readonly key: LedgerKey;
readonly patch: LedgerRow;
readonly tombstone: boolean;
/** 草稿建立时读到的基线值 —— 放行前的数据冲突检查用 */
readonly baseline: LedgerRow;
};
/** 主键的规范化串:字段名排序后拼接,保证同一行的键恒等 */
export function keyOf(key: LedgerKey): string {
return Object.keys(key)
.sort()
.map((k) => `${k}=${String(key[k])}`)
.join('|');
}
/**
* 叠加:正式版行 + 本任务草稿 patch → 任务视图行。
* 草稿新增的行(正式版没有)也出现在视图里;tombstone 的行从视图中消失。
*/
export function overlay(
official: readonly LedgerRow[],
drafts: readonly DraftEntry[],
primaryKey: readonly string[],
): LedgerRow[] {
const draftByKey = new Map(drafts.map((d) => [keyOf(d.key), d]));
const seen = new Set<string>();
const out: LedgerRow[] = [];
for (const row of official) {
const k = keyOf(pickKey(row, primaryKey));
seen.add(k);
const draft = draftByKey.get(k);
if (draft === undefined) {
out.push(row);
continue;
}
if (draft.tombstone) continue;
out.push({ ...row, ...draft.patch });
}
for (const draft of drafts) {
const k = keyOf(draft.key);
if (seen.has(k) || draft.tombstone) continue;
out.push({ ...draft.key, ...draft.patch });
}
return out;
}
export function pickKey(row: LedgerRow, primaryKey: readonly string[]): LedgerKey {
const key: Record<string, string | number> = {};
for (const field of primaryKey) {
const value = row[field];
if (typeof value === 'string' || typeof value === 'number') key[field] = value;
else if (value !== undefined && value !== null) key[field] = String(value);
}
return key;
}
/* ── 数据冲突检查(落地预检第五项,OS 7.3) ────────────────────────── */
export type ConflictFinding = {
readonly key: LedgerKey;
readonly field: string;
readonly baseline: unknown;
readonly official: unknown;
readonly draft: unknown;
};
/**
* 草稿层所改字段与正式版的并发冲突:**他单已改同一字段即预检不过**,
* 打回环内基于新正式版重拟。
*
* 判据:草稿建立时读到的基线值 ≠ 当前正式版值 ⇒ 冲突。
* 只检查草稿实际修改过的字段 —— 没碰的字段被别人改了不算冲突。
*/
export function detectConflicts(
drafts: readonly DraftEntry[],
officialByKey: ReadonlyMap<string, LedgerRow>,
): ConflictFinding[] {
const findings: ConflictFinding[] = [];
for (const draft of drafts) {
const official = officialByKey.get(keyOf(draft.key));
if (official === undefined) continue; // 草稿新增的行,无从冲突
for (const field of Object.keys(draft.patch)) {
const baseline = draft.baseline[field];
const current = official[field];
if (!sameValue(baseline, current)) {
findings.push({
key: draft.key,
field,
baseline,
official: current,
draft: draft.patch[field],
});
}
}
}
return findings;
}
function sameValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || a === undefined) return b === null || b === undefined;
if (b === null || b === undefined) return false;
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
if (typeof a === 'object' || typeof b === 'object') return JSON.stringify(a) === JSON.stringify(b);
return String(a) === String(b);
}
@@ -0,0 +1,236 @@
/**
* ledger 用例 —— 编排 domain,依赖 port.ts,不 import 任何 adapter(编码规范 §5.3)。
*
* 写三分的不变量在此落实(OS 09):
* systemWrite → 正式版 + **即时**数据事件
* draftWrite → 草稿层,**零**事件
* releaseWrite → 冲突检查 → 原子合并 → **此刻**抛事件
*/
import type { EnvelopeDraft, LedgerPage, LedgerQuery, ObjectDeclaration, TaskId } from '@contracts';
import { eventIdSchema } from '@contracts';
import type {
DraftWrite,
LedgerService,
ObjectAddress,
ReleaseWriteResult,
SchemaApplyResult,
SystemWrite,
} from '../../contract.ts';
import { DraftConflictError, ObjectNotMaterializedError } from '../../contract.ts';
import type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from '../../port.ts';
import { mergeDeclarations, planIsNonDestructive, planSchemaChanges } from '../domain/schema-merge.ts';
import { detectConflicts, keyOf, overlay, pickKey } from '../domain/task-view.ts';
export type LedgerServiceDeps = {
readonly store: LedgerStore;
readonly drafts: DraftStore;
readonly events: EventPublisher;
readonly clock: Clock;
readonly ids: IdGenerator;
};
function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<string, ObjectDeclaration[]> {
const groups = new Map<string, ObjectDeclaration[]>();
for (const d of declarations) {
const key = `${d.domain}.${d.object}`;
const bucket = groups.get(key);
if (bucket === undefined) groups.set(key, [d]);
else bucket.push(d);
}
return groups;
}
export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
function dataEvent(
ref: ObjectRef,
key: Record<string, string | number>,
changed: readonly string[],
operation: 'insert' | 'update' | 'delete',
taskId: TaskId | undefined,
cause: { kind: 'ledger-write'; ref: string },
): EnvelopeDraft {
return {
id: eventIdSchema.parse(deps.ids.next()),
type: 'data',
correlation: {
// 未绑任务的系统写不指定消费目标 —— 三级消费由 `event` 判定(OS 6.3)
...(taskId === undefined ? {} : { target: { kind: 'task' as const, id: taskId } }),
cause,
keys: Object.fromEntries(Object.entries(key).map(([k, v]) => [k, String(v)])),
},
payload: {
data: {
domain: ref.domain,
object: ref.object,
primaryKey: key,
changed: [...changed],
operation,
},
},
occurredAt: deps.clock.now().toISOString(),
source: { kind: 'engine', detail: 'ledger' },
};
}
async function requireSchema(address: ObjectAddress) {
const described = await deps.store.describe(address);
if (described === undefined) throw new ObjectNotMaterializedError(`${address.domain}.${address.object}`);
return described;
}
return {
async applyDeclarations(declarations) {
const results: SchemaApplyResult[] = [];
for (const [, group] of groupDeclarations(declarations)) {
const first = group[0]!;
const ref: ObjectRef = { domain: first.domain, object: first.object };
const merge = mergeDeclarations(group);
if (merge.merged === undefined || merge.conflicts.length > 0) {
results.push({ merged: merge.merged, notices: merge.notices, conflicts: merge.conflicts, applied: false });
continue;
}
const current = await deps.store.describe(ref);
const plan = planSchemaChanges(merge.merged, current);
const conflicts = [...merge.conflicts, ...plan.conflicts];
if (conflicts.length > 0 || !planIsNonDestructive(plan)) {
results.push({ merged: merge.merged, notices: [...merge.notices, ...plan.notices], conflicts, applied: false });
continue;
}
if (plan.changes.length > 0) await deps.store.applyChanges(ref, plan.changes);
results.push({
merged: merge.merged,
notices: [...merge.notices, ...plan.notices],
conflicts: [],
applied: true,
});
}
return results;
},
async describe(address) {
return deps.store.describe(address);
},
async listObjects() {
return deps.store.listObjects();
},
/** ① 系统写:世界已发生的事实,免门,即时抛事件 */
async systemWrite(write: SystemWrite) {
await requireSchema(write.address);
await deps.store.upsert(write.address, write.key, write.values);
await deps.events.publish(
dataEvent(
write.address,
write.key,
Object.keys(write.values),
'update',
write.taskId,
{ kind: 'ledger-write', ref: `${write.cause.kind}:${write.cause.ref}` },
),
);
},
/** ② 草稿写:读己之写,正式版不可见,**不产生数据事件** */
async draftWrite(write: DraftWrite) {
const schema = await requireSchema(write.address);
const existing = await deps.store.selectByKeys(write.address, [write.key]);
const baseline: Record<string, unknown> = {};
const row = existing[0];
if (row !== undefined) {
for (const field of Object.keys(write.patch)) baseline[field] = row[field];
}
await deps.drafts.put(write.taskId, write.address, {
key: pickKey({ ...write.key }, schema.primaryKey.length > 0 ? schema.primaryKey : Object.keys(write.key)),
patch: write.patch,
tombstone: write.tombstone ?? false,
baseline,
});
},
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId);
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
const drafts = await deps.drafts.list(taskId);
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0 };
const pending: EnvelopeDraft[] = [];
await deps.store.transaction(async (store, draftStore) => {
for (const draft of drafts) {
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
if (draft.tombstone) {
await store.deleteRow(ref, draft.key);
pending.push(
dataEvent(ref, draft.key, [], 'delete', taskId, { kind: 'ledger-write', ref: `release:${taskId}` }),
);
continue;
}
const existing = await store.selectByKeys(ref, [draft.key]);
await store.upsert(ref, draft.key, draft.patch);
pending.push(
dataEvent(
ref,
draft.key,
Object.keys(draft.patch),
existing.length === 0 ? 'insert' : 'update',
taskId,
{ kind: 'ledger-write', ref: `release:${taskId}` },
),
);
}
await draftStore.clear(taskId);
});
for (const envelope of pending) await deps.events.publish(envelope);
return { mergedRows: drafts.length, emittedEvents: pending.length };
},
async query(query: LedgerQuery): Promise<LedgerPage> {
const { rows, total } = await deps.store.select(query);
return { rows, total };
},
async taskView(taskId, query): Promise<LedgerPage> {
const ref: ObjectRef = { domain: query.address.domain, object: query.address.object };
const schema = await requireSchema(ref);
const [official, drafts] = await Promise.all([
deps.store.select(query),
deps.drafts.listForObject(taskId, ref),
]);
const rows = overlay(official.rows, drafts, schema.primaryKey);
return { rows, total: rows.length };
},
async checkConflicts(taskId) {
const drafts = await deps.drafts.list(taskId);
if (drafts.length === 0) return [];
const byObject = new Map<string, Array<(typeof drafts)[number]>>();
for (const d of drafts) {
const k = `${d.domain}.${d.object}`;
const bucket = byObject.get(k);
if (bucket === undefined) byObject.set(k, [d]);
else bucket.push(d);
}
const findings = [];
for (const [, group] of byObject) {
const first = group[0]!;
const ref: ObjectRef = { domain: first.domain, object: first.object };
const schema = await requireSchema(ref);
const rows = await deps.store.selectByKeys(
ref,
group.map((g) => g.key),
);
const officialByKey = new Map(rows.map((r) => [keyOf(pickKey(r, schema.primaryKey)), r]));
findings.push(...detectConflicts(group, officialByKey));
}
return findings;
},
async discardDrafts(taskId) {
await deps.drafts.clear(taskId);
},
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/ledger",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+42
View File
@@ -0,0 +1,42 @@
/**
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
import type { DraftEntry } from './internal/domain/task-view.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
/** 数据事件的发射口 —— app 注入 `event` 模块的出口(§5.2) */
export type EventPublisher = { publish(envelope: EnvelopeDraft): Promise<void> };
export type ObjectRef = { readonly domain: string; readonly object: string };
/**
* 台账存储 —— 运行时 DDL / DML 由声明生成,ORM 静态 schema 不适用,
* 一律 node-postgres 直写(§1 / ADR-002)。
*/
export type LedgerStore = {
/** 已物化对象的列与主键;未物化返回 undefined */
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectRef[]>;
/** 执行「只增不破坏」的变更计划 */
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
select(query: LedgerQuery): Promise<{ rows: LedgerRow[]; total: number }>;
selectByKeys(ref: ObjectRef, keys: readonly LedgerKey[]): Promise<LedgerRow[]>;
upsert(ref: ObjectRef, key: LedgerKey, values: LedgerRow): Promise<void>;
deleteRow(ref: ObjectRef, key: LedgerKey): Promise<void>;
/** 放行写的原子性:草稿合并与事件登记同一事务 */
transaction<T>(fn: (store: LedgerStore, drafts: DraftStore) => Promise<T>): Promise<T>;
};
/** 任务级草稿层 —— 推荐形态 ledger.__draft;物理形态可换,功能约束不可变(§7.2) */
export type DraftStore = {
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
clear(taskId: string): Promise<void>;
};
+34
View File
@@ -0,0 +1,34 @@
# module: llm
职责: LLM 实例:自定义模型名 → 四元绑定(Provider 枚举 / URL / API-Key / Model);
`ModelGateway` —— **两条缝之一**,支持 live / record / replay;系统用途模型的选取;
安装静态审的「模型名已绑定」核对
不负责: LLM 工具(是 `toolbox` 的一类工具,与基座的 LLM 调用不同物,OS 07)、
凭证主密钥的持有(见 app 的 `SecretBox` 实现,ADR-009)、预算扣减(见 task 的 Harness)
公开出口: index.ts
- 类型:`ModelGateway` `ModelCall` `ModelResult` `ModelUsage` `GatewayMode` `LlmService`
`UpsertLlmInstance` `ModelBinding` `LlmInstanceView`
- 错误:`ModelNotBoundError` `ModelDisabledError` `ReplayMissError`
- 端口类型:`LlmInstanceRepository` `ReplayStore` `ProviderInvoker` `SecretBox` `Clock` `IdGenerator`
- 用例工厂:`createLlmService` `createModelGateway`
- 纯函数:`validateBinding` `isBound` `toPublicView` `pickSystemUse` `replayKey`
`callFingerprint` `stableStringify`
- adapter:`createLlmInstanceRepository` `createReplayStore` `createProviderInvoker` `parseJsonLoose`
- Drizzle 表:`llmInstanceTable` `replayModelTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db;port:`SecretBox`(app 注入)、`ProviderInvoker`、`ReplayStore`
被依赖: toolbox, memory, loop-engine, flow-engine, skill-engine, task, activity-registry,
report(叙述性小结)、app(治理台 · 系统管理路由)
不变量:
- **一切模型调用经 `ModelGateway`** —— 绕过它,回放审就对它失明(§12.5#10)
- `replay` 模式**绝不回退实调**:缺录即 `ReplayMissError`
- 明文 API-Key **永不进 domain**:domain 只见密文串,开封在 usecase 调 `SecretBox.open`
- 对外读模型 `LlmInstanceView` **结构上没有密钥字段** —— 只回 `apiKeyBound: boolean`(ADR-009)
- 录制不含凭证:录的是语义层(model / system / prompt / 参数 / 出参),不是 HTTP 层
- `upsert` 的 `apiKey` 留空 = 保持原密文(只写不读)
- 四元缺一即未绑定;未绑定 / 已停用的实例调用一律抛错,不静默降级
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { isBound, pickSystemUse, toPublicView, validateBinding } from '../internal/domain/model-binding.ts';
import type { ModelBinding } from '../internal/domain/model-binding.ts';
import { callFingerprint, replayKey, stableStringify } from '../internal/domain/replay-key.ts';
import type { TaskId } from '@contracts';
const binding = (over: Partial<ModelBinding> = {}): ModelBinding => ({
id: 'i1',
name: 'quote-extractor',
provider: 'openai-compatible',
baseUrl: 'https://api.example.com/v1',
model: 'gpt-4o-mini',
apiKeyCipher: 'v1.aa.bb.cc',
systemUse: false,
enabled: true,
...over,
});
describe('validateBinding —— 四元齐备才算绑定', () => {
it('四元齐备通过', () => {
expect(validateBinding(binding())).toEqual([]);
expect(isBound(binding())).toBe(true);
});
it('缺 API-Key 即未绑定', () => {
const v = validateBinding(binding({ apiKeyCipher: undefined }));
expect(v.map((x) => x.field)).toEqual(['apiKey']);
expect(isBound(binding({ apiKeyCipher: undefined }))).toBe(false);
});
it('URL 必须是 http(s)', () => {
expect(validateBinding(binding({ baseUrl: 'ftp://x' })).map((x) => x.field)).toEqual(['baseUrl']);
expect(validateBinding(binding({ baseUrl: '' })).map((x) => x.field)).toEqual(['baseUrl']);
});
it('模型名不得为空', () => {
expect(validateBinding(binding({ model: ' ' })).map((x) => x.field)).toEqual(['model']);
});
it('自定义模型名有字符集约束', () => {
expect(validateBinding(binding({ name: '模型 一' })).map((x) => x.field)).toEqual(['name']);
});
});
describe('toPublicView —— 结构上不可能泄漏密钥', () => {
it('只回是否已绑定', () => {
const view = toPublicView(binding());
expect(view).toEqual({
id: 'i1',
name: 'quote-extractor',
provider: 'openai-compatible',
baseUrl: 'https://api.example.com/v1',
model: 'gpt-4o-mini',
apiKeyBound: true,
systemUse: false,
enabled: true,
});
expect(JSON.stringify(view)).not.toContain('v1.aa.bb.cc');
});
it('未绑定时 apiKeyBound = false', () => {
expect(toPublicView(binding({ apiKeyCipher: '' })).apiKeyBound).toBe(false);
});
});
describe('pickSystemUse', () => {
it('取启用且标记系统用途的实例', () => {
const picked = pickSystemUse([
binding({ id: 'a', name: 'a', systemUse: false }),
binding({ id: 'b', name: 'b', systemUse: true, enabled: false }),
binding({ id: 'c', name: 'c', systemUse: true }),
]);
expect(picked?.id).toBe('c');
});
it('没有即 undefined', () => {
expect(pickSystemUse([])).toBeUndefined();
});
});
describe('replayKey —— 按任务 × 步序号', () => {
it('有任务与步序号时用定位键', () => {
expect(replayKey({ model: 'm', prompt: 'p', taskId: 't1' as TaskId, stepNo: 3 })).toBe('task:t1#3');
});
it('缺定位时退到内容指纹,且同输入同键', () => {
const a = replayKey({ model: 'm', prompt: 'p', temperature: 0 });
const b = replayKey({ model: 'm', prompt: 'p', temperature: 0 });
expect(a).toBe(b);
expect(a.startsWith('fp:')).toBe(true);
});
it('不同 prompt 得不同键', () => {
expect(replayKey({ model: 'm', prompt: 'p1' })).not.toBe(replayKey({ model: 'm', prompt: 'p2' }));
});
it('指纹不含凭证字段', () => {
expect(callFingerprint({ model: 'm', prompt: 'p' })).not.toMatch(/key|token|authorization/i);
});
});
describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () => {
it('对象键排序', () => {
expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 }));
});
it('嵌套与数组', () => {
expect(stableStringify({ a: [1, { d: 4, c: 3 }] })).toBe('{"a":[1,{"c":3,"d":4}]}');
});
it('null 与标量', () => {
expect(stableStringify(null)).toBe('null');
expect(stableStringify(3)).toBe('3');
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* module: llm —— 对外类型(编码规范 §10.1)。
*
* 两条缝之一(§7.3):`ModelGateway` 支持 record | replay | live 三模式 ——
* **任何模型调用不经 ModelGateway,回放审就对它失明**(§12.5#10)。
*/
import type { LlmInstanceView, LlmProvider, TaskId } from '@contracts';
import type { ModelBinding, BindingViolation } from './internal/domain/model-binding.ts';
export type { LlmInstanceView, LlmProvider, ModelBinding, BindingViolation };
/** 一条缝的三个开关位 */
export const GATEWAY_MODES = ['live', 'record', 'replay'] as const;
export type GatewayMode = (typeof GATEWAY_MODES)[number];
export class ModelNotBoundError extends Error {
constructor(
readonly modelName: string,
readonly violations: readonly BindingViolation[],
) {
super(`模型未绑定:${modelName}(${violations.map((v) => `${v.field}:${v.message}`).join('; ')})`);
this.name = 'ModelNotBoundError';
}
}
export class ModelDisabledError extends Error {
constructor(readonly modelName: string) {
super(`模型实例已停用:${modelName}`);
this.name = 'ModelDisabledError';
}
}
export class ReplayMissError extends Error {
constructor(readonly key: string) {
super(`回放缺录:${key} —— 回放模式下不得回退为实调(否则回放审失去意义)`);
this.name = 'ReplayMissError';
}
}
/** 模型调用的语义入参 —— 录制的就是它,不含任何凭证(ADR-009 #5) */
export type ModelCall = {
/** 活动声明的自定义模型名(逻辑名) */
readonly model: string;
readonly system?: string;
readonly prompt: string;
readonly temperature?: number;
readonly maxOutputTokens?: number;
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
readonly outputSchema?: Record<string, unknown>;
/** 录制/回放的定位:任务 × 步序号(§7.3) */
readonly taskId?: TaskId;
readonly stepNo?: number;
/** 归因用:调用点标签(flow 步名 / loop 回合 / 系统用途) */
readonly label?: string;
};
export type ModelUsage = {
readonly inputTokens: number;
readonly outputTokens: number;
readonly latencyMs: number;
};
export type ModelResult = {
readonly text: string;
/** outputSchema 给定时的解析结果 */
readonly object?: unknown;
readonly usage: ModelUsage;
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
readonly binding: { readonly name: string; readonly provider: LlmProvider; readonly model: string };
readonly mode: GatewayMode;
};
/**
* 模型网关 —— 活动基座六件里的「LLM 调用」。
* 活动只见自定义模型名;解析、凭证、录制回放全在此。
*/
export type ModelGateway = {
readonly mode: GatewayMode;
generate(call: ModelCall): Promise<ModelResult>;
/** 换模式的网关 —— 回放审用 replay 模式重跑引擎(§7.3) */
withMode(mode: GatewayMode): ModelGateway;
};
export type UpsertLlmInstance = {
readonly id?: string | undefined;
readonly name: string;
readonly provider: LlmProvider;
readonly baseUrl: string;
readonly model: string;
/** 只写不读:留空表示保持原值 */
readonly apiKey?: string | undefined;
readonly systemUse: boolean;
readonly enabled: boolean;
};
export type LlmService = {
list(): Promise<LlmInstanceView[]>;
upsert(input: UpsertLlmInstance): Promise<LlmInstanceView>;
remove(id: string): Promise<void>;
/** 连通性探针 —— 治理台「测试连接」 */
probe(id: string): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
/** 安装静态审:核对活动声明的模型名已绑定(AM 11.3) */
assertNamesBound(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
/** 系统用途模型 —— 叙述性小结 / 汇报生成 / 蒸馏钩子 */
systemUseBinding(): Promise<ModelBinding | undefined>;
};
+39
View File
@@ -0,0 +1,39 @@
/**
* module: llm —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
BindingViolation,
GatewayMode,
LlmInstanceView,
LlmProvider,
LlmService,
ModelBinding,
ModelCall,
ModelGateway,
ModelResult,
ModelUsage,
UpsertLlmInstance,
} from './contract.ts';
export { GATEWAY_MODES, ModelDisabledError, ModelNotBoundError, ReplayMissError } from './contract.ts';
export type {
Clock,
IdGenerator,
LlmInstanceRepository,
ProviderInvoker,
ReplayRecord,
ReplayStore,
SecretBox,
} from './port.ts';
export type { LlmServiceDeps } from './internal/usecase/llm-service.ts';
export { createLlmService } from './internal/usecase/llm-service.ts';
export type { ModelGatewayDeps } from './internal/usecase/model-gateway.ts';
export { createModelGateway } from './internal/usecase/model-gateway.ts';
export { isBound, pickSystemUse, toPublicView, validateBinding } from './internal/domain/model-binding.ts';
export { callFingerprint, replayKey, stableStringify } from './internal/domain/replay-key.ts';
export { createLlmInstanceRepository, createReplayStore } from './internal/adapter/llm-repository.ts';
export { createProviderInvoker, parseJsonLoose } from './internal/adapter/ai-sdk-provider.ts';
export { llmInstanceTable, replayModelTable } from './internal/adapter/llm-schema.ts';
@@ -0,0 +1,69 @@
/**
* Provider 工厂 —— Vercel AI SDK 只准出现在 adapter(编码规范 §6.2 / §11.4)。
*
* 四元绑定落在这里:Provider 枚举 → provider 工厂(baseURL / apiKey)→ 模型实例。
* 上层只见 `ProviderInvoker` 端口,拿不到任何 SDK 类型。
*/
import { generateText } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { ProviderInvoker } from '../../port.ts';
/**
* MVP 的全部 Provider 都以 OpenAI 兼容端点接入(README:「绑定 LLM 实例(任一 OpenAI 兼容端点)」)。
* 枚举是为了治理台的呈现与日后分化,当下共用一个工厂。
*/
export function createProviderInvoker(): ProviderInvoker {
return {
async invoke({ binding, apiKey, call }) {
const provider = createOpenAICompatible({
name: binding.provider,
baseURL: binding.baseUrl,
apiKey,
});
const started = Date.now();
const result = await generateText({
model: provider(binding.model),
...(call.system === undefined ? {} : { system: call.system }),
prompt: call.prompt,
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
});
const object = call.outputSchema === undefined ? undefined : parseJsonLoose(result.text);
return {
text: result.text,
...(object === undefined ? {} : { object }),
usage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
latencyMs: Date.now() - started,
},
};
},
};
}
/**
* 结构化出参:模型常把 JSON 包在 ```json 围栏里。这里只做**提取**,
* schema 校验归调用方(ADR-003:运行时校验用 ajv)。
*/
export function parseJsonLoose(text: string): unknown {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const candidate = (fenced?.[1] ?? text).trim();
try {
return JSON.parse(candidate);
} catch {
const start = candidate.search(/[[{]/);
const end = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'));
if (start >= 0 && end > start) {
try {
return JSON.parse(candidate.slice(start, end + 1));
} catch {
return undefined;
}
}
return undefined;
}
}
@@ -0,0 +1,103 @@
/**
* llm 的仓储与录制存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { eq } from 'drizzle-orm';
import { llmProviderSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { LlmInstanceRepository, ReplayRecord, ReplayStore } from '../../port.ts';
import type { ModelBinding } from '../domain/model-binding.ts';
import { llmInstanceTable, replayModelTable } from './llm-schema.ts';
type Row = typeof llmInstanceTable.$inferSelect;
function toBinding(row: Row): ModelBinding {
return {
id: row.id,
name: row.name,
provider: llmProviderSchema.parse(row.provider),
baseUrl: row.baseUrl,
model: row.model,
apiKeyCipher: row.apiKeyCipher ?? undefined,
systemUse: row.systemUse,
enabled: row.enabled,
};
}
export function createLlmInstanceRepository(db: Db): LlmInstanceRepository {
return {
async list() {
const rows = await db.select().from(llmInstanceTable).orderBy(llmInstanceTable.name);
return rows.map(toBinding);
},
async findByName(name) {
const rows = await db.select().from(llmInstanceTable).where(eq(llmInstanceTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toBinding(rows[0]);
},
async findById(id) {
const rows = await db.select().from(llmInstanceTable).where(eq(llmInstanceTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toBinding(rows[0]);
},
async upsert(binding) {
const values = {
id: binding.id,
name: binding.name,
provider: binding.provider,
baseUrl: binding.baseUrl,
model: binding.model,
apiKeyCipher: binding.apiKeyCipher ?? null,
systemUse: binding.systemUse,
enabled: binding.enabled,
};
const [row] = await db
.insert(llmInstanceTable)
.values(values)
.onConflictDoUpdate({
target: llmInstanceTable.name,
set: {
provider: values.provider,
baseUrl: values.baseUrl,
model: values.model,
apiKeyCipher: values.apiKeyCipher,
systemUse: values.systemUse,
enabled: values.enabled,
},
})
.returning();
if (row === undefined) throw new Error('LLM 实例写入未返回行');
return toBinding(row);
},
async remove(id) {
await db.delete(llmInstanceTable).where(eq(llmInstanceTable.id, id));
},
};
}
export function createReplayStore(db: Db): ReplayStore {
return {
async put(record: ReplayRecord) {
await db
.insert(replayModelTable)
.values({
key: record.key,
call: record.call,
result: record.result,
recordedAt: record.recordedAt,
})
.onConflictDoUpdate({
target: replayModelTable.key,
set: { call: record.call, result: record.result, recordedAt: record.recordedAt },
});
},
async get(key: string) {
const rows = await db.select().from(replayModelTable).where(eq(replayModelTable.key, key)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return {
key: row.key,
call: row.call as ReplayRecord['call'],
result: row.result as ReplayRecord['result'],
recordedAt: row.recordedAt,
};
},
};
}
@@ -0,0 +1,35 @@
/**
* llm 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。
*/
import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const llmInstanceTable = sys.table(
'llm_instance',
{
id: text('id').primaryKey(),
/** 自定义模型名(逻辑名)—— 活动以此声明引用,全局唯一 */
name: text('name').notNull(),
provider: text('provider').notNull(),
baseUrl: text('base_url').notNull(),
model: text('model').notNull(),
/** AES-256-GCM 密文;明文与 endpoint 值只存治理台,不入包、不入活动、不入留痕(ADR-009) */
apiKeyCipher: text('api_key_cipher'),
systemUse: boolean('system_use').notNull().default(false),
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('llm_instance_name_key').on(t.name)],
);
/** 录制回放:按任务 × 步序号(编码规范 §7.3 两缝之一) */
export const replayModelTable = sys.table(
'replay_model',
{
key: text('key').primaryKey(),
call: jsonb('call').notNull(),
result: jsonb('result').notNull(),
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('replay_model_recorded_idx').on(t.recordedAt)],
);
@@ -0,0 +1,71 @@
/**
* 模型四元绑定 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 02 系统管理 / 04 基座六件)。
*
* 活动侧只声明**自定义模型名(逻辑名)**;名称背后的
* Provider(枚举)/ URL / API-Key / Model name 四元在系统侧创建绑定 —— 活动不触厂商与密钥。
*/
import type { LlmProvider } from '@contracts';
export type ModelBinding = {
readonly id: string;
/** 自定义模型名 —— 活动以此声明引用 */
readonly name: string;
readonly provider: LlmProvider;
readonly baseUrl: string;
readonly model: string;
/** 密文;明文永不进 domain(ADR-009) */
readonly apiKeyCipher: string | undefined;
readonly systemUse: boolean;
readonly enabled: boolean;
};
export type BindingViolation = { readonly field: string; readonly message: string };
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** 四元齐备才算绑定;缺一即静态审的 `model-name-unbound` */
export function validateBinding(binding: ModelBinding): BindingViolation[] {
const out: BindingViolation[] = [];
if (!NAME.test(binding.name)) out.push({ field: 'name', message: '自定义模型名只准字母数字与 . _ -,不超 64 字符' });
if (binding.baseUrl.trim() === '') out.push({ field: 'baseUrl', message: 'URL 不得为空' });
else if (!/^https?:\/\//.test(binding.baseUrl)) out.push({ field: 'baseUrl', message: 'URL 必须是 http(s)' });
if (binding.model.trim() === '') out.push({ field: 'model', message: 'Model name 不得为空' });
if (binding.apiKeyCipher === undefined || binding.apiKeyCipher === '')
out.push({ field: 'apiKey', message: 'API-Key 未绑定' });
return out;
}
export function isBound(binding: ModelBinding): boolean {
return validateBinding(binding).length === 0;
}
/**
* 对外读模型 —— **结构上不可能泄漏密钥**(ADR-009 #4):
* 只回是否已绑定,不回密文、不回明文。
*/
export function toPublicView(binding: ModelBinding): {
id: string;
name: string;
provider: LlmProvider;
baseUrl: string;
model: string;
apiKeyBound: boolean;
systemUse: boolean;
enabled: boolean;
} {
return {
id: binding.id,
name: binding.name,
provider: binding.provider,
baseUrl: binding.baseUrl,
model: binding.model,
apiKeyBound: binding.apiKeyCipher !== undefined && binding.apiKeyCipher !== '',
systemUse: binding.systemUse,
enabled: binding.enabled,
};
}
/** 系统用途模型:叙述性小结 / 汇报生成 / 蒸馏钩子 —— 计量入系统账、不占活动预算 */
export function pickSystemUse(bindings: readonly ModelBinding[]): ModelBinding | undefined {
return bindings.find((b) => b.systemUse && b.enabled);
}
@@ -0,0 +1,82 @@
/**
* llm 用例 —— LLM 实例的四元绑定管理(编码规范 §5.3;语义出处 OS 02 系统管理)。
*/
import type { LlmInstanceView } from '@contracts';
import type { LlmService, UpsertLlmInstance } from '../../contract.ts';
import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts';
import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts';
import type { ModelBinding } from '../domain/model-binding.ts';
export type LlmServiceDeps = {
readonly instances: LlmInstanceRepository;
readonly provider: ProviderInvoker;
readonly secrets: SecretBox;
readonly clock: Clock;
readonly ids: IdGenerator;
};
export function createLlmService(deps: LlmServiceDeps): LlmService {
return {
async list(): Promise<LlmInstanceView[]> {
const all = await deps.instances.list();
return all.map(toPublicView);
},
async upsert(input: UpsertLlmInstance): Promise<LlmInstanceView> {
const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id);
// 只写不读:apiKey 留空即保持原密文(ADR-009 #4)
const apiKeyCipher =
input.apiKey !== undefined && input.apiKey !== ''
? deps.secrets.seal(input.apiKey)
: existing?.apiKeyCipher;
const binding: ModelBinding = {
id: existing?.id ?? input.id ?? deps.ids.next(),
name: input.name,
provider: input.provider,
baseUrl: input.baseUrl,
model: input.model,
apiKeyCipher,
systemUse: input.systemUse,
enabled: input.enabled,
};
const saved = await deps.instances.upsert(binding);
return toPublicView(saved);
},
async remove(id) {
await deps.instances.remove(id);
},
async probe(id) {
const binding = await deps.instances.findById(id);
if (binding === undefined) return { ok: false, error: '实例不存在' };
const violations = validateBinding(binding);
if (violations.length > 0) {
return { ok: false, error: violations.map((v) => `${v.field}:${v.message}`).join('; ') };
}
const started = deps.clock.now().getTime();
try {
await deps.provider.invoke({
binding,
apiKey: deps.secrets.open(binding.apiKeyCipher!),
call: { model: binding.name, prompt: 'ping', maxOutputTokens: 8, label: 'probe' },
});
return { ok: true, latencyMs: deps.clock.now().getTime() - started };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
},
async assertNamesBound(names) {
const all = await deps.instances.list();
const bound = new Set(all.filter((b) => validateBinding(b).length === 0 && b.enabled).map((b) => b.name));
const missing = [...new Set(names)].filter((n) => !bound.has(n));
return { ok: missing.length === 0, missing };
},
async systemUseBinding() {
return pickSystemUse(await deps.instances.list());
},
};
}
@@ -0,0 +1,85 @@
/**
* ModelGateway —— 两条缝之一(编码规范 §7.3)。
*
* live 实调 provider(经 ProviderInvoker 端口,AI SDK 在 adapter)
* record 实调 + 落录 `sys.replay_model`
* replay 只读录、**绝不回退实调** —— 回退即回放审失明(ReplayMissError)
*
* 活动只见自定义模型名;解析、凭证开封、留痕在此。
*/
import type { GatewayMode, ModelCall, ModelGateway, ModelResult } from '../../contract.ts';
import { ModelDisabledError, ModelNotBoundError, ReplayMissError } from '../../contract.ts';
import type { Clock, LlmInstanceRepository, ProviderInvoker, ReplayStore, SecretBox } from '../../port.ts';
import { validateBinding } from '../domain/model-binding.ts';
import { replayKey } from '../domain/replay-key.ts';
export type ModelGatewayDeps = {
readonly instances: LlmInstanceRepository;
readonly provider: ProviderInvoker;
readonly replay: ReplayStore;
readonly secrets: SecretBox;
readonly clock: Clock;
readonly mode: GatewayMode;
};
export function createModelGateway(deps: ModelGatewayDeps): ModelGateway {
const gateway: ModelGateway = {
mode: deps.mode,
async generate(call: ModelCall): Promise<ModelResult> {
const key = replayKey(call);
if (deps.mode === 'replay') {
const record = await deps.replay.get(key);
if (record === undefined) throw new ReplayMissError(key);
const binding = await deps.instances.findByName(call.model);
return {
text: record.result.text,
...(record.result.object === undefined ? {} : { object: record.result.object }),
usage: record.result.usage,
binding: {
name: call.model,
provider: binding?.provider ?? 'openai-compatible',
model: binding?.model ?? record.call.model,
},
mode: 'replay',
};
}
const binding = await deps.instances.findByName(call.model);
if (binding === undefined) {
throw new ModelNotBoundError(call.model, [{ field: 'name', message: '实例不存在' }]);
}
if (!binding.enabled) throw new ModelDisabledError(call.model);
const violations = validateBinding(binding);
if (violations.length > 0) throw new ModelNotBoundError(call.model, violations);
const apiKey = deps.secrets.open(binding.apiKeyCipher!);
const started = deps.clock.now().getTime();
const raw = await deps.provider.invoke({ binding, apiKey, call });
const usage = { ...raw.usage, latencyMs: raw.usage.latencyMs || deps.clock.now().getTime() - started };
if (deps.mode === 'record') {
await deps.replay.put({
key,
call,
result: { text: raw.text, ...(raw.object === undefined ? {} : { object: raw.object }), usage },
recordedAt: deps.clock.now(),
});
}
return {
text: raw.text,
...(raw.object === undefined ? {} : { object: raw.object }),
usage,
binding: { name: binding.name, provider: binding.provider, model: binding.model },
mode: deps.mode,
};
},
withMode(mode) {
return createModelGateway({ ...deps, mode });
},
};
return gateway;
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/llm",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+49
View File
@@ -0,0 +1,49 @@
/**
* module: llm —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ModelBinding } from './internal/domain/model-binding.ts';
import type { ModelCall, ModelResult, ModelUsage } from './contract.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
/** 凭证密封 —— 明文永不进 domain(ADR-009) */
export type SecretBox = {
seal(plaintext: string): string;
open(cipher: string): string;
};
export type LlmInstanceRepository = {
list(): Promise<ModelBinding[]>;
findByName(name: string): Promise<ModelBinding | undefined>;
findById(id: string): Promise<ModelBinding | undefined>;
upsert(binding: ModelBinding): Promise<ModelBinding>;
remove(id: string): Promise<void>;
};
/** 录制回放存储:按任务 × 步序号(§7.3) */
export type ReplayRecord = {
readonly key: string;
readonly call: ModelCall;
readonly result: { readonly text: string; readonly object?: unknown; readonly usage: ModelUsage };
readonly recordedAt: Date;
};
export type ReplayStore = {
put(record: ReplayRecord): Promise<void>;
get(key: string): Promise<ReplayRecord | undefined>;
};
/**
* Provider 工厂 —— Vercel AI SDK 只准出现在 adapter(§6.2 / §11.4)。
* usecase 只见本端口,拿不到任何 SDK 类型。
*/
export type ProviderInvoker = {
invoke(input: {
readonly binding: ModelBinding;
readonly apiKey: string;
readonly call: ModelCall;
}): Promise<{ text: string; object?: unknown; usage: ModelUsage }>;
};
export type { ModelCall, ModelResult };
+33
View File
@@ -0,0 +1,33 @@
# module: org
职责: 员工与岗位:账号、岗位授权(客体三类 × 动作三种)、停用前置检查、会话装载、
岗位在岗人员查询(负责人四策略解析的候选集来源)、种子数据播种
不负责: 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 端口由 app 注入)、
鉴权中间件与 cookie 会话(app 层)
公开出口: index.ts
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` 与 @contracts 的 `Staff` / `Position` /
`Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
- 错误:`StaffNotFoundError` `PositionNotFoundError` `AccountTakenError` `DisableBlockedError`
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `InFlightWorkProbe`
`Clock` `IdGenerator`
- 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService`
- 纯函数:`isAuthorized` `actionCovers` `objectCovers`
- 常量:`POSITION_SEED` `STAFF_SEED` `DEFAULT_LOGIN_ACCOUNT`
- adapter 工厂(供 app 装配):`createPositionRepository` `createStaffRepository`
`createGrantRepository` `objectKeyOf`
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema)
port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`
被依赖: process(负责人解析的候选集)、message、interaction、human-tool、report、
app(鉴权中间件与 /api/gov/staff 路由组装)
不变量:
- 动作蕴含:治理 ⊃ 写 ⊃ 读;数据客体三级覆盖(域 ⊃ 对象 ⊃ 字段)
- 停用是全有全无:前置检查未过即抛 `DisableBlockedError`,不做部分执行
- `authorize` 对停用员工恒 false
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,92 @@
/** domain 测试零 mock(编码规范 §16) */
import { describe, expect, it } from 'vitest';
import type { Grant, PositionId } from '@contracts';
import { actionCovers, isAuthorized, objectCovers } from '../internal/domain/authorization.ts';
const pos = 'p-bd' as PositionId;
describe('actionCovers —— 动作蕴含', () => {
it('治理蕴含写与读', () => {
expect(actionCovers('govern', 'write')).toBe(true);
expect(actionCovers('govern', 'read')).toBe(true);
});
it('写蕴含读,但读不蕴含写', () => {
expect(actionCovers('write', 'read')).toBe(true);
expect(actionCovers('read', 'write')).toBe(false);
});
});
describe('objectCovers —— 客体三级覆盖', () => {
it('对象级授权覆盖其下字段', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: 'customer' },
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
),
).toBe(true);
});
it('字段级授权不覆盖别的字段', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
{ kind: 'data', domain: 'shared', object: 'customer', field: 'payment_terms' },
),
).toBe(false);
});
it('域级通配覆盖域内全部对象', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: '*' },
{ kind: 'data', domain: 'shared', object: 'supplier', field: 'contact' },
),
).toBe(true);
});
it('跨域不覆盖', () => {
expect(
objectCovers(
{ kind: 'data', domain: 'shared', object: '*' },
{ kind: 'data', domain: 'quote_intake', object: 'record' },
),
).toBe(false);
});
it('治理功能逐项授权', () => {
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'task' })).toBe(true);
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'staff' })).toBe(false);
});
it('任务客体未限定活动即覆盖全部任务', () => {
expect(objectCovers({ kind: 'task' }, { kind: 'task', activityId: 'a1' })).toBe(true);
expect(objectCovers({ kind: 'task', activityId: 'a1' }, { kind: 'task', activityId: 'a2' })).toBe(false);
});
it('客体类型不同一律不覆盖', () => {
expect(objectCovers({ kind: 'task' }, { kind: 'governance', function: 'task' })).toBe(false);
});
});
describe('isAuthorized —— 任一授权覆盖即通过', () => {
const grants: Grant[] = [
{ positionId: pos, object: { kind: 'governance', function: 'task' }, action: 'read' },
{ positionId: pos, object: { kind: 'data', domain: 'shared', object: '*' }, action: 'write' },
];
it('命中数据域的写', () => {
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'write')).toBe(true);
});
it('写权蕴含读权', () => {
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'read')).toBe(true);
});
it('只读的治理功能不得写', () => {
expect(isAuthorized(grants, { kind: 'governance', function: 'task' }, 'govern')).toBe(false);
});
it('空授权一律不通过', () => {
expect(isAuthorized([], { kind: 'governance', function: 'system' }, 'read')).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
*/
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId } from '@contracts';
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff };
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
export type StaffWithPosition = Staff & { readonly position: Position };
export type CreateStaffInput = {
readonly account: string;
readonly name: string;
readonly positionId: string;
};
export class StaffNotFoundError extends Error {
constructor(readonly staffId: StaffId) {
super(`员工不存在:${staffId}`);
this.name = 'StaffNotFoundError';
}
}
export class PositionNotFoundError extends Error {
constructor(readonly positionCode: string) {
super(`岗位不存在:${positionCode}`);
this.name = 'PositionNotFoundError';
}
}
export class AccountTakenError extends Error {
constructor(readonly account: string) {
super(`账号已存在:${account}`);
this.name = 'AccountTakenError';
}
}
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
export class DisableBlockedError extends Error {
constructor(readonly check: DisableCheck) {
super(`员工停用前置检查未过:在途任务 ${check.openTasks.length} 件、待答卡 ${check.openCards.length} 张`);
this.name = 'DisableBlockedError';
}
}
export type OrgService = {
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
createStaff(input: CreateStaffInput): Promise<Staff>;
/** 停用前置检查 —— 只读,治理台先看再点 */
checkDisable(staffId: StaffId): Promise<DisableCheck>;
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
disableStaff(staffId: StaffId): Promise<void>;
listGrants(positionId: string): Promise<Grant[]>;
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
/** 岗位权限校验 —— app 层鉴权中间件读它 */
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
loadSession(account: string): Promise<Session | undefined>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
ensureSeeded(): Promise<void>;
};
+44
View File
@@ -0,0 +1,44 @@
/**
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
CreateStaffInput,
DisableCheck,
Grant,
GrantAction,
GrantObject,
OrgService,
Position,
Session,
Staff,
StaffWithPosition,
} from './contract.ts';
export {
AccountTakenError,
DisableBlockedError,
PositionNotFoundError,
StaffNotFoundError,
} from './contract.ts';
export type {
Clock,
GrantRepository,
IdGenerator,
InFlightWorkProbe,
PositionRepository,
StaffRepository,
} from './port.ts';
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
export { createOrgService } from './internal/usecase/org-service.ts';
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
export {
createGrantRepository,
createPositionRepository,
createStaffRepository,
objectKeyOf,
} from './internal/adapter/org-repository.ts';
export { grantTable, positionTable, staffTable } from './internal/adapter/org-schema.ts';
@@ -0,0 +1,132 @@
/**
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
*/
import { and, eq } from 'drizzle-orm';
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId } from '@contracts';
import { grantSchema, positionIdSchema, positionSchema, staffSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { GrantRepository, PositionRepository, StaffRepository } from '../../port.ts';
import { grantTable, positionTable, staffTable } from './org-schema.ts';
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
export function objectKeyOf(object: GrantObject): string {
switch (object.kind) {
case 'data':
return `data:${object.domain}.${object.object}${object.field === undefined ? '' : `.${object.field}`}`;
case 'governance':
return `governance:${object.function}`;
case 'task':
return `task${object.activityId === undefined ? '' : `:${object.activityId}`}`;
}
}
export function createPositionRepository(db: Db): PositionRepository {
const toPosition = (row: typeof positionTable.$inferSelect): Position =>
positionSchema.parse({
id: row.id,
code: row.code,
name: row.name,
...(row.note === null ? {} : { note: row.note }),
});
return {
async list() {
const rows = await db.select().from(positionTable).orderBy(positionTable.code);
return rows.map(toPosition);
},
async findByCode(code) {
const rows = await db.select().from(positionTable).where(eq(positionTable.code, code)).limit(1);
return rows[0] === undefined ? undefined : toPosition(rows[0]);
},
async findById(id) {
const rows = await db.select().from(positionTable).where(eq(positionTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toPosition(rows[0]);
},
async upsert(position) {
const id = position.id ?? positionIdSchema.parse(position.code.toLowerCase());
const [row] = await db
.insert(positionTable)
.values({ id, code: position.code, name: position.name, note: position.note ?? null })
.onConflictDoUpdate({
target: positionTable.code,
set: { name: position.name, note: position.note ?? null },
})
.returning();
if (row === undefined) throw new Error('岗位写入未返回行');
return toPosition(row);
},
};
}
export function createStaffRepository(db: Db): StaffRepository {
const toStaff = (row: typeof staffTable.$inferSelect): Staff =>
staffSchema.parse({
id: row.id,
account: row.account,
name: row.name,
positionId: row.positionId,
state: row.state,
});
return {
async list() {
const rows = await db.select().from(staffTable).orderBy(staffTable.account);
return rows.map(toStaff);
},
async findById(id: StaffId) {
const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toStaff(rows[0]);
},
async findByAccount(account: string) {
const rows = await db.select().from(staffTable).where(eq(staffTable.account, account)).limit(1);
return rows[0] === undefined ? undefined : toStaff(rows[0]);
},
async listByPosition(positionId: PositionId) {
const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId));
return rows.map(toStaff);
},
async insert(staff: Staff) {
const [row] = await db.insert(staffTable).values(staff).returning();
if (row === undefined) throw new Error('员工写入未返回行');
return toStaff(row);
},
async setState(id: StaffId, state) {
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
},
};
}
export function createGrantRepository(db: Db): GrantRepository {
const toGrant = (row: typeof grantTable.$inferSelect): Grant =>
grantSchema.parse({ positionId: row.positionId, object: row.object, action: row.action });
return {
async listByPosition(positionId: PositionId) {
const rows = await db
.select()
.from(grantTable)
.where(and(eq(grantTable.positionId, positionId), eq(grantTable.enabled, true)));
return rows.map(toGrant);
},
async replaceForPosition(positionId: PositionId, grants) {
return db.transaction(async (tx) => {
await tx.delete(grantTable).where(eq(grantTable.positionId, positionId));
if (grants.length === 0) return [];
const rows = await tx
.insert(grantTable)
.values(
grants.map((g) => ({
positionId,
objectKey: objectKeyOf(g.object),
action: g.action,
object: g.object,
enabled: true,
})),
)
.onConflictDoNothing()
.returning();
return rows.map(toGrant);
});
},
};
}
@@ -0,0 +1,48 @@
/**
* org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。
* drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。
*/
import { boolean, index, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const positionTable = sys.table(
'position',
{
id: text('id').primaryKey(),
code: text('code').notNull(),
name: text('name').notNull(),
note: text('note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('position_code_key').on(t.code)],
);
export const staffTable = sys.table(
'staff',
{
id: text('id').primaryKey(),
account: text('account').notNull(),
name: text('name').notNull(),
positionId: text('position_id')
.notNull()
.references(() => positionTable.id),
state: text('state').notNull().default('active'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
);
export const grantTable = sys.table(
'position_grant',
{
positionId: text('position_id')
.notNull()
.references(() => positionTable.id, { onDelete: 'cascade' }),
/** 客体的规范化串:data:<域>.<对象>[.<字段>] / governance:<功能> / task[:<活动>] */
objectKey: text('object_key').notNull(),
action: text('action').notNull(),
object: jsonb('object').notNull(),
enabled: boolean('enabled').notNull().default(true),
},
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
);
@@ -0,0 +1,56 @@
/**
* 岗位授权判定 —— 纯逻辑,零 IO(编码规范 §5.3)。
*
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
* 语义出处:OS 02 员工管理。
*/
import type { Grant, GrantAction, GrantObject } from '@contracts';
/** 动作蕴含:治理 ⊃ 写 ⊃ 读 —— 有写权即可读,有治理权即可写 */
const IMPLIES: Readonly<Record<GrantAction, readonly GrantAction[]>> = {
govern: ['govern', 'write', 'read'],
write: ['write', 'read'],
read: ['read'],
};
export function actionCovers(granted: GrantAction, required: GrantAction): boolean {
return IMPLIES[granted].includes(required);
}
/**
* 客体覆盖:授权客体是否覆盖被请求客体。
* 数据客体分三级:域.对象.字段 —— 授到域即覆盖其下全部对象,授到对象即覆盖其下全部字段。
*/
export function objectCovers(granted: GrantObject, required: GrantObject): boolean {
if (granted.kind !== required.kind) return false;
switch (granted.kind) {
case 'data': {
const req = required as Extract<GrantObject, { kind: 'data' }>;
if (granted.domain !== req.domain) return false;
// 授权到域级(未指定对象名的通配 '*')
if (granted.object !== '*' && granted.object !== req.object) return false;
if (granted.field === undefined) return true;
return granted.field === req.field;
}
case 'governance': {
const req = required as Extract<GrantObject, { kind: 'governance' }>;
return granted.function === req.function;
}
case 'task': {
const req = required as Extract<GrantObject, { kind: 'task' }>;
// 未限定活动 = 覆盖全部任务
if (granted.activityId === undefined) return true;
return granted.activityId === req.activityId;
}
}
}
/** 任一授权覆盖即通过 */
export function isAuthorized(
grants: readonly Grant[],
object: GrantObject,
action: GrantAction,
): boolean {
return grants.some((g) => actionCovers(g.action, action) && objectCovers(g.object, object));
}
@@ -0,0 +1,25 @@
/**
* 停用前置检查 —— 纯逻辑,零 IO(编码规范 §5.3)。
*
* OS 02:员工停用前,其在途任务与待答卡须先改派完毕。
*/
import type { DisableCheck, StaffId } from '@contracts';
export type InFlightWork = {
readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>;
readonly openCards: ReadonlyArray<{ cardId: string; title: string }>;
readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>;
};
export function buildDisableCheck(staffId: StaffId, work: InFlightWork): DisableCheck {
const openTasks = [...work.openTasks];
const openCards = [...work.openCards];
const openApprovals = [...work.openApprovals];
return {
staffId,
blocked: openTasks.length > 0 || openCards.length > 0 || openApprovals.length > 0,
openTasks,
openCards,
openApprovals,
};
}
@@ -0,0 +1,61 @@
/**
* 种子数据(README《种子数据》)—— 纯常量,零 IO。
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
*/
export const POSITION_SEED = [
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
{ code: 'FAE', name: '现场应用', note: '替代选型' },
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
{ code: 'OP', name: '运营', note: '运营支持' },
] as const;
export const STAFF_SEED = [
{ account: 'dingziqiao', name: '丁子乔', position: 'PM' },
{ account: 'fengbing', name: '冯兵', position: 'BD' },
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
] as const;
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
export type PositionSeed = (typeof POSITION_SEED)[number];
export type StaffSeed = (typeof STAFF_SEED)[number];
/**
* 岗位默认授权 —— 治理台九功能对 PM 全开,其余岗位只开与其职责相关的读。
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
*/
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
PM: [
{ fn: 'task', action: 'govern' },
{ fn: 'staff', action: 'govern' },
{ fn: 'process', action: 'govern' },
{ fn: 'trigger', action: 'govern' },
{ fn: 'activity', action: 'govern' },
{ fn: 'tool', action: 'govern' },
{ fn: 'data', action: 'govern' },
{ fn: 'memory', action: 'govern' },
{ fn: 'system', action: 'govern' },
],
BD: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
],
FAE: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
],
PRM: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
],
OP: [
{ fn: 'task', action: 'read' },
{ fn: 'data', action: 'read' },
{ fn: 'memory', action: 'read' },
],
};
@@ -0,0 +1,183 @@
/**
* org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。
*/
import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, Staff, StaffId } from '@contracts';
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
import type {
CreateStaffInput,
OrgService,
StaffWithPosition,
} from '../../contract.ts';
import {
AccountTakenError,
DisableBlockedError,
PositionNotFoundError,
StaffNotFoundError,
} from '../../contract.ts';
import type {
GrantRepository,
IdGenerator,
InFlightWorkProbe,
PositionRepository,
StaffRepository,
} from '../../port.ts';
import { isAuthorized } from '../domain/authorization.ts';
import { buildDisableCheck } from '../domain/disable-check.ts';
import { POSITION_GRANT_SEED, POSITION_SEED, STAFF_SEED } from '../domain/seed-data.ts';
export type OrgServiceDeps = {
readonly positions: PositionRepository;
readonly staff: StaffRepository;
readonly grants: GrantRepository;
readonly inFlight: InFlightWorkProbe;
readonly ids: IdGenerator;
};
export function createOrgService(deps: OrgServiceDeps): OrgService {
async function positionMap(): Promise<Map<string, Position>> {
const all = await deps.positions.list();
return new Map(all.map((p) => [p.id, p]));
}
async function withPosition(staff: Staff, positions?: Map<string, Position>): Promise<StaffWithPosition> {
const map = positions ?? (await positionMap());
const position = map.get(staff.positionId);
if (!position) throw new PositionNotFoundError(staff.positionId);
return { ...staff, position };
}
async function grantsOfStaff(staffId: StaffId): Promise<Grant[]> {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
return deps.grants.listByPosition(staff.positionId);
}
return {
async listStaff() {
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
const map = new Map(positions.map((p) => [p.id, p]));
const staff = await Promise.all(rows.map((s) => withPosition(s, map)));
return { staff, positions };
},
async getStaff(staffId) {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
return withPosition(staff);
},
async findByAccount(account) {
const staff = await deps.staff.findByAccount(account);
if (!staff) return undefined;
return withPosition(staff);
},
async createStaff(input: CreateStaffInput) {
const taken = await deps.staff.findByAccount(input.account);
if (taken) throw new AccountTakenError(input.account);
const positionId = positionIdSchema.parse(input.positionId);
const position = await deps.positions.findById(positionId);
if (!position) throw new PositionNotFoundError(input.positionId);
return deps.staff.insert({
id: staffIdSchema.parse(deps.ids.next()),
account: input.account,
name: input.name,
positionId,
state: 'active',
});
},
async checkDisable(staffId) {
const staff = await deps.staff.findById(staffId);
if (!staff) throw new StaffNotFoundError(staffId);
const [openTasks, openCards, openApprovals] = await Promise.all([
deps.inFlight.openTasksOf(staffId),
deps.inFlight.openCardsOf(staffId),
deps.inFlight.openApprovalsOf(staffId),
]);
return buildDisableCheck(staffId, { openTasks, openCards, openApprovals });
},
async disableStaff(staffId) {
const check = await this.checkDisable(staffId);
if (check.blocked) throw new DisableBlockedError(check);
await deps.staff.setState(staffId, 'disabled');
},
async listGrants(positionId) {
return deps.grants.listByPosition(positionIdSchema.parse(positionId));
},
async setGrants(positionId, grants) {
return deps.grants.replaceForPosition(positionIdSchema.parse(positionId), grants);
},
async authorize(staffId: StaffId, object: GrantObject, action: GrantAction) {
const staff = await deps.staff.findById(staffId);
if (!staff || staff.state !== 'active') return false;
const grants = await grantsOfStaff(staffId);
return isAuthorized(grants, object, action);
},
async loadSession(account): Promise<Session | undefined> {
const staff = await deps.staff.findByAccount(account);
if (!staff || staff.state !== 'active') return undefined;
const position = await deps.positions.findById(staff.positionId);
if (!position) throw new PositionNotFoundError(staff.positionId);
const grants = await deps.grants.listByPosition(staff.positionId);
return { staff, position, grants };
},
async staffOfPosition(positionCode) {
const position = await deps.positions.findByCode(positionCode);
if (!position) throw new PositionNotFoundError(positionCode);
const rows = await deps.staff.listByPosition(position.id);
return rows.filter((s) => s.state === 'active').map((s) => ({ ...s, position }));
},
async ensureSeeded() {
const byCode = new Map<string, PositionId>();
for (const seed of POSITION_SEED) {
const existing = await deps.positions.findByCode(seed.code);
const position = existing
? existing
: await deps.positions.upsert({
id: positionIdSchema.parse(deps.ids.next()),
code: seed.code,
name: seed.name,
note: seed.note,
});
byCode.set(seed.code, position.id);
}
for (const seed of STAFF_SEED) {
const existing = await deps.staff.findByAccount(seed.account);
if (existing) continue;
const positionId = byCode.get(seed.position);
if (!positionId) throw new PositionNotFoundError(seed.position);
await deps.staff.insert({
id: staffIdSchema.parse(deps.ids.next()),
account: seed.account,
name: seed.name,
positionId,
state: 'active',
});
}
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
const positionId = byCode.get(code);
if (!positionId) continue;
const existing = await deps.grants.listByPosition(positionId);
if (existing.length > 0) continue;
await deps.grants.replaceForPosition(
positionId,
entries.map((e) => ({
positionId,
object: { kind: 'governance' as const, function: governanceFunctionSchema.parse(e.fn) },
action: e.action,
})),
);
}
},
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/org",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+41
View File
@@ -0,0 +1,41 @@
/**
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
* adapter 实现之,usecase 依赖之,app 装配之。
*/
import type { Grant, Position, PositionId, Staff, StaffId } from '@contracts';
export type Clock = { now(): Date };
export type PositionRepository = {
list(): Promise<Position[]>;
findByCode(code: string): Promise<Position | undefined>;
findById(id: PositionId): Promise<Position | undefined>;
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
};
export type StaffRepository = {
list(): Promise<Staff[]>;
findById(id: StaffId): Promise<Staff | undefined>;
findByAccount(account: string): Promise<Staff | undefined>;
listByPosition(positionId: PositionId): Promise<Staff[]>;
insert(staff: Staff): Promise<Staff>;
setState(id: StaffId, state: Staff['state']): Promise<void>;
};
export type GrantRepository = {
listByPosition(positionId: PositionId): Promise<Grant[]>;
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
};
/**
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
*/
export type InFlightWorkProbe = {
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
};
/** ID 生成 —— domain 零随机(§5.3) */
export type IdGenerator = { next(): string };