forked from ChaiHao/BPOS-MVP
M1: 底座 —— @contracts 首版 + event / ledger / org / llm
@contracts 首版(§8.1 九个文件): - id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件 - card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿) - contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类 - manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约 模块(L1): - event 信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定 - ledger schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查 - org 岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种 - llm 四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一 app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封 ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密 (ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误) 门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cd664af3a
commit
14d4fa046a
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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 declare class ListenerDeadlineError extends Error {
|
||||
readonly detail: string;
|
||||
constructor(detail: string);
|
||||
}
|
||||
/** 发布结果 —— 三级消费的判定结果,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;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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';
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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>;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 declare class DraftConflictError extends Error {
|
||||
readonly findings: readonly ConflictFinding[];
|
||||
constructor(findings: readonly ConflictFinding[]);
|
||||
}
|
||||
export declare class SchemaConflictError extends Error {
|
||||
readonly conflicts: readonly MergeConflict[];
|
||||
constructor(conflicts: readonly MergeConflict[]);
|
||||
}
|
||||
export declare class ObjectNotMaterializedError extends Error {
|
||||
readonly address: string;
|
||||
constructor(address: string);
|
||||
}
|
||||
/** 声明合并与物化的结果 —— 安装静态审读它: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>;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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,47 @@
|
||||
/**
|
||||
* 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>;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 declare const GATEWAY_MODES: readonly ["live", "record", "replay"];
|
||||
export type GatewayMode = (typeof GATEWAY_MODES)[number];
|
||||
export declare class ModelNotBoundError extends Error {
|
||||
readonly modelName: string;
|
||||
readonly violations: readonly BindingViolation[];
|
||||
constructor(modelName: string, violations: readonly BindingViolation[]);
|
||||
}
|
||||
export declare class ModelDisabledError extends Error {
|
||||
readonly modelName: string;
|
||||
constructor(modelName: string);
|
||||
}
|
||||
export declare class ReplayMissError extends Error {
|
||||
readonly key: string;
|
||||
constructor(key: string);
|
||||
}
|
||||
/** 模型调用的语义入参 —— 录制的就是它,不含任何凭证(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>;
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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';
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 declare class StaffNotFoundError extends Error {
|
||||
readonly staffId: StaffId;
|
||||
constructor(staffId: StaffId);
|
||||
}
|
||||
export declare class PositionNotFoundError extends Error {
|
||||
readonly positionCode: string;
|
||||
constructor(positionCode: string);
|
||||
}
|
||||
export declare class AccountTakenError extends Error {
|
||||
readonly account: string;
|
||||
constructor(account: string);
|
||||
}
|
||||
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
|
||||
export declare class DisableBlockedError extends Error {
|
||||
readonly check: DisableCheck;
|
||||
constructor(check: DisableCheck);
|
||||
}
|
||||
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>;
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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';
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 治理台路由契约(编码规范 §8.1 / §9.1)。
|
||||
* 治理台九功能不设后端 governance 模块:每个功能是对应模块的管理用例 + 本文件的路由组装(§3)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const listStaffRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/staff";
|
||||
readonly output: z.ZodObject<{
|
||||
staff: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>>;
|
||||
positions: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const createStaffRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/staff";
|
||||
readonly body: z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const disableStaffCheckRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/staff/:staffId/disable-check";
|
||||
readonly params: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const disableStaffRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/staff/:staffId/disable";
|
||||
readonly params: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
check: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const listGrantsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/positions/:positionId/grants";
|
||||
readonly params: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
grants: z.ZodArray<z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
action: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const setGrantsRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/positions/:positionId/grants";
|
||||
readonly params: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
grants: z.ZodArray<z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
action: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
grants: z.ZodArray<z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
action: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const LLM_PROVIDERS: readonly ["openai-compatible", "openai", "deepseek", "anthropic", "qwen"];
|
||||
export declare const llmProviderSchema: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
export type LlmProvider = z.infer<typeof llmProviderSchema>;
|
||||
/** 活动只见自定义模型名;Provider / URL / API-Key / Model 四元在系统侧绑定,活动不触厂商与密钥 */
|
||||
export declare const llmInstanceViewSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
provider: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
baseUrl: z.ZodString;
|
||||
model: z.ZodString;
|
||||
apiKeyBound: z.ZodBoolean;
|
||||
systemUse: z.ZodDefault<z.ZodBoolean>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
export type LlmInstanceView = z.infer<typeof llmInstanceViewSchema>;
|
||||
export declare const listLlmInstancesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/llm";
|
||||
readonly output: z.ZodObject<{
|
||||
instances: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
provider: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
baseUrl: z.ZodString;
|
||||
model: z.ZodString;
|
||||
apiKeyBound: z.ZodBoolean;
|
||||
systemUse: z.ZodDefault<z.ZodBoolean>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const upsertLlmInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm";
|
||||
readonly body: z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodString;
|
||||
provider: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
baseUrl: z.ZodString;
|
||||
model: z.ZodString;
|
||||
apiKey: z.ZodOptional<z.ZodString>;
|
||||
systemUse: z.ZodDefault<z.ZodBoolean>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
provider: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
baseUrl: z.ZodString;
|
||||
model: z.ZodString;
|
||||
apiKeyBound: z.ZodBoolean;
|
||||
systemUse: z.ZodDefault<z.ZodBoolean>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const probeLlmInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm/:id/probe";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 路由契约的形状(编码规范 §8.1 / §9.2)。
|
||||
*
|
||||
* 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。
|
||||
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
|
||||
*/
|
||||
import type { z } from 'zod';
|
||||
export declare const HTTP_METHODS: readonly ["GET", "POST", "PATCH", "DELETE"];
|
||||
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
/**
|
||||
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
|
||||
* body 只在 POST/PATCH 上有意义;GET 用 query。
|
||||
*/
|
||||
export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery extends z.ZodTypeAny = z.ZodTypeAny, TBody extends z.ZodTypeAny = z.ZodTypeAny, TOutput extends z.ZodTypeAny = z.ZodTypeAny> = {
|
||||
readonly method: HttpMethod;
|
||||
readonly path: string;
|
||||
readonly params?: TParams;
|
||||
readonly query?: TQuery;
|
||||
readonly body?: TBody;
|
||||
readonly output: TOutput;
|
||||
};
|
||||
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
|
||||
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny ? z.infer<R['body']> : never;
|
||||
/** 结构化错误:前端按 code 分辨,不解析文案 */
|
||||
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal"];
|
||||
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
||||
export type ApiError = {
|
||||
readonly code: ApiErrorCode;
|
||||
readonly message: string;
|
||||
readonly detail?: unknown;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './http.ts';
|
||||
export * from './workbench.ts';
|
||||
export * from './governance.ts';
|
||||
export * from './ingest.ts';
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 入站路由契约(编码规范 §8.1)。
|
||||
*
|
||||
* 通道入站 → 归属解析 → 统一事件流(OS 06)。MVP 的邮件通道以**模拟通道**接入:
|
||||
* mock 只允许出现在**外界**(邮件通道与外部系统),系统内部机制不得 mock(README MVP 完成判据)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** 一封入站邮件的归一形状 —— 真实邮件通道与模拟通道共用 */
|
||||
export declare const inboundMailSchema: z.ZodObject<{
|
||||
messageId: z.ZodString;
|
||||
threadId: z.ZodOptional<z.ZodString>;
|
||||
from: z.ZodString;
|
||||
to: z.ZodArray<z.ZodString>;
|
||||
cc: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
subject: z.ZodString;
|
||||
body: z.ZodString;
|
||||
receivedAt: z.ZodISODateTime;
|
||||
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type InboundMail = z.infer<typeof inboundMailSchema>;
|
||||
export declare const ingestMailRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/ingest/channel/:channelId/mail";
|
||||
readonly params: z.ZodObject<{
|
||||
channelId: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
messageId: z.ZodString;
|
||||
threadId: z.ZodOptional<z.ZodString>;
|
||||
from: z.ZodString;
|
||||
to: z.ZodArray<z.ZodString>;
|
||||
cc: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
subject: z.ZodString;
|
||||
body: z.ZodString;
|
||||
receivedAt: z.ZodISODateTime;
|
||||
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
eventId: z.core.$ZodBranded<z.ZodString, "EventId", "out">;
|
||||
resolution: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
outcome: z.ZodLiteral<"owned">;
|
||||
rule: z.ZodEnum<{
|
||||
thread: "thread";
|
||||
"listener-key": "listener-key";
|
||||
"address-window": "address-window";
|
||||
}>;
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
outcome: z.ZodLiteral<"unowned">;
|
||||
reason: z.ZodEnum<{
|
||||
"no-match": "no-match";
|
||||
ambiguous: "ambiguous";
|
||||
"extract-failed": "extract-failed";
|
||||
}>;
|
||||
fallbackPosition: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "PositionId", "out">>;
|
||||
}, z.core.$strip>], "outcome">;
|
||||
listenersHit: z.ZodNumber;
|
||||
triggersHit: z.ZodNumber;
|
||||
fellBackToMessage: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 工作台路由契约(编码规范 §8.1 / §9.1)。
|
||||
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const sessionRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/session";
|
||||
readonly output: z.ZodObject<{
|
||||
staff: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
grants: z.ZodArray<z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
action: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const healthRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/health";
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
version: z.ZodString;
|
||||
db: z.ZodEnum<{
|
||||
up: "up";
|
||||
down: "down";
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
+1486
File diff suppressed because it is too large
Load Diff
+187
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 契约七件(编码规范 §8.1;语义出处 OS 4.1,AM 01)。
|
||||
*
|
||||
* 契约是活动对调用方暴露的全部真相,也是对 Agent 机制的全部授权;
|
||||
* 两种范式同一契约格式 —— 这是「统一套执行契约」的锚点。
|
||||
* 契约必须在活动启动前冻结;运行中不得变更 —— 变更即新版本。
|
||||
*
|
||||
* ADR-003:**声明层 = JSON Schema 2020-12 子集**(活动声明是运行时数据,可 diff 可版本),
|
||||
* 代码层 = zod。本文件用 zod 描述「声明的形状」,声明里的 schema 字段本身是 JSON Schema。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** JSON Schema 2020-12 子集的运行时形态 —— 由 ajv 校验,不在编译期展开 */
|
||||
export declare const jsonSchemaSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
export type JsonSchema = z.infer<typeof jsonSchemaSchema>;
|
||||
export declare const contractInputSchema: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||
messageHint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type ContractInput = z.infer<typeof contractInputSchema>;
|
||||
export declare const outputPortSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type OutputPort = z.infer<typeof outputPortSchema>;
|
||||
export declare const outputPortsSchema: z.ZodObject<{
|
||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type OutputPorts = z.infer<typeof outputPortsSchema>;
|
||||
export declare const requiredDeliverableSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
}, z.core.$strip>;
|
||||
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
|
||||
export declare const acceptanceCriterionSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
level: z.ZodEnum<{
|
||||
L1: "L1";
|
||||
L2: "L2";
|
||||
L3: "L3";
|
||||
L4: "L4";
|
||||
L5: "L5";
|
||||
}>;
|
||||
predicate: z.ZodOptional<z.ZodString>;
|
||||
modelRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type AcceptanceCriterion = z.infer<typeof acceptanceCriterionSchema>;
|
||||
export declare const prohibitionSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type Prohibition = z.infer<typeof prohibitionSchema>;
|
||||
export declare const authorizationBoundarySchema: z.ZodObject<{
|
||||
discretion: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
preauthorizedActions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
tool: z.ZodString;
|
||||
operation: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
humanCallBudget: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
export type AuthorizationBoundary = z.infer<typeof authorizationBoundarySchema>;
|
||||
/** 预算四类上限 —— 由基座在环外强制,不得由活动代码自查自守(AM 10.1) */
|
||||
export declare const budgetCapsSchema: z.ZodObject<{
|
||||
steps: z.ZodNumber;
|
||||
tokens: z.ZodNumber;
|
||||
activeMinutes: z.ZodNumber;
|
||||
humanCalls: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
export type BudgetCaps = z.infer<typeof budgetCapsSchema>;
|
||||
export declare const resourceDeclarationSchema: z.ZodObject<{
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
version: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
skills: z.ZodDefault<z.ZodArray<z.core.$ZodBranded<z.ZodString, "SkillId", "out">>>;
|
||||
models: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
budget: z.ZodObject<{
|
||||
steps: z.ZodNumber;
|
||||
tokens: z.ZodNumber;
|
||||
activeMinutes: z.ZodNumber;
|
||||
humanCalls: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
export type ResourceDeclaration = z.infer<typeof resourceDeclarationSchema>;
|
||||
export declare const sevenPartContractSchema: z.ZodObject<{
|
||||
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
|
||||
version: z.ZodString;
|
||||
input: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||
messageHint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
requiredDeliverables: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
}, z.core.$strip>>>;
|
||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
level: z.ZodEnum<{
|
||||
L1: "L1";
|
||||
L2: "L2";
|
||||
L3: "L3";
|
||||
L4: "L4";
|
||||
L5: "L5";
|
||||
}>;
|
||||
predicate: z.ZodOptional<z.ZodString>;
|
||||
modelRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
authorization: z.ZodObject<{
|
||||
discretion: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
statement: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
preauthorizedActions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
tool: z.ZodString;
|
||||
operation: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
humanCallBudget: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
resources: z.ZodObject<{
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
version: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
skills: z.ZodDefault<z.ZodArray<z.core.$ZodBranded<z.ZodString, "SkillId", "out">>>;
|
||||
models: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
budget: z.ZodObject<{
|
||||
steps: z.ZodNumber;
|
||||
tokens: z.ZodNumber;
|
||||
activeMinutes: z.ZodNumber;
|
||||
humanCalls: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
export type SevenPartContract = z.infer<typeof sevenPartContractSchema>;
|
||||
/**
|
||||
* 完成时的发射集合 = 全部必发口 + 每个选择组各一口(AM 1.2)。
|
||||
* 纯函数,前后端同源:前端预演、后端裁决。
|
||||
*/
|
||||
export declare function emissionPortNames(ports: OutputPorts, selected: Readonly<Record<string, string>>): string[];
|
||||
/** 选择组必须恰选一口 —— 未选或选了组外的口即非法 */
|
||||
export declare function validateSelection(ports: OutputPorts, selected: Readonly<Record<string, string>>): string[];
|
||||
/** 验收等级不得谎报:L4 不得标注为 L1–L3;生成者不得自评(AM 12.1) */
|
||||
export declare function machineCheckable(criterion: AcceptanceCriterion): boolean;
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 统一事件标准(编码规范 §8.1;语义出处 OS 06)。
|
||||
*
|
||||
* 系统向活动只以统一信封抛四类事件;一条事件流,三级消费(任务监听 / 触发器 / 消息)。
|
||||
* 投递五诺(先持久后投递 / 同任务按序 / 至少一次 + 幂等 / 处理有回执 / 扇出与幂等)
|
||||
* 是 `event` 模块的实现承诺,不在本文件表达 —— 本文件只定信封的形状。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const EVENT_TYPES: readonly ["data", "time", "human", "control"];
|
||||
export declare const eventTypeSchema: z.ZodEnum<{
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
export type EventType = z.infer<typeof eventTypeSchema>;
|
||||
export declare const EVENT_TYPE_LABEL: Readonly<Record<EventType, string>>;
|
||||
/** 消费目标四类(OS 6.1):任务 / 触发器 / 工作流 / 消息收件岗位 */
|
||||
export declare const eventTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"trigger">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"workflow">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkflowId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"message-position">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
export type EventTarget = z.infer<typeof eventTargetSchema>;
|
||||
/** 起因:数据对象 / 卡 ID / 定时器 ID —— 「这条事件是被什么激起的」 */
|
||||
export declare const eventCauseSchema: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"ledger-write": "ledger-write";
|
||||
"card-reply": "card-reply";
|
||||
timer: "timer";
|
||||
inbound: "inbound";
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
}>;
|
||||
ref: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type EventCause = z.infer<typeof eventCauseSchema>;
|
||||
export declare const EVENT_SOURCES: readonly ["channel", "engine", "desk", "governance"];
|
||||
export declare const eventSourceKindSchema: z.ZodEnum<{
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
channel: "channel";
|
||||
desk: "desk";
|
||||
}>;
|
||||
export type EventSourceKind = z.infer<typeof eventSourceKindSchema>;
|
||||
export declare const eventSourceSchema: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
channel: "channel";
|
||||
desk: "desk";
|
||||
}>;
|
||||
channelId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>;
|
||||
detail: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type EventSource = z.infer<typeof eventSourceSchema>;
|
||||
/** 载荷:schema 化内容 + 原件指针(如邮件原文的存档地址) */
|
||||
export declare const eventPayloadSchema: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
originRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type EventPayload = z.infer<typeof eventPayloadSchema>;
|
||||
export declare const envelopeSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "EventId", "out">;
|
||||
type: z.ZodEnum<{
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
correlation: z.ZodObject<{
|
||||
target: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"trigger">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"workflow">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkflowId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"message-position">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
cause: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"ledger-write": "ledger-write";
|
||||
"card-reply": "card-reply";
|
||||
timer: "timer";
|
||||
inbound: "inbound";
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
}>;
|
||||
ref: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
keys: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
payload: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
originRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
occurredAt: z.ZodISODateTime;
|
||||
source: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
channel: "channel";
|
||||
desk: "desk";
|
||||
}>;
|
||||
channelId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>;
|
||||
detail: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
trace: z.ZodObject<{
|
||||
storeRef: z.ZodString;
|
||||
taskMemoryRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
export type Envelope = z.infer<typeof envelopeSchema>;
|
||||
/**
|
||||
* 尚未落存储的信封草稿 —— `publish` 的入参。
|
||||
*
|
||||
* `target` 在此**可空**:原始事件未必知道自己该被谁消费。归属解析(6.2)与三级消费判定
|
||||
* (任务监听 / 触发器 / 消息兜底,6.3)由 `event` 模块完成,并为每个命中目标生成一份
|
||||
* 独立投递 —— 投递出去的信封「关联 = 单目标」,这正是扇出与幂等承诺的形态。
|
||||
* `storeRef` 由 event adapter 落库时补。
|
||||
*/
|
||||
export declare const envelopeDraftSchema: z.ZodObject<{
|
||||
type: z.ZodEnum<{
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
payload: z.ZodObject<{
|
||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
originRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
source: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
channel: "channel";
|
||||
desk: "desk";
|
||||
}>;
|
||||
channelId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>;
|
||||
detail: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
correlation: z.ZodObject<{
|
||||
target: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"trigger">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"workflow">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkflowId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"message-position">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
cause: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"ledger-write": "ledger-write";
|
||||
"card-reply": "card-reply";
|
||||
timer: "timer";
|
||||
inbound: "inbound";
|
||||
governance: "governance";
|
||||
engine: "engine";
|
||||
}>;
|
||||
ref: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
keys: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
occurredAt: z.ZodISODateTime;
|
||||
id: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "EventId", "out">>;
|
||||
}, z.core.$strip>;
|
||||
export type EnvelopeDraft = z.infer<typeof envelopeDraftSchema>;
|
||||
/** 控制事件载荷:三语义 + 发起人 */
|
||||
export declare const controlPayloadSchema: z.ZodObject<{
|
||||
semantic: z.ZodEnum<{
|
||||
pause: "pause";
|
||||
halt: "halt";
|
||||
terminate: "terminate";
|
||||
}>;
|
||||
reason: z.ZodOptional<z.ZodString>;
|
||||
issuedBy: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type ControlPayload = z.infer<typeof controlPayloadSchema>;
|
||||
/** 时间事件载荷:哪个监听 / 定时器到点 */
|
||||
export declare const timePayloadSchema: z.ZodObject<{
|
||||
listenerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "ListenerId", "out">>;
|
||||
timerRef: z.ZodString;
|
||||
escalation: z.ZodOptional<z.ZodEnum<{
|
||||
nudge: "nudge";
|
||||
reroute: "reroute";
|
||||
escalate: "escalate";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type TimePayload = z.infer<typeof timePayloadSchema>;
|
||||
/** 数据事件载荷:域.对象.主键 的正式版写入 —— 草稿写不抛事件(OS 09) */
|
||||
export declare const dataPayloadSchema: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
primaryKey: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
||||
changed: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
operation: z.ZodEnum<{
|
||||
insert: "insert";
|
||||
update: "update";
|
||||
delete: "delete";
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
export type DataPayload = z.infer<typeof dataPayloadSchema>;
|
||||
/**
|
||||
* 处理语义:loop 事件即观察 / flow 事件即步的完成条件。
|
||||
* 这是**引擎层**的区分,交互层不可见(编码规范 §9 双层边界)。
|
||||
*/
|
||||
export declare const LISTENER_SEMANTICS: readonly ["observe", "complete-step"];
|
||||
export declare const listenerSemanticSchema: z.ZodEnum<{
|
||||
observe: "observe";
|
||||
"complete-step": "complete-step";
|
||||
}>;
|
||||
export type ListenerSemantic = z.infer<typeof listenerSemanticSchema>;
|
||||
export declare const listenerSpecSchema: z.ZodObject<{
|
||||
origin: z.ZodEnum<{
|
||||
static: "static";
|
||||
dynamic: "dynamic";
|
||||
}>;
|
||||
eventType: z.ZodEnum<{
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
keyFilter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
condition: z.ZodOptional<z.ZodString>;
|
||||
semantic: z.ZodEnum<{
|
||||
observe: "observe";
|
||||
"complete-step": "complete-step";
|
||||
}>;
|
||||
deadline: z.ZodString;
|
||||
escalation: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
||||
nudge: "nudge";
|
||||
reroute: "reroute";
|
||||
escalate: "escalate";
|
||||
}>>>;
|
||||
}, z.core.$strip>;
|
||||
export type ListenerSpec = z.infer<typeof listenerSpecSchema>;
|
||||
export declare const registeredListenerSchema: z.ZodObject<{
|
||||
origin: z.ZodEnum<{
|
||||
static: "static";
|
||||
dynamic: "dynamic";
|
||||
}>;
|
||||
eventType: z.ZodEnum<{
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>;
|
||||
keyFilter: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
condition: z.ZodOptional<z.ZodString>;
|
||||
semantic: z.ZodEnum<{
|
||||
observe: "observe";
|
||||
"complete-step": "complete-step";
|
||||
}>;
|
||||
deadline: z.ZodString;
|
||||
escalation: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
||||
nudge: "nudge";
|
||||
reroute: "reroute";
|
||||
escalate: "escalate";
|
||||
}>>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "ListenerId", "out">;
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
registeredAt: z.ZodISODateTime;
|
||||
expiresAt: z.ZodISODateTime;
|
||||
state: z.ZodEnum<{
|
||||
armed: "armed";
|
||||
fired: "fired";
|
||||
expired: "expired";
|
||||
cancelled: "cancelled";
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
export type RegisteredListener = z.infer<typeof registeredListenerSchema>;
|
||||
export declare const deliverySchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "DeliveryId", "out">;
|
||||
eventId: z.core.$ZodBranded<z.ZodString, "EventId", "out">;
|
||||
target: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"trigger">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"workflow">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkflowId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"message-position">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
attempt: z.ZodNumber;
|
||||
state: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
delivered: "delivered";
|
||||
failed: "failed";
|
||||
"dead-letter": "dead-letter";
|
||||
}>;
|
||||
lastError: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type Delivery = z.infer<typeof deliverySchema>;
|
||||
/** 匹配次序确定、逐级短路:① 线程精确 ② 关联键命中监听 ③ 地址 + 时间窗 */
|
||||
export declare const OWNERSHIP_RULES: readonly ["thread", "listener-key", "address-window"];
|
||||
export declare const ownershipRuleSchema: z.ZodEnum<{
|
||||
thread: "thread";
|
||||
"listener-key": "listener-key";
|
||||
"address-window": "address-window";
|
||||
}>;
|
||||
export type OwnershipRule = z.infer<typeof ownershipRuleSchema>;
|
||||
export declare const ownershipResolutionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
outcome: z.ZodLiteral<"owned">;
|
||||
rule: z.ZodEnum<{
|
||||
thread: "thread";
|
||||
"listener-key": "listener-key";
|
||||
"address-window": "address-window";
|
||||
}>;
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
outcome: z.ZodLiteral<"unowned">;
|
||||
reason: z.ZodEnum<{
|
||||
"no-match": "no-match";
|
||||
ambiguous: "ambiguous";
|
||||
"extract-failed": "extract-failed";
|
||||
}>;
|
||||
fallbackPosition: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "PositionId", "out">>;
|
||||
}, z.core.$strip>], "outcome">;
|
||||
export type OwnershipResolution = z.infer<typeof ownershipResolutionSchema>;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 品牌 ID(编码规范 §8.1)。
|
||||
*
|
||||
* 每一类 ID 有自己的名义类型 —— TaskId 传不进要 WorkflowId 的位置,编译期就拦下。
|
||||
* 运行时形态一律是非空字符串;生成归 adapter(clock / uuid 走 port,domain 零随机)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const staffIdSchema: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
export type StaffId = z.infer<typeof staffIdSchema>;
|
||||
export declare const positionIdSchema: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
export type PositionId = z.infer<typeof positionIdSchema>;
|
||||
export declare const processPackageIdSchema: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||
export type ProcessPackageId = z.infer<typeof processPackageIdSchema>;
|
||||
export declare const workflowIdSchema: z.core.$ZodBranded<z.ZodString, "WorkflowId", "out">;
|
||||
export type WorkflowId = z.infer<typeof workflowIdSchema>;
|
||||
export declare const nodeIdSchema: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
export type NodeId = z.infer<typeof nodeIdSchema>;
|
||||
export declare const triggerIdSchema: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
export type TriggerId = z.infer<typeof triggerIdSchema>;
|
||||
export declare const approvalIdSchema: z.core.$ZodBranded<z.ZodString, "ApprovalId", "out">;
|
||||
export type ApprovalId = z.infer<typeof approvalIdSchema>;
|
||||
export declare const activityIdSchema: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
|
||||
export type ActivityId = z.infer<typeof activityIdSchema>;
|
||||
export declare const taskIdSchema: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
export type TaskId = z.infer<typeof taskIdSchema>;
|
||||
export declare const deliverableIdSchema: z.core.$ZodBranded<z.ZodString, "DeliverableId", "out">;
|
||||
export type DeliverableId = z.infer<typeof deliverableIdSchema>;
|
||||
export declare const stageIdSchema: z.core.$ZodBranded<z.ZodString, "StageId", "out">;
|
||||
export type StageId = z.infer<typeof stageIdSchema>;
|
||||
export declare const cardIdSchema: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
export type CardId = z.infer<typeof cardIdSchema>;
|
||||
export declare const workOrderIdSchema: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
export type WorkOrderId = z.infer<typeof workOrderIdSchema>;
|
||||
export declare const eventIdSchema: z.core.$ZodBranded<z.ZodString, "EventId", "out">;
|
||||
export type EventId = z.infer<typeof eventIdSchema>;
|
||||
export declare const deliveryIdSchema: z.core.$ZodBranded<z.ZodString, "DeliveryId", "out">;
|
||||
export type DeliveryId = z.infer<typeof deliveryIdSchema>;
|
||||
export declare const listenerIdSchema: z.core.$ZodBranded<z.ZodString, "ListenerId", "out">;
|
||||
export type ListenerId = z.infer<typeof listenerIdSchema>;
|
||||
export declare const messageIdSchema: z.core.$ZodBranded<z.ZodString, "MessageId", "out">;
|
||||
export type MessageId = z.infer<typeof messageIdSchema>;
|
||||
export declare const toolIdSchema: z.core.$ZodBranded<z.ZodString, "ToolId", "out">;
|
||||
export type ToolId = z.infer<typeof toolIdSchema>;
|
||||
export declare const llmInstanceIdSchema: z.core.$ZodBranded<z.ZodString, "LlmInstanceId", "out">;
|
||||
export type LlmInstanceId = z.infer<typeof llmInstanceIdSchema>;
|
||||
export declare const channelIdSchema: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
|
||||
export type ChannelId = z.infer<typeof channelIdSchema>;
|
||||
export declare const skillIdSchema: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||
export type SkillId = z.infer<typeof skillIdSchema>;
|
||||
export declare const memoryEntryIdSchema: z.core.$ZodBranded<z.ZodString, "MemoryEntryId", "out">;
|
||||
export type MemoryEntryId = z.infer<typeof memoryEntryIdSchema>;
|
||||
export declare const actionIntentIdSchema: z.core.$ZodBranded<z.ZodString, "ActionIntentId", "out">;
|
||||
export type ActionIntentId = z.infer<typeof actionIntentIdSchema>;
|
||||
export declare const minuteIdSchema: z.core.$ZodBranded<z.ZodString, "MinuteId", "out">;
|
||||
export type MinuteId = z.infer<typeof minuteIdSchema>;
|
||||
+11
-1
@@ -3,4 +3,14 @@
|
||||
* 只有 zod schema、常量、品牌类型与推导类型,零实现。
|
||||
* 修改本包任何导出 = 契约变更(§12.4)。
|
||||
*/
|
||||
export declare const CONTRACTS_VERSION = "0.0.0-m0";
|
||||
export declare const CONTRACTS_VERSION = "0.1.0-m1";
|
||||
export * from './id.ts';
|
||||
export * from './terminal.ts';
|
||||
export * from './envelope.ts';
|
||||
export * from './card.ts';
|
||||
export * from './contract7.ts';
|
||||
export * from './ledger.ts';
|
||||
export * from './process-package.ts';
|
||||
export * from './manifest.ts';
|
||||
export * from './org.ts';
|
||||
export * from './api/index.ts';
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 数据与台账(编码规范 §8.1 / §7.2;语义出处 OS 04 / 09,AM 10.3 / 11.1)。
|
||||
*
|
||||
* 三级寻址「数据域.数据对象.字段」+ 通用事务性接口 + 写三分(系统写 / 草稿写 / 放行写)。
|
||||
* 写三分是 `ledger` 出口上的**三个不同方法**,不是一个带 flag 的方法 —— 调用方拿不到「绕门」的形态。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* 数据域:活动自持域用活动名,共享域固定为 `shared`(AM 11.3 暗耦合禁令)。
|
||||
* 活动间不得共享自持域的数据表;跨活动的事实只有两条路 —— 输出口载荷,或共享域。
|
||||
*/
|
||||
export declare const SHARED_DOMAIN = "shared";
|
||||
export declare const ledgerAddressSchema: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type LedgerAddress = z.infer<typeof ledgerAddressSchema>;
|
||||
export declare function formatAddress(address: LedgerAddress): string;
|
||||
export declare function parseAddress(text: string): LedgerAddress;
|
||||
export declare function isSharedDomain(address: LedgerAddress): boolean;
|
||||
/**
|
||||
* 字段类型 —— 台账自生长的类型格。
|
||||
* 「同名异型取能兼容双方的最宽类型」(放宽即增,不破坏);语义互斥才拦下(OS 04)。
|
||||
*/
|
||||
export declare const LEDGER_FIELD_TYPES: readonly ["text", "number", "integer", "boolean", "date", "timestamp", "json"];
|
||||
export declare const ledgerFieldTypeSchema: z.ZodEnum<{
|
||||
number: "number";
|
||||
boolean: "boolean";
|
||||
json: "json";
|
||||
text: "text";
|
||||
date: "date";
|
||||
integer: "integer";
|
||||
timestamp: "timestamp";
|
||||
}>;
|
||||
export type LedgerFieldType = z.infer<typeof ledgerFieldTypeSchema>;
|
||||
/** 字段使用声明 —— 声明的是**活动要用什么字段**,不是对象该定义什么字段 */
|
||||
export declare const fieldDeclarationSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
number: "number";
|
||||
boolean: "boolean";
|
||||
json: "json";
|
||||
text: "text";
|
||||
date: "date";
|
||||
integer: "integer";
|
||||
timestamp: "timestamp";
|
||||
}>;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
|
||||
export declare const objectDeclarationSchema: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
fields: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
number: "number";
|
||||
boolean: "boolean";
|
||||
json: "json";
|
||||
text: "text";
|
||||
date: "date";
|
||||
integer: "integer";
|
||||
timestamp: "timestamp";
|
||||
}>;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||
masked: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
|
||||
export declare const ledgerRowSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
export type LedgerRow = z.infer<typeof ledgerRowSchema>;
|
||||
export declare const ledgerKeySchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
||||
export type LedgerKey = z.infer<typeof ledgerKeySchema>;
|
||||
export declare const LEDGER_OPERATIONS: readonly ["insert", "update", "upsert", "delete"];
|
||||
export declare const ledgerOperationSchema: z.ZodEnum<{
|
||||
insert: "insert";
|
||||
update: "update";
|
||||
delete: "delete";
|
||||
upsert: "upsert";
|
||||
}>;
|
||||
export type LedgerOperation = z.infer<typeof ledgerOperationSchema>;
|
||||
export declare const ledgerMutationSchema: z.ZodObject<{
|
||||
address: z.ZodObject<{
|
||||
object: z.ZodString;
|
||||
domain: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
operation: z.ZodEnum<{
|
||||
insert: "insert";
|
||||
update: "update";
|
||||
delete: "delete";
|
||||
upsert: "upsert";
|
||||
}>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
||||
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
}, z.core.$strip>;
|
||||
export type LedgerMutation = z.infer<typeof ledgerMutationSchema>;
|
||||
/** 查询:简单等值过滤 + 排序 + 分页(治理台数据管理的记录浏览也走这里) */
|
||||
export declare const ledgerQuerySchema: z.ZodObject<{
|
||||
address: z.ZodObject<{
|
||||
object: z.ZodString;
|
||||
domain: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
where: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
|
||||
orderBy: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
limit: z.ZodDefault<z.ZodNumber>;
|
||||
offset: z.ZodDefault<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
export type LedgerQuery = z.infer<typeof ledgerQuerySchema>;
|
||||
export declare const ledgerPageSchema: z.ZodObject<{
|
||||
rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
total: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
export type LedgerPage = z.infer<typeof ledgerPageSchema>;
|
||||
/**
|
||||
* ① system —— 系统写:入站归一、流转记录、留痕。登记世界已发生的事实,不过放行门,
|
||||
* 写入正式版并**即时产生数据事件**。
|
||||
* ② draft —— 草稿写:任务级草稿层(读己之写:本任务可见、正式版不可见、**不产生数据事件**)。
|
||||
* ③ release —— 放行写:草稿层合并入正式版,**此刻才产生数据事件**。
|
||||
*/
|
||||
export declare const WRITE_KINDS: readonly ["system", "draft", "release"];
|
||||
export declare const writeKindSchema: z.ZodEnum<{
|
||||
draft: "draft";
|
||||
system: "system";
|
||||
release: "release";
|
||||
}>;
|
||||
export type WriteKind = z.infer<typeof writeKindSchema>;
|
||||
export declare const WRITE_KIND_LABEL: Readonly<Record<WriteKind, string>>;
|
||||
/** 三分的不变量,机检用:只有 draft 不抛数据事件 */
|
||||
export declare function emitsDataEvent(kind: WriteKind): boolean;
|
||||
/** 任务视图 = 正式版 + 本任务草稿层;环内推进读它,不依赖事件(OS 09) */
|
||||
export declare const taskViewRequestSchema: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
query: z.ZodObject<{
|
||||
address: z.ZodObject<{
|
||||
object: z.ZodString;
|
||||
domain: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
where: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
|
||||
orderBy: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
limit: z.ZodDefault<z.ZodNumber>;
|
||||
offset: z.ZodDefault<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
export type TaskViewRequest = z.infer<typeof taskViewRequestSchema>;
|
||||
export declare const draftPatchSchema: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
address: z.ZodObject<{
|
||||
object: z.ZodString;
|
||||
domain: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
||||
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
tombstone: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
export type DraftPatch = z.infer<typeof draftPatchSchema>;
|
||||
/** 数据冲突检查(落地预检第五项):他单已改同一字段即预检不过(OS 7.3) */
|
||||
export declare const draftConflictSchema: z.ZodObject<{
|
||||
address: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
key: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
||||
field: z.ZodString;
|
||||
draftValue: z.ZodUnknown;
|
||||
officialValue: z.ZodUnknown;
|
||||
baselineValue: z.ZodUnknown;
|
||||
}, z.core.$strip>;
|
||||
export type DraftConflict = z.infer<typeof draftConflictSchema>;
|
||||
export declare const ACTION_INTENT_STATES: readonly ["drafted", "prechecked", "landed", "failed", "abandoned"];
|
||||
export declare const actionIntentStateSchema: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
drafted: "drafted";
|
||||
prechecked: "prechecked";
|
||||
landed: "landed";
|
||||
abandoned: "abandoned";
|
||||
}>;
|
||||
export type ActionIntentState = z.infer<typeof actionIntentStateSchema>;
|
||||
export declare const actionIntentSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
tool: z.ZodString;
|
||||
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
idempotencyKey: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
drafted: "drafted";
|
||||
prechecked: "prechecked";
|
||||
landed: "landed";
|
||||
abandoned: "abandoned";
|
||||
}>;
|
||||
label: z.ZodString;
|
||||
detail: z.ZodOptional<z.ZodString>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type ActionIntent = z.infer<typeof actionIntentSchema>;
|
||||
+1263
File diff suppressed because it is too large
Load Diff
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。
|
||||
*
|
||||
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
|
||||
* 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。
|
||||
* 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const positionSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type Position = z.infer<typeof positionSchema>;
|
||||
export declare const STAFF_STATES: readonly ["active", "disabled"];
|
||||
export declare const staffStateSchema: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
export type StaffState = z.infer<typeof staffStateSchema>;
|
||||
export declare const staffSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type Staff = z.infer<typeof staffSchema>;
|
||||
export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"];
|
||||
export declare const grantActionSchema: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
export type GrantAction = z.infer<typeof grantActionSchema>;
|
||||
export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
|
||||
/** 治理台九功能 —— 授权客体之一 */
|
||||
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
||||
export declare const governanceFunctionSchema: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
export type GovernanceFunction = z.infer<typeof governanceFunctionSchema>;
|
||||
export declare const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>>;
|
||||
export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
export type GrantObject = z.infer<typeof grantObjectSchema>;
|
||||
export declare const grantSchema: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
action: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
export type Grant = z.infer<typeof grantSchema>;
|
||||
/** 员工停用前,其在途任务与待答卡须先改派完毕 */
|
||||
export declare const disableCheckSchema: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type DisableCheck = z.infer<typeof disableCheckSchema>;
|
||||
export declare const sessionSchema: z.ZodObject<{
|
||||
staff: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
grants: z.ZodArray<z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"governance">;
|
||||
function: z.ZodEnum<{
|
||||
data: "data";
|
||||
task: "task";
|
||||
trigger: "trigger";
|
||||
system: "system";
|
||||
tool: "tool";
|
||||
activity: "activity";
|
||||
staff: "staff";
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
action: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 流程包 DSL 与触发器配置(编码规范 §8.1;语义出处 OS 03)。
|
||||
*
|
||||
* 流程包 = 调用接口 + 两种活动(声明)+ 审批点 + 两种网关;工作流 = 其运行时实例。
|
||||
* 触发器与流程包**平级**:不入流程包结构、不随流程包版本,配置独立存放。
|
||||
*
|
||||
* 编排铁律:**连线即映射,画图即校验,不闭合不得发布** —— 存在未完成边、未满足必填
|
||||
* 或类型冲突的流程包可存草稿、不得发布。校验算法在 `process` 的 domain,本文件只定形状。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const OWNER_STRATEGIES: readonly ["assigned", "sole", "rotation", "follow-source"];
|
||||
export declare const ownerStrategySchema: z.ZodEnum<{
|
||||
assigned: "assigned";
|
||||
sole: "sole";
|
||||
rotation: "rotation";
|
||||
"follow-source": "follow-source";
|
||||
}>;
|
||||
export type OwnerStrategy = z.infer<typeof ownerStrategySchema>;
|
||||
export declare const OWNER_STRATEGY_LABEL: Readonly<Record<OwnerStrategy, string>>;
|
||||
export declare const ownerSpecSchema: z.ZodObject<{
|
||||
position: z.ZodString;
|
||||
strategy: z.ZodDefault<z.ZodEnum<{
|
||||
assigned: "assigned";
|
||||
sole: "sole";
|
||||
rotation: "rotation";
|
||||
"follow-source": "follow-source";
|
||||
}>>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>;
|
||||
export type OwnerSpec = z.infer<typeof ownerSpecSchema>;
|
||||
/**
|
||||
* 映射来源四类:上游输出口载荷字段 / 流程变量 / 起单载荷 / 常量;
|
||||
* 允许 S-FEEL 表达式做转换(与触发器字段映射同一形式)。
|
||||
*/
|
||||
export declare const mappingSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
export type MappingSource = z.infer<typeof mappingSourceSchema>;
|
||||
export declare const fieldMappingSchema: z.ZodObject<{
|
||||
target: z.ZodString;
|
||||
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>;
|
||||
export type FieldMapping = z.infer<typeof fieldMappingSchema>;
|
||||
export declare const edgeSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
from: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
to: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
condition: z.ZodOptional<z.ZodString>;
|
||||
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
target: z.ZodString;
|
||||
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
reopen: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
export type Edge = z.infer<typeof edgeSchema>;
|
||||
export declare const failureDispositionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"terminate">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"reroute">;
|
||||
edgeId: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"escalate">;
|
||||
approvalNodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"tolerate">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
export type FailureDisposition = z.infer<typeof failureDispositionSchema>;
|
||||
export declare const FAILURE_DISPOSITION_LABEL: Readonly<Record<FailureDisposition['kind'], string>>;
|
||||
export declare const nudgeLadderSchema: z.ZodObject<{
|
||||
deadline: z.ZodString;
|
||||
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
after: z.ZodString;
|
||||
action: z.ZodEnum<{
|
||||
nudge: "nudge";
|
||||
escalate: "escalate";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type NudgeLadder = z.infer<typeof nudgeLadderSchema>;
|
||||
export declare const GATEWAY_KINDS: readonly ["parallel", "exclusive"];
|
||||
export declare const gatewayKindSchema: z.ZodEnum<{
|
||||
parallel: "parallel";
|
||||
exclusive: "exclusive";
|
||||
}>;
|
||||
export type GatewayKind = z.infer<typeof gatewayKindSchema>;
|
||||
export declare const GATEWAY_KIND_LABEL: Readonly<Record<GatewayKind, string>>;
|
||||
export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"start">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"end">;
|
||||
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"activity">;
|
||||
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
|
||||
activityVersion: z.ZodOptional<z.ZodString>;
|
||||
owner: z.ZodObject<{
|
||||
position: z.ZodString;
|
||||
strategy: z.ZodDefault<z.ZodEnum<{
|
||||
assigned: "assigned";
|
||||
sole: "sole";
|
||||
rotation: "rotation";
|
||||
"follow-source": "follow-source";
|
||||
}>>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>;
|
||||
failure: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"terminate">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"reroute">;
|
||||
edgeId: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"escalate">;
|
||||
approvalNodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"tolerate">;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
message: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"approval">;
|
||||
approver: z.ZodObject<{
|
||||
position: z.ZodString;
|
||||
strategy: z.ZodDefault<z.ZodEnum<{
|
||||
assigned: "assigned";
|
||||
sole: "sole";
|
||||
rotation: "rotation";
|
||||
"follow-source": "follow-source";
|
||||
}>>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>;
|
||||
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"reopen">;
|
||||
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
target: z.ZodString;
|
||||
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"terminate">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
nudge: z.ZodOptional<z.ZodObject<{
|
||||
deadline: z.ZodString;
|
||||
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
after: z.ZodString;
|
||||
action: z.ZodEnum<{
|
||||
nudge: "nudge";
|
||||
escalate: "escalate";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
message: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"gateway">;
|
||||
gateway: z.ZodEnum<{
|
||||
parallel: "parallel";
|
||||
exclusive: "exclusive";
|
||||
}>;
|
||||
mode: z.ZodEnum<{
|
||||
join: "join";
|
||||
fork: "fork";
|
||||
}>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
export type ProcessNode = z.infer<typeof processNodeSchema>;
|
||||
export declare const callInterfaceSchema: z.ZodObject<{
|
||||
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>;
|
||||
export type CallInterface = z.infer<typeof callInterfaceSchema>;
|
||||
export declare const PROCESS_PACKAGE_STATES: readonly ["draft", "published", "retired"];
|
||||
export declare const processPackageStateSchema: z.ZodEnum<{
|
||||
draft: "draft";
|
||||
published: "published";
|
||||
retired: "retired";
|
||||
}>;
|
||||
export type ProcessPackageState = z.infer<typeof processPackageStateSchema>;
|
||||
export declare const processPackageSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||
name: z.ZodString;
|
||||
version: z.ZodNumber;
|
||||
state: z.ZodEnum<{
|
||||
draft: "draft";
|
||||
published: "published";
|
||||
retired: "retired";
|
||||
}>;
|
||||
callInterface: z.ZodObject<{
|
||||
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>;
|
||||
nodes: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"start">;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"end">;
|
||||
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"activity">;
|
||||
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
|
||||
activityVersion: z.ZodOptional<z.ZodString>;
|
||||
owner: z.ZodObject<{
|
||||
position: z.ZodString;
|
||||
strategy: z.ZodDefault<z.ZodEnum<{
|
||||
assigned: "assigned";
|
||||
sole: "sole";
|
||||
rotation: "rotation";
|
||||
"follow-source": "follow-source";
|
||||
}>>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>;
|
||||
failure: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"terminate">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"reroute">;
|
||||
edgeId: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"escalate">;
|
||||
approvalNodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"tolerate">;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
message: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"approval">;
|
||||
approver: z.ZodObject<{
|
||||
position: z.ZodString;
|
||||
strategy: z.ZodDefault<z.ZodEnum<{
|
||||
assigned: "assigned";
|
||||
sole: "sole";
|
||||
rotation: "rotation";
|
||||
"follow-source": "follow-source";
|
||||
}>>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>;
|
||||
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"reopen">;
|
||||
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
target: z.ZodString;
|
||||
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"terminate">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
nudge: z.ZodOptional<z.ZodObject<{
|
||||
deadline: z.ZodString;
|
||||
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
after: z.ZodString;
|
||||
action: z.ZodEnum<{
|
||||
nudge: "nudge";
|
||||
escalate: "escalate";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
message: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"gateway">;
|
||||
gateway: z.ZodEnum<{
|
||||
parallel: "parallel";
|
||||
exclusive: "exclusive";
|
||||
}>;
|
||||
mode: z.ZodEnum<{
|
||||
join: "join";
|
||||
fork: "fork";
|
||||
}>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
label: z.ZodString;
|
||||
layout: z.ZodOptional<z.ZodObject<{
|
||||
row: z.ZodNumber;
|
||||
col: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
edges: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
from: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
to: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
|
||||
condition: z.ZodOptional<z.ZodString>;
|
||||
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
target: z.ZodString;
|
||||
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
reopen: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>>;
|
||||
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>>>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type ProcessPackage = z.infer<typeof processPackageSchema>;
|
||||
export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "gateway-unbalanced", "join-ambiguous-source", "tolerate-required-dependency", "unknown-activity", "missing-owner-position"];
|
||||
export declare const closureViolationKindSchema: z.ZodEnum<{
|
||||
"edge-unmapped": "edge-unmapped";
|
||||
"required-unmapped": "required-unmapped";
|
||||
"type-conflict": "type-conflict";
|
||||
"dangling-node": "dangling-node";
|
||||
"gateway-unbalanced": "gateway-unbalanced";
|
||||
"join-ambiguous-source": "join-ambiguous-source";
|
||||
"tolerate-required-dependency": "tolerate-required-dependency";
|
||||
"unknown-activity": "unknown-activity";
|
||||
"missing-owner-position": "missing-owner-position";
|
||||
}>;
|
||||
export type ClosureViolationKind = z.infer<typeof closureViolationKindSchema>;
|
||||
export declare const closureViolationSchema: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"edge-unmapped": "edge-unmapped";
|
||||
"required-unmapped": "required-unmapped";
|
||||
"type-conflict": "type-conflict";
|
||||
"dangling-node": "dangling-node";
|
||||
"gateway-unbalanced": "gateway-unbalanced";
|
||||
"join-ambiguous-source": "join-ambiguous-source";
|
||||
"tolerate-required-dependency": "tolerate-required-dependency";
|
||||
"unknown-activity": "unknown-activity";
|
||||
"missing-owner-position": "missing-owner-position";
|
||||
}>;
|
||||
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
|
||||
edgeId: z.ZodOptional<z.ZodString>;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
message: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type ClosureViolation = z.infer<typeof closureViolationSchema>;
|
||||
export declare const closureReportSchema: z.ZodObject<{
|
||||
publishable: z.ZodBoolean;
|
||||
violations: z.ZodArray<z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"edge-unmapped": "edge-unmapped";
|
||||
"required-unmapped": "required-unmapped";
|
||||
"type-conflict": "type-conflict";
|
||||
"dangling-node": "dangling-node";
|
||||
"gateway-unbalanced": "gateway-unbalanced";
|
||||
"join-ambiguous-source": "join-ambiguous-source";
|
||||
"tolerate-required-dependency": "tolerate-required-dependency";
|
||||
"unknown-activity": "unknown-activity";
|
||||
"missing-owner-position": "missing-owner-position";
|
||||
}>;
|
||||
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
|
||||
edgeId: z.ZodOptional<z.ZodString>;
|
||||
field: z.ZodOptional<z.ZodString>;
|
||||
message: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ClosureReport = z.infer<typeof closureReportSchema>;
|
||||
export declare const TRIGGER_KINDS: readonly ["periodic", "event", "registration"];
|
||||
export declare const triggerKindSchema: z.ZodEnum<{
|
||||
periodic: "periodic";
|
||||
event: "event";
|
||||
registration: "registration";
|
||||
}>;
|
||||
export type TriggerKind = z.infer<typeof triggerKindSchema>;
|
||||
export declare const TRIGGER_KIND_LABEL: Readonly<Record<TriggerKind, string>>;
|
||||
export declare const triggerConfigSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
name: z.ZodString;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
processPackageId: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
target: z.ZodString;
|
||||
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
from: z.ZodLiteral<"output-port">;
|
||||
port: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"workflow-var">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"initiation">;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"constant">;
|
||||
value: z.ZodUnknown;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
from: z.ZodLiteral<"expression">;
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
spec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"periodic">;
|
||||
mode: z.ZodLiteral<"calendar">;
|
||||
calendar: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"periodic">;
|
||||
mode: z.ZodLiteral<"data-calendar">;
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
dateField: z.ZodString;
|
||||
offset: z.ZodString;
|
||||
filter: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"event">;
|
||||
rule: z.ZodString;
|
||||
eventType: z.ZodDefault<z.ZodEnum<{
|
||||
human: "human";
|
||||
data: "data";
|
||||
time: "time";
|
||||
control: "control";
|
||||
}>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
kind: z.ZodLiteral<"registration">;
|
||||
formHint: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
}, z.core.$strip>;
|
||||
export type TriggerConfig = z.infer<typeof triggerConfigSchema>;
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 终态五名与非终态注记(编码规范 §8.1;语义出处 OS 4.3 / AM 1.3)。
|
||||
*
|
||||
* 终态由**系统**裁定,封闭枚举,活动与模型不得增删。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** 终态五名 —— 唯一成功终态是 released(放行通过且落地收敛,输出口已发射) */
|
||||
export declare const TERMINAL_STATES: readonly ["released", "blocked", "stalled", "exhausted", "terminated"];
|
||||
export declare const terminalStateSchema: z.ZodEnum<{
|
||||
released: "released";
|
||||
blocked: "blocked";
|
||||
stalled: "stalled";
|
||||
exhausted: "exhausted";
|
||||
terminated: "terminated";
|
||||
}>;
|
||||
export type TerminalState = z.infer<typeof terminalStateSchema>;
|
||||
export declare const TERMINAL_STATE_LABEL: Readonly<Record<TerminalState, string>>;
|
||||
/** 触发者 —— OS 4.3 的第三列 */
|
||||
export declare const TERMINAL_STATE_TRIGGER: Readonly<Record<TerminalState, string>>;
|
||||
export declare function isSuccessTerminal(state: TerminalState): boolean;
|
||||
/** 驳回:放行卡的环内回转,任务继续 —— redo 按原要求重做 / revise 带意见迭代 */
|
||||
export declare const REJECTION_KINDS: readonly ["redo", "revise"];
|
||||
export declare const rejectionKindSchema: z.ZodEnum<{
|
||||
redo: "redo";
|
||||
revise: "revise";
|
||||
}>;
|
||||
export type RejectionKind = z.infer<typeof rejectionKindSchema>;
|
||||
export declare const REJECTION_KIND_LABEL: Readonly<Record<RejectionKind, string>>;
|
||||
/** 控制三语义(OS 06):暂停 = 可恢复冻结 · 叫停 = 负责人紧急中断 · 终止 = 不可恢复关闭 */
|
||||
export declare const CONTROL_SEMANTICS: readonly ["pause", "halt", "terminate"];
|
||||
export declare const controlSemanticSchema: z.ZodEnum<{
|
||||
pause: "pause";
|
||||
halt: "halt";
|
||||
terminate: "terminate";
|
||||
}>;
|
||||
export type ControlSemantic = z.infer<typeof controlSemanticSchema>;
|
||||
export declare const CONTROL_SEMANTIC_LABEL: Readonly<Record<ControlSemantic, string>>;
|
||||
/**
|
||||
* 任务运行态 —— 与终态分层:终态是收场名,运行态是「此刻在干嘛」。
|
||||
* landing-blocked(落地受阻)是运行态不是终态,处置后仍收敛于终态名录(OS 7.3)。
|
||||
*/
|
||||
export declare const TASK_RUNTIME_STATES: readonly ["running", "awaiting-human", "awaiting-event", "suspended", "landing-blocked", "terminal"];
|
||||
export declare const taskRuntimeStateSchema: z.ZodEnum<{
|
||||
running: "running";
|
||||
"awaiting-human": "awaiting-human";
|
||||
"awaiting-event": "awaiting-event";
|
||||
suspended: "suspended";
|
||||
"landing-blocked": "landing-blocked";
|
||||
terminal: "terminal";
|
||||
}>;
|
||||
export type TaskRuntimeState = z.infer<typeof taskRuntimeStateSchema>;
|
||||
export declare const TASK_RUNTIME_STATE_LABEL: Readonly<Record<TaskRuntimeState, string>>;
|
||||
export declare const WORKFLOW_STATES: readonly ["running", "waiting", "completed", "terminated", "abnormal"];
|
||||
export declare const workflowStateSchema: z.ZodEnum<{
|
||||
terminated: "terminated";
|
||||
running: "running";
|
||||
waiting: "waiting";
|
||||
completed: "completed";
|
||||
abnormal: "abnormal";
|
||||
}>;
|
||||
export type WorkflowState = z.infer<typeof workflowStateSchema>;
|
||||
export declare const WORKFLOW_STATE_LABEL: Readonly<Record<WorkflowState, string>>;
|
||||
export declare const ATTRIBUTION_SIDES: readonly ["machine", "human", "human-verdict", "world-wait"];
|
||||
export declare const attributionSideSchema: z.ZodEnum<{
|
||||
machine: "machine";
|
||||
human: "human";
|
||||
"human-verdict": "human-verdict";
|
||||
"world-wait": "world-wait";
|
||||
}>;
|
||||
export type AttributionSide = z.infer<typeof attributionSideSchema>;
|
||||
export declare const ATTRIBUTION_SIDE_LABEL: Readonly<Record<AttributionSide, string>>;
|
||||
export declare const ACCEPTANCE_LEVELS: readonly ["L1", "L2", "L3", "L4", "L5"];
|
||||
export declare const acceptanceLevelSchema: z.ZodEnum<{
|
||||
L1: "L1";
|
||||
L2: "L2";
|
||||
L3: "L3";
|
||||
L4: "L4";
|
||||
L5: "L5";
|
||||
}>;
|
||||
export type AcceptanceLevel = z.infer<typeof acceptanceLevelSchema>;
|
||||
export declare const ACCEPTANCE_LEVEL_LABEL: Readonly<Record<AcceptanceLevel, string>>;
|
||||
/** L1–L4 是质量关,L5 是责任关 —— 判据全绿只是可提交,完成只出自放行通过 */
|
||||
export declare function isResponsibilityGate(level: AcceptanceLevel): boolean;
|
||||
+18
-2
@@ -3,10 +3,26 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/app/main.ts" },
|
||||
"exports": {
|
||||
".": "./src/app/main.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsup --watch --onSuccess \"node dist/main.js\"",
|
||||
"build": "tsup",
|
||||
"seed": "node dist/seed.js"
|
||||
"start": "node dist/main.js",
|
||||
"seed": "node dist/seed.js",
|
||||
"migrate": "node dist/migrate.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||
"@hono/node-server": "^2.0.12",
|
||||
"ai": "^7.0.37",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.12.32",
|
||||
"pg": "^8.22.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* DI 装配 —— `app/` 是**唯一组装层**(编码规范 §5.2)。
|
||||
* 只有这里能同时认识多个模块:new 出 adapter、注入 port、把用例接起来。
|
||||
*
|
||||
* 跨层协作一律经端口注入:`ledger` 要发数据事件 → 注入 `event` 的出口;
|
||||
* `event` 的三级消费下游(trigger L7 / message L2)在其**上**,同样经端口注入。
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
|
||||
import type { EventService, MessageFallback, TriggerMatcher } from '@modules/event';
|
||||
import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
||||
import {
|
||||
createGrantRepository,
|
||||
createOrgService,
|
||||
createPositionRepository,
|
||||
createStaffRepository,
|
||||
} from '@modules/org';
|
||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||
import {
|
||||
createLlmInstanceRepository,
|
||||
createLlmService,
|
||||
createModelGateway,
|
||||
createProviderInvoker,
|
||||
createReplayStore,
|
||||
} from '@modules/llm';
|
||||
|
||||
import type { AppConfig } from './config.ts';
|
||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||
import { createSecretBox, loadSecretKey } from './secret-box.ts';
|
||||
|
||||
export type Container = {
|
||||
readonly config: AppConfig;
|
||||
readonly pool: Pool;
|
||||
readonly db: Db;
|
||||
readonly org: OrgService;
|
||||
readonly ledger: LedgerService;
|
||||
readonly event: EventService;
|
||||
readonly llm: LlmService;
|
||||
readonly modelGateway: ModelGateway;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* M1 的占位实现 —— 在途工作探针要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。
|
||||
* 占位返回空集,停用检查因此在 M1 一律放行;M4 起接真实用例。
|
||||
*/
|
||||
const emptyInFlightProbe: InFlightWorkProbe = {
|
||||
openTasksOf: async () => [],
|
||||
openCardsOf: async () => [],
|
||||
openApprovalsOf: async () => [],
|
||||
};
|
||||
|
||||
/** M1 的占位实现 —— 触发器(L7)随 M5 落地,当下恒不命中 */
|
||||
const noTriggerMatcher: TriggerMatcher = { match: async () => [] };
|
||||
|
||||
/** M1 的占位实现 —— 消息(L2)随 M2 落地;在此之前无主事件只落日志,不静默丢弃 */
|
||||
function createConsoleMessageFallback(): MessageFallback {
|
||||
return {
|
||||
async deadLetter(envelope, error) {
|
||||
process.stderr.write(`[死信] event=${envelope.id} type=${envelope.type} error=${error}\n`);
|
||||
},
|
||||
async unowned(envelope, fallbackPosition) {
|
||||
process.stderr.write(
|
||||
`[无主兜底] event=${envelope.id} type=${envelope.type} 认领岗位=${fallbackPosition ?? '未配置'}\n`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** S-FEEL 求值 —— @sfeel 随 M3 落地;M1 的条件监听一律按「无条件」处理 */
|
||||
const passthroughEvaluator = (): boolean => true;
|
||||
|
||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||
const db = drizzle(pool);
|
||||
const ids = createIdGenerator();
|
||||
const secrets = createSecretBox(loadSecretKey());
|
||||
|
||||
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
||||
await pool.query(DRAFT_DDL);
|
||||
|
||||
const event = createEventService({
|
||||
store: createEventStore(db, ids),
|
||||
clock: systemClock,
|
||||
ids,
|
||||
evaluate: passthroughEvaluator,
|
||||
triggers: noTriggerMatcher,
|
||||
messages: createConsoleMessageFallback(),
|
||||
});
|
||||
|
||||
const ledger = createLedgerService({
|
||||
store: createLedgerStore(pool),
|
||||
drafts: createDraftStore(pool),
|
||||
events: { publish: async (envelope) => void (await event.publish(envelope)) },
|
||||
clock: systemClock,
|
||||
ids,
|
||||
});
|
||||
|
||||
const org = createOrgService({
|
||||
positions: createPositionRepository(db),
|
||||
staff: createStaffRepository(db),
|
||||
grants: createGrantRepository(db),
|
||||
inFlight: emptyInFlightProbe,
|
||||
ids,
|
||||
});
|
||||
|
||||
const instances = createLlmInstanceRepository(db);
|
||||
const provider = createProviderInvoker();
|
||||
const llm = createLlmService({ instances, provider, secrets, clock: systemClock, ids });
|
||||
const modelGateway = createModelGateway({
|
||||
instances,
|
||||
provider,
|
||||
replay: createReplayStore(db),
|
||||
secrets,
|
||||
clock: systemClock,
|
||||
mode: 'live',
|
||||
});
|
||||
|
||||
return {
|
||||
config,
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
ledger,
|
||||
event,
|
||||
llm,
|
||||
modelGateway,
|
||||
async close() {
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
|
||||
*/
|
||||
export type AppEnv = { Variables: { account: string } };
|
||||
@@ -1,16 +1,28 @@
|
||||
/**
|
||||
* apps/api 唯一组装层入口(编码规范 §5.2)。
|
||||
* 只有 app/ 能同时认识多个模块:new adapter、注入 port、注册路由 / worker / Mastra 实例。
|
||||
*
|
||||
* M0:骨架 —— 门禁跑通即可,业务装配自 M1 起逐层填入。
|
||||
*/
|
||||
import { serve } from '@hono/node-server';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
import { createServer } from './server.ts';
|
||||
|
||||
export async function boot(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
process.stdout.write(
|
||||
`JCAI api · M0 骨架 · env=${config.nodeEnv} · port=${config.port} · db=${config.databaseUrl.replace(/:[^:@]*@/, ':***@')}\n`,
|
||||
);
|
||||
const container = await createContainer(config);
|
||||
await container.org.ensureSeeded();
|
||||
|
||||
const app = createServer(container);
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
|
||||
});
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
await container.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => void shutdown());
|
||||
process.on('SIGTERM', () => void shutdown());
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* sys schema 迁移执行器 —— drizzle-kit 生成 SQL,本文件应用(ADR-002:迁移随系统版本)。
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from './config.ts';
|
||||
|
||||
/** 从本文件位置向上找 db/migrations —— 源码跑与 dist 跑同一套逻辑 */
|
||||
function findMigrationsFolder(): string {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const candidate = join(dir, 'db', 'migrations');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error('找不到 db/migrations —— 先跑 pnpm db:generate');
|
||||
}
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 2 });
|
||||
try {
|
||||
const db = drizzle(pool);
|
||||
await migrate(db, { migrationsFolder: findMigrationsFolder(), migrationsSchema: 'sys' });
|
||||
process.stdout.write('sys schema 迁移完成\n');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await runMigrations();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
||||
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction } from '@contracts';
|
||||
import {
|
||||
createStaffRoute,
|
||||
disableStaffCheckRoute,
|
||||
disableStaffRoute,
|
||||
listGrantsRoute,
|
||||
listLlmInstancesRoute,
|
||||
listStaffRoute,
|
||||
positionIdSchema,
|
||||
probeLlmInstanceRoute,
|
||||
setGrantsRoute,
|
||||
staffIdSchema,
|
||||
upsertLlmInstanceRoute,
|
||||
} from '@contracts';
|
||||
import { DisableBlockedError } from '@modules/org';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */
|
||||
function requireGovern(container: Container, fn: GovernanceFunction) {
|
||||
return async (account: string): Promise<ApiError | undefined> => {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
||||
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern');
|
||||
return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` };
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
const guardStaff = requireGovern(container, 'staff');
|
||||
const guardSystem = requireGovern(container, 'system');
|
||||
|
||||
/* ── 员工管理 ─────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json(await container.org.listStaff());
|
||||
});
|
||||
|
||||
app.post(createStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = createStaffRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.org.createStaff(parsed.data));
|
||||
});
|
||||
|
||||
app.get(disableStaffCheckRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
return c.json(await container.org.checkDisable(staffId));
|
||||
});
|
||||
|
||||
app.post(disableStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
try {
|
||||
await container.org.disableStaff(staffId);
|
||||
return c.json({ ok: true, check: await container.org.checkDisable(staffId) });
|
||||
} catch (error) {
|
||||
if (error instanceof DisableBlockedError) {
|
||||
// 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02)
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get(listGrantsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
||||
return c.json({ grants: await container.org.listGrants(positionId) });
|
||||
});
|
||||
|
||||
app.post(setGrantsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
||||
const parsed = setGrantsRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) });
|
||||
});
|
||||
|
||||
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
||||
|
||||
app.get(listLlmInstancesRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json({ instances: await container.llm.list() });
|
||||
});
|
||||
|
||||
app.post(upsertLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.llm.upsert(parsed.data));
|
||||
});
|
||||
|
||||
app.post(probeLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json(await container.llm.probe(c.req.param('id')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { CONTRACTS_VERSION, healthRoute, sessionRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
app.get(healthRoute.path, async (c) => {
|
||||
let db: 'up' | 'down' = 'down';
|
||||
try {
|
||||
await container.pool.query('select 1');
|
||||
db = 'up';
|
||||
} catch {
|
||||
db = 'down';
|
||||
}
|
||||
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
|
||||
});
|
||||
|
||||
app.get(sessionRoute.path, async (c) => {
|
||||
const session = await container.org.loadSession(c.get('account'));
|
||||
if (session === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效或员工已停用' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
return c.json(session);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 运行时基础件 —— 时钟与 ID 生成器。
|
||||
* domain 零 `Date.now` / `Math.random`(§5.3),二者一律经 port 注入;实现在此。
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
export const systemClock: Clock = { now: () => new Date() };
|
||||
|
||||
export function createIdGenerator(prefix = ''): IdGenerator {
|
||||
return { next: () => `${prefix}${randomUUID()}` };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 凭证密封 —— ADR-009。app 层持有主密钥,模块只见 `SecretBox` 端口。
|
||||
*
|
||||
* 密文格式:`v1.<iv>.<tag>.<cipher>`(均 base64url)。
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import type { SecretBox } from '@modules/llm';
|
||||
|
||||
const DEV_SALT = 'jcai-dev-only-not-for-production';
|
||||
|
||||
export function loadSecretKey(env: NodeJS.ProcessEnv = process.env): Buffer {
|
||||
const raw = env['JCAI_SECRET_KEY'];
|
||||
if (raw !== undefined && raw !== '') {
|
||||
const key = Buffer.from(raw, 'base64');
|
||||
if (key.length !== 32) throw new Error('JCAI_SECRET_KEY 必须是 32 字节的 base64');
|
||||
return key;
|
||||
}
|
||||
if (env['NODE_ENV'] === 'production') {
|
||||
throw new Error('生产环境必须设置 JCAI_SECRET_KEY(ADR-009)');
|
||||
}
|
||||
process.stderr.write('⚠️ JCAI_SECRET_KEY 未设置,使用开发派生密钥 —— 切勿用于生产(ADR-009)\n');
|
||||
return createHash('sha256').update(DEV_SALT).digest();
|
||||
}
|
||||
|
||||
export function createSecretBox(key: Buffer): SecretBox {
|
||||
return {
|
||||
seal(plaintext) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), enc.toString('base64url')].join('.');
|
||||
},
|
||||
open(text) {
|
||||
const [version, ivRaw, tagRaw, cipherRaw] = text.split('.');
|
||||
if (version !== 'v1' || ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) {
|
||||
throw new Error('密文格式非法');
|
||||
}
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(cipherRaw, 'base64url')), decipher.final()]).toString('utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
+15
-42
@@ -1,49 +1,22 @@
|
||||
/**
|
||||
* 种子数据与播种入口(编码规范 §15 · M0)。
|
||||
*
|
||||
* 数据是常量、写入是端口 —— M0 立骨架:数据在此冻结,写入实现随 M1 的 `org` 模块落地。
|
||||
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
|
||||
* 播种入口(编码规范 §15 · M0/M1)。
|
||||
* 种子数据本体在 `org` 模块的 domain(它是组织主数据,归 org 管);此处只做装配与运行。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
|
||||
/** 岗位:编排层声明岗位,起单时按四策略解析出人(OS 03) */
|
||||
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;
|
||||
|
||||
/** 员工五人;登录者 = 冯兵(BD) */
|
||||
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;
|
||||
|
||||
/** 默认登录者(MVP 单组织 cookie session,§4 鉴权定调) */
|
||||
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
|
||||
|
||||
export type PositionSeed = (typeof POSITION_SEED)[number];
|
||||
export type StaffSeed = (typeof STAFF_SEED)[number];
|
||||
|
||||
/** 播种端口 —— 实现由 app/ 在 M1 注入(org 模块出口) */
|
||||
export type OrgSeeder = {
|
||||
upsertPosition(position: PositionSeed): Promise<void>;
|
||||
upsertStaff(staff: StaffSeed): Promise<void>;
|
||||
};
|
||||
|
||||
export async function runSeed(seeder: OrgSeeder): Promise<void> {
|
||||
for (const position of POSITION_SEED) await seeder.upsertPosition(position);
|
||||
for (const staff of STAFF_SEED) await seeder.upsertStaff(staff);
|
||||
export async function seed(): Promise<void> {
|
||||
const container = await createContainer(loadConfig());
|
||||
try {
|
||||
await container.org.ensureSeeded();
|
||||
const { staff, positions } = await container.org.listStaff();
|
||||
process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`);
|
||||
for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`);
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
process.stdout.write(
|
||||
`种子数据:${POSITION_SEED.length} 个岗位 / ${STAFF_SEED.length} 名员工;` +
|
||||
`登录者=${DEFAULT_LOGIN_ACCOUNT}\n` +
|
||||
'写入实现随 M1 `org` 模块落地(docs/debt.md · M0-1)。\n',
|
||||
);
|
||||
await seed();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
||||
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
||||
import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
export type { AppEnv };
|
||||
|
||||
export function createServer(container: Container): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// 单组织 cookie session(编码规范 §4 鉴权定调)—— MVP 单登录者,cookie 缺省即种子登录者
|
||||
app.use('*', async (c, next) => {
|
||||
const cookie = c.req.header('cookie') ?? '';
|
||||
const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie);
|
||||
c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.onError((error, c) => {
|
||||
const body: ApiError = {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
process.stderr.write(`[api] ${body.message}\n`);
|
||||
return c.json(body, 500);
|
||||
});
|
||||
|
||||
app.notFound((c) => {
|
||||
const body: ApiError = { code: 'not-found', message: `无此路由:${c.req.path}` };
|
||||
return c.json(body, 404);
|
||||
});
|
||||
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/event",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/ledger",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/llm",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/org",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 持久层的公共类型与 schema 句柄(编码规范 §2:shared 只收无业务语义的基础件)。
|
||||
*
|
||||
* 四个 schema、各自单写者(§7 / ADR-002):
|
||||
* sys 由各模块 adapter 经 Drizzle 单写;ledger 仅 ledger adapter(raw pg);
|
||||
* mastra 仅 Mastra;queue 仅 pg-boss。
|
||||
* 连接池的创建与生命周期归 app 层,模块只拿到句柄。
|
||||
*/
|
||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
/** Drizzle 句柄 —— 模块 adapter 的入参类型 */
|
||||
export type Db = NodePgDatabase<Record<string, never>>;
|
||||
|
||||
/** 系统自持 schema —— 全仓一份句柄,各模块 adapter 在其上建表 */
|
||||
export const sys = pgSchema('sys');
|
||||
|
||||
/** 台账 schema 名 —— ledger adapter 以 raw pg 动态 DDL,不经 Drizzle */
|
||||
export const LEDGER_SCHEMA = 'ledger';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: { main: 'src/app/main.ts', seed: 'src/app/seed.ts' },
|
||||
entry: { main: 'src/app/main.ts', seed: 'src/app/seed.ts', migrate: 'src/app/migrate.ts' },
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
platform: 'node',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
CREATE TABLE "sys"."event_consumption" (
|
||||
"event_id" text NOT NULL,
|
||||
"consumer" text NOT NULL,
|
||||
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ok" boolean DEFAULT true NOT NULL,
|
||||
CONSTRAINT "event_consumption_event_id_consumer_pk" PRIMARY KEY("event_id","consumer")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."event_delivery" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"event_id" text NOT NULL,
|
||||
"target_kind" text NOT NULL,
|
||||
"target_id" text NOT NULL,
|
||||
"attempt" integer DEFAULT 1 NOT NULL,
|
||||
"state" text DEFAULT 'pending' NOT NULL,
|
||||
"last_error" text,
|
||||
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."event" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"correlation" jsonb NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"source" jsonb NOT NULL,
|
||||
"trace" jsonb NOT NULL,
|
||||
"target_kind" text,
|
||||
"target_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."listener" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"origin" text NOT NULL,
|
||||
"event_type" text NOT NULL,
|
||||
"key_filter" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"condition" text,
|
||||
"semantic" text NOT NULL,
|
||||
"deadline" text NOT NULL,
|
||||
"escalation" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"registered_at" timestamp with time zone NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"state" text DEFAULT 'armed' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."outbound_ref" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"thread_id" text,
|
||||
"address" text NOT NULL,
|
||||
"sent_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."llm_instance" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"base_url" text NOT NULL,
|
||||
"model" text NOT NULL,
|
||||
"api_key_cipher" text,
|
||||
"system_use" boolean DEFAULT false NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."replay_model" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"call" jsonb NOT NULL,
|
||||
"result" jsonb NOT NULL,
|
||||
"recorded_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."position_grant" (
|
||||
"position_id" text NOT NULL,
|
||||
"object_key" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"object" jsonb NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
CONSTRAINT "position_grant_position_id_object_key_action_pk" PRIMARY KEY("position_id","object_key","action")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."position" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"note" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."staff" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"position_id" text NOT NULL,
|
||||
"state" text DEFAULT 'active' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sys"."event_delivery" ADD CONSTRAINT "event_delivery_event_id_event_id_fk" FOREIGN KEY ("event_id") REFERENCES "sys"."event"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sys"."position_grant" ADD CONSTRAINT "position_grant_position_id_position_id_fk" FOREIGN KEY ("position_id") REFERENCES "sys"."position"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sys"."staff" ADD CONSTRAINT "staff_position_id_position_id_fk" FOREIGN KEY ("position_id") REFERENCES "sys"."position"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "consumption_key" ON "sys"."event_consumption" USING btree ("event_id","consumer");--> statement-breakpoint
|
||||
CREATE INDEX "delivery_pending_idx" ON "sys"."event_delivery" USING btree ("state","next_attempt_at");--> statement-breakpoint
|
||||
CREATE INDEX "delivery_target_idx" ON "sys"."event_delivery" USING btree ("target_kind","target_id");--> statement-breakpoint
|
||||
CREATE INDEX "event_target_idx" ON "sys"."event" USING btree ("target_kind","target_id","occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "event_occurred_idx" ON "sys"."event" USING btree ("occurred_at");--> statement-breakpoint
|
||||
CREATE INDEX "listener_armed_idx" ON "sys"."listener" USING btree ("state","expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "listener_task_idx" ON "sys"."listener" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX "outbound_thread_idx" ON "sys"."outbound_ref" USING btree ("thread_id");--> statement-breakpoint
|
||||
CREATE INDEX "outbound_addr_idx" ON "sys"."outbound_ref" USING btree ("address","sent_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "llm_instance_name_key" ON "sys"."llm_instance" USING btree ("name");--> statement-breakpoint
|
||||
CREATE INDEX "replay_model_recorded_idx" ON "sys"."replay_model" USING btree ("recorded_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "position_code_key" ON "sys"."position" USING btree ("code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "staff_account_key" ON "sys"."staff" USING btree ("account");--> statement-breakpoint
|
||||
CREATE INDEX "staff_position_idx" ON "sys"."staff" USING btree ("position_id");
|
||||
@@ -0,0 +1,897 @@
|
||||
{
|
||||
"id": "76bf5784-08dd-4a68-9840-058ec13c7378",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"sys.event_consumption": {
|
||||
"name": "event_consumption",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"consumer": {
|
||||
"name": "consumer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"ok": {
|
||||
"name": "ok",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"consumption_key": {
|
||||
"name": "consumption_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "event_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "consumer",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"event_consumption_event_id_consumer_pk": {
|
||||
"name": "event_consumption_event_id_consumer_pk",
|
||||
"columns": [
|
||||
"event_id",
|
||||
"consumer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.event_delivery": {
|
||||
"name": "event_delivery",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_kind": {
|
||||
"name": "target_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"attempt": {
|
||||
"name": "attempt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_attempt_at": {
|
||||
"name": "next_attempt_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"delivery_pending_idx": {
|
||||
"name": "delivery_pending_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "state",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "next_attempt_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"delivery_target_idx": {
|
||||
"name": "delivery_target_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "target_kind",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "target_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"event_delivery_event_id_event_id_fk": {
|
||||
"name": "event_delivery_event_id_event_id_fk",
|
||||
"tableFrom": "event_delivery",
|
||||
"tableTo": "event",
|
||||
"schemaTo": "sys",
|
||||
"columnsFrom": [
|
||||
"event_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.event": {
|
||||
"name": "event",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"correlation": {
|
||||
"name": "correlation",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"trace": {
|
||||
"name": "trace",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_kind": {
|
||||
"name": "target_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"event_target_idx": {
|
||||
"name": "event_target_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "target_kind",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "target_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "occurred_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"event_occurred_idx": {
|
||||
"name": "event_occurred_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "occurred_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.listener": {
|
||||
"name": "listener",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"event_type": {
|
||||
"name": "event_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_filter": {
|
||||
"name": "key_filter",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"condition": {
|
||||
"name": "condition",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"semantic": {
|
||||
"name": "semantic",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"deadline": {
|
||||
"name": "deadline",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"escalation": {
|
||||
"name": "escalation",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"registered_at": {
|
||||
"name": "registered_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'armed'"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"listener_armed_idx": {
|
||||
"name": "listener_armed_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "state",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"listener_task_idx": {
|
||||
"name": "listener_task_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "task_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.outbound_ref": {
|
||||
"name": "outbound_ref",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sent_at": {
|
||||
"name": "sent_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"outbound_thread_idx": {
|
||||
"name": "outbound_thread_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"outbound_addr_idx": {
|
||||
"name": "outbound_addr_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "address",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sent_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.llm_instance": {
|
||||
"name": "llm_instance",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"base_url": {
|
||||
"name": "base_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"api_key_cipher": {
|
||||
"name": "api_key_cipher",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"system_use": {
|
||||
"name": "system_use",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"llm_instance_name_key": {
|
||||
"name": "llm_instance_name_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.replay_model": {
|
||||
"name": "replay_model",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"call": {
|
||||
"name": "call",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"result": {
|
||||
"name": "result",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"recorded_at": {
|
||||
"name": "recorded_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"replay_model_recorded_idx": {
|
||||
"name": "replay_model_recorded_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "recorded_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.position_grant": {
|
||||
"name": "position_grant",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"position_id": {
|
||||
"name": "position_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"object": {
|
||||
"name": "object",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"position_grant_position_id_position_id_fk": {
|
||||
"name": "position_grant_position_id_position_id_fk",
|
||||
"tableFrom": "position_grant",
|
||||
"tableTo": "position",
|
||||
"schemaTo": "sys",
|
||||
"columnsFrom": [
|
||||
"position_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"position_grant_position_id_object_key_action_pk": {
|
||||
"name": "position_grant_position_id_object_key_action_pk",
|
||||
"columns": [
|
||||
"position_id",
|
||||
"object_key",
|
||||
"action"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.position": {
|
||||
"name": "position",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"position_code_key": {
|
||||
"name": "position_code_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "code",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sys.staff": {
|
||||
"name": "staff",
|
||||
"schema": "sys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account": {
|
||||
"name": "account",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"position_id": {
|
||||
"name": "position_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"staff_account_key": {
|
||||
"name": "staff_account_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "account",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"staff_position_idx": {
|
||||
"name": "staff_position_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "position_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"staff_position_id_position_id_fk": {
|
||||
"name": "staff_position_id_position_id_fk",
|
||||
"tableFrom": "staff",
|
||||
"tableTo": "position",
|
||||
"schemaTo": "sys",
|
||||
"columnsFrom": [
|
||||
"position_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1785084862267,
|
||||
"tag": "0000_luxuriant_gorilla_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-005 · 工具链与门禁的落地形态
|
||||
# ADR-007 · 工具链与门禁的落地形态
|
||||
|
||||
- 状态:已决(M0)
|
||||
- 日期:2026-07-27
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-008 · 投递队列的落地形态:sys.event_delivery 是队列本体,pg-boss 只管定时
|
||||
|
||||
- 状态:已决(M1)
|
||||
- 日期:2026-07-27
|
||||
- 上下文:编码规范 §7.1 要求「先持久后投递」= 信封写 `sys.event` 与 **pg-boss 入队在同一事务**。
|
||||
但 pg-boss 的 `send()` 走自己的连接池,无法参与我们打开的事务;若拆成两步,
|
||||
「先持久后投递」的原子性就落空(写了事件、没入队 = 事件永不推进)。
|
||||
|
||||
## 决定
|
||||
|
||||
1. **投递记录本身就是队列**:`sys.event_delivery` 每行一份投递(投递 ID / 事件 ID / 单目标 /
|
||||
attempt / state / next_attempt_at),与 `sys.event` 的信封写入**同一事务**落库。
|
||||
「先持久后投递」由此在一条事务里成立 —— 队列不是外部件,是事件存储的一部分。
|
||||
2. **消费由派发器拉取**:派发 worker 按 `state='pending' and next_attempt_at <= now()` 取行,
|
||||
对目标 ID 取 **advisory lock** 串行执行 —— 这正是「同任务按序」的实现(§7.1)。
|
||||
3. **幂等在消费侧**:`sys.event_consumption(event_id, consumer)` 唯一约束;
|
||||
`claimConsumption` 插入成功即首次消费,冲突即已消费 —— 「至少一次 + 幂等」。
|
||||
4. **pg-boss 只承担定时**:监听时限扫描、周期触发器求值、蒸馏钩子等**时间驱动**的作业归 pg-boss
|
||||
(`queue` schema 自管)。它不承载事件投递,因此不需要与业务事务同源。
|
||||
5. 重试与死信:`decideRetry`(纯函数)给退避阶梯,超限置 `dead-letter` 并经 `MessageFallback`
|
||||
端口送系统通知 —— 「处理有回执」。
|
||||
|
||||
## 后果
|
||||
|
||||
- 投递五诺全部有落点,且五条都可在集成测试里断言。
|
||||
- 代价:多一个派发 worker(app 层),不能白嫖 pg-boss 的并发控制;换来的是原子性与可测性。
|
||||
- `event` 模块因此不依赖 pg-boss;pg-boss 只出现在 `app/workers/`。
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-009 · 凭证加密方案
|
||||
|
||||
- 状态:已决(M1)
|
||||
- 日期:2026-07-27
|
||||
- 上下文:编码规范 §14 留白「凭证加密方案」,实现期再定、各建 ADR 后方可动工。
|
||||
约束来自 OS 7.3【凭证隔离(必须)】:**密钥与 endpoint 值只存治理台,不入包、不入活动、不入留痕明文**。
|
||||
作用面有二:LLM 实例的 API-Key(`llm`)与功能工具的连接配置(`toolbox`)。
|
||||
|
||||
## 决定
|
||||
|
||||
1. **算法**:AES-256-GCM,Node 内置 `node:crypto`,不引第三方加密库(§12.5#3)。
|
||||
密文格式 `v1.<iv-base64url>.<tag-base64url>.<cipher-base64url>` —— 版本前缀留给日后轮换。
|
||||
2. **主密钥**:环境变量 `JCAI_SECRET_KEY`(32 字节,base64)。
|
||||
开发期缺省时**派生自固定开发盐**并在启动日志打醒目告警;`NODE_ENV=production` 缺失即**拒绝启动**。
|
||||
3. **落点**:`SecretBox` 是端口(`seal` / `open`),实现在 app 层的 `internal/adapter` 之外的
|
||||
`apps/api/src/app/secret-box.ts`,由 app 注入 `llm` 与 `toolbox`。
|
||||
domain / usecase 只见密文串与 `SecretBox` 端口 —— 明文永不进 domain。
|
||||
4. **出口纪律**:任何对外读模型(治理台 API、任务记忆、事件载荷、日志)一律**只回 `apiKeyBound: boolean`**,
|
||||
不回密文、不回明文。`llm` 的 `LlmInstanceView` 在 @contracts 里就没有明文字段 —— 结构上不可能泄漏。
|
||||
5. **回放录制**:`sys.replay_*` 记录模型出入参,**入参中的 Authorization 头不落库** ——
|
||||
录制发生在 `ModelGateway` 的语义层(prompt / 参数 / 出参),不在 HTTP 层。
|
||||
|
||||
## 后果
|
||||
|
||||
- 密钥轮换 = 换 `JCAI_SECRET_KEY` 并重新绑定实例;`v1.` 前缀让日后支持多版本共存。
|
||||
- 单组织 ≤50 人的预算下不引 KMS / Vault(OS 0.3:一切机制的复杂度预算以此为上限)。
|
||||
+4
-1
@@ -6,4 +6,7 @@
|
||||
| # | 债 | 原因 | 预期清理 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| M0-1 | `pnpm db:seed` 只打印种子计划,未真正写库 | 种子数据(五岗位 / 五员工)已在 `apps/api/src/app/seed.ts` 冻结为常量并定义 `OrgSeeder` 端口;写入实现依赖 `org` 模块的 Drizzle schema,属 M1 | M1 | 已清理(M1) |
|
||||
| M0-2 | `packages/{contracts,sfeel,activity-sdk,ui-kit}` 仅有版本常量 | M0 只立骨架,内容按里程碑序填(§15) | M1–M5 | 进行中 |
|
||||
| M0-2 | `packages/{sfeel,activity-sdk,ui-kit}` 仅有版本常量 | M0 只立骨架,内容按里程碑序填(§15);`@contracts` 已于 M1 落首版 | M3–M5 | 进行中 |
|
||||
| M1-1 | `container.ts` 里三个占位端口:`InFlightWorkProbe`(空集)、`TriggerMatcher`(恒不命中)、`MessageFallback`(落 stderr) | 三者的真身分别在 task(L6,M4)、trigger(L7,M5)、message(L2,M2);M1 只有 L1,端口先立、实现随层级到位 | M2 / M4 / M5 | 进行中 |
|
||||
| M1-2 | 条件求值 `passthroughEvaluator` 恒真 | `@sfeel` 属 M3;M1 的监听只用类型 + 关联键过滤,不用内容条件 | M3 | 进行中 |
|
||||
| M1-3 | 投递记录写入即止,尚无派发 worker | 队列本体已按 ADR-008 落 `sys.event_delivery`;派发 worker 与 pg-boss 定时随 M2 | M2 | 进行中 |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
/**
|
||||
* `sys` schema 由**各模块 adapter** 经 Drizzle 单写(ADR-002)。
|
||||
* 这里以 glob 收集各模块的 `*-schema.ts` —— 是构建工具读文件,不是代码 import,
|
||||
* 因此不违反「app 才是唯一组装层」(编码规范 §5.2)。
|
||||
*/
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './apps/api/src/modules/*/internal/adapter/*-schema.ts',
|
||||
out: './db/migrations',
|
||||
schemaFilter: ['sys'],
|
||||
dbCredentials: {
|
||||
url: process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai',
|
||||
},
|
||||
casing: 'snake_case',
|
||||
});
|
||||
+7
-1
@@ -36,11 +36,17 @@ export default tseslint.config(
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{ group: ['**/internal/**'], message: '禁止穿透模块出口(编码规范 §5.2)' },
|
||||
// 别名穿透:@modules/ledger/internal/... 一律拦下(只能 import 到模块根)
|
||||
{
|
||||
group: ['@modules/*/*', '@features/*/*'],
|
||||
message: '只能 import 到模块根(编码规范 §5.2)',
|
||||
},
|
||||
// 显式写出 modules/ 段的相对穿透
|
||||
{
|
||||
group: ['**/modules/*/internal/**', '**/features/*/internal/**'],
|
||||
message: '禁止穿透模块出口(编码规范 §5.2)',
|
||||
},
|
||||
// 爬三层及以上 = 跨模块;跨模块请走别名与出口
|
||||
{ group: ['../../../*'], message: '跨模块请用路径别名(编码规范 §11.3)' },
|
||||
],
|
||||
},
|
||||
|
||||
+7
-2
@@ -17,17 +17,22 @@
|
||||
"gates": "pnpm gen:tiers && pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test",
|
||||
"db:up": "docker compose up -d",
|
||||
"db:down": "docker compose down",
|
||||
"db:seed": "pnpm --filter @app/api seed",
|
||||
"db:seed": "pnpm --filter @app/api build && pnpm --filter @app/api seed",
|
||||
"dev:api": "pnpm --filter @app/api dev",
|
||||
"dev:web": "pnpm --filter @app/web dev"
|
||||
"dev:web": "pnpm --filter @app/web dev",
|
||||
"db:migrate": "pnpm --filter @app/api build && pnpm --filter @app/api migrate",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@testcontainers/postgresql": "^12.0.4",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"dependency-cruiser": "^16.10.4",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^9.39.0",
|
||||
"globals": "^16.5.0",
|
||||
"pg": "^8.22.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user