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