This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/ledger/internal/usecase/ledger-service.ts
T
xcsandClaude Fable 5 7b85a79323 harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:16 +08:00

383 lines
16 KiB
TypeScript

/**
* 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,
ReleaseScope,
ReleaseWriteResult,
SchemaApplyResult,
SystemWrite,
} from '../../contract.ts';
import { DraftConflictError, ObjectNotMaterializedError } from '../../contract.ts';
import type {
Clock,
DraftStore,
EventPublisher,
IdGenerator,
LedgerStore,
ObjectRef,
SimulationProbe,
} 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;
/** 试跑判定 —— 放行写据此把主数据挡在正式表外(见 port.ts `SimulationProbe`) */
readonly simulation: SimulationProbe;
};
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;
}
/** 范围过滤:不给范围 = 全收 */
function inScope(only: readonly ObjectAddress[] | undefined): (draft: ObjectRef) => boolean {
if (only === undefined) return () => true;
const wanted = new Set(only.map((a) => `${a.domain}.${a.object}`));
return (draft) => wanted.has(`${draft.domain}.${draft.object}`);
}
/** 落地后的基线:只取这一笔碰过的那几列,值取表里那一份 */
function baselineOf(stored: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of Object.keys(patch)) out[field] = stored[field];
return out;
}
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);
/*
* 性质每次都记 —— 它随声明走,不随建表走。
* 声明合并已经定了口径(平台的 master 优先于活动的缺省 record),这里照写。
*/
await deps.store.setObjectMeta(ref, merge.merged.kind, merge.merged.caseKey);
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,
});
},
/**
* 撤掉本单草稿层的一行 —— **只撤自己写过的**。
*
* 回 `false` 表示草稿层里根本没有这一行:那多半是主键写法对不上
* (`DAG…` 与 `DAG…-R0` 是两行),如实说出来比静默成功有用 ——
* 静默成功会让模型以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
async draftDrop(drop) {
const mine = await deps.drafts.listForObject(drop.taskId, drop.address);
const schema = await requireSchema(drop.address);
const keys = schema.primaryKey.length > 0 ? schema.primaryKey : Object.keys(drop.key);
const wanted = pickKey({ ...drop.key }, keys);
const hit = mine.some((d) => keys.every((k) => String((d.key as Record<string, unknown>)[k] ?? '') === String(wanted[k] ?? '')));
if (!hit) return false;
await deps.drafts.drop(drop.taskId, drop.address, wanted);
return true;
},
/**
* ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件。
*
* `scope` 给了就是**数据工单确认那一道门**(2026-08-27 用户定:「数据工单里填的数据,
* 确认这个数据工单后就应该入库」):只并那几张表,并过的行留在草稿层打「已落地」记号 ——
* 它仍是本单写下的行(`mine:` / `_rows` / 任务视图照旧认它,不然 P-08 那张件当场变空、
* S1 的哨兵 `parts_profile_rows >= created` 永远开不了),只是整单放行时不再写它。
*/
async releaseWrite(taskId, scope?: ReleaseScope): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId, scope?.only);
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
const drafts = (await deps.drafts.list(taskId)).filter(inScope(scope?.only));
const nothing: ReleaseWriteResult = { mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 0, merged: [] };
if (drafts.length === 0) return nothing;
const scoped = scope !== undefined;
const causeRef = scoped ? scope.cause : `release:${taskId}`;
/*
* 事件指不指回本单:整单放行指回(它正要收尾,与从前一样);数据工单那一道**不指回** ——
* 本单此刻正等着自己的人事件(卡的回传)叫醒,再喂它七八条 data 事件只会在日志里结巴,
* 而它对这些行的「读己之写」从没断过。别单的监听照旧按正式版事件认(跨任务只认过门的事实)。
*/
const target = scoped ? undefined : taskId;
/*
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
*
* 试跑照旧是真跑:过装载两审、过预算、过放行门,单据照写。挡的只有主数据 ——
* 它按业务实体分行,试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
* 「新客户来函」那条样例第二次跑就成了老客户,这条路从此试不出来。
* 单据不挡:它按单标识分行,一次试跑写自己那一行,清运行数据就干净了。
*
* 被挡下的行仍然进 `merged`(出口载荷取的就是它)—— 拦的是**落到世界**,
* 不是拦这一单自己看得见:活动缓存里它一直在,这一单该怎么跑还怎么跑。
*/
const simulated = await deps.simulation.isSimulated(taskId);
const masters = new Set<string>();
if (simulated) {
for (const ref of await deps.store.masterRefs()) masters.add(`${ref.domain}.${ref.object}`);
}
const heldBack = (draft: { domain: string; object: string }): boolean =>
simulated && masters.has(`${draft.domain}.${draft.object}`);
let simulatedRows = 0;
let landedEarlierRows = 0;
const pending: EnvelopeDraft[] = [];
/*
* 合并后**读回来**的行 —— 「落地即事实」,事实是表里那一份,不是写进去之前的草稿。
*
* 从前这里直接回 `draft.patch`,于是出口载荷可以跟台账不一致:草稿里是字符串
* `"true"`(人的回传值天生是字符串),列是 boolean,PG 存的时候转了,可发出去的口里
* 还是字符串 —— 下游活动的入参校验当场拒收「/needSourcing must be boolean」,
* 节点起不来、报 blocked,而台账里明明是对的(实测)。
* 读回来之后,出口载荷与台账逐字一致,类型也由列定义说了算。
*/
const landed: Array<{ domain: string; object: string; key: Record<string, unknown>; values: Record<string, unknown> }> = [];
await deps.store.transaction(async (store, draftStore) => {
for (const draft of drafts) {
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
if (draft.landed === true) {
/*
* 早已在数据工单确认那一道门上落过地:这次不写、不抛事件、不计入合并数;
* 只把表里那一份读回来当事实(出口载荷要它)。表里读不到(别单后来删了)就不摆。
*/
landedEarlierRows += 1;
if (draft.tombstone) continue;
const current = (await store.selectByKeys(ref, [draft.key]))[0];
if (current !== undefined) landed.push({ domain: draft.domain, object: draft.object, key: draft.key, values: current });
continue;
}
if (heldBack(draft)) {
simulatedRows += 1;
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
/*
* 值取「表里现有的 + 这一单改的」——没真写进去,读不回 PG 归型后的那一份,
* 所以这里的值是草稿的原样(人的回传天生是字符串)。如实记在这:
* 主数据不进出口载荷,下游拿不到它,归不归型都影响不到谁。
*/
const current = (await store.selectByKeys(ref, [draft.key]))[0];
landed.push({
domain: draft.domain,
object: draft.object,
key: draft.key,
values: { ...(current ?? {}), ...draft.patch },
});
continue;
}
if (draft.tombstone) {
await store.deleteRow(ref, draft.key);
pending.push(dataEvent(ref, draft.key, [], 'delete', target, { kind: 'ledger-write', ref: causeRef }));
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, {});
continue;
}
const existing = await store.selectByKeys(ref, [draft.key]);
await store.upsert(ref, draft.key, draft.patch);
const stored = (await store.selectByKeys(ref, [draft.key]))[0];
landed.push({
domain: draft.domain,
object: draft.object,
key: draft.key,
// 读不回来只能退回草稿(理论上不该发生:刚 upsert 过);宁可退也不丢这一行
values: stored ?? draft.patch,
});
pending.push(
dataEvent(
ref,
draft.key,
Object.keys(draft.patch),
existing.length === 0 ? 'insert' : 'update',
target,
{ kind: 'ledger-write', ref: causeRef },
),
);
/*
* 数据工单那一道门:条目留着、打记号,基线换成落地后表里的值(PG 归型后的那一份)——
* 之后再往这一行写一笔,冲突检查比的才是「我改之前表里是什么」。
*/
if (scoped) await draftStore.markLanded(taskId, ref, draft.key, baselineOf(stored ?? draft.patch, draft.patch));
}
if (!scoped) await draftStore.clear(taskId);
});
for (const envelope of pending) await deps.events.publish(envelope);
return {
mergedRows: drafts.length - simulatedRows - landedEarlierRows,
emittedEvents: pending.length,
landedEarlierRows,
simulatedRows,
merged: landed,
};
},
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 taskDrafts(taskId) {
const drafts = await deps.drafts.list(taskId);
return drafts.map((d) => ({ domain: d.domain, object: d.object, key: d.key, patch: d.patch, landed: d.landed === true }));
},
async checkConflicts(taskId, only) {
const drafts = (await deps.drafts.list(taskId)).filter(inScope(only));
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);
},
};
}