feat: 账本 ledger 模块(任务书 03)
一张只追加的表(序号、种类、内容、哈希),禁改禁删两条触发器。 有自己编号、只发生一次的 8 种记录加部分唯一索引,重复就报错:阶段开、阶段关、运行登记、 确认启动(确认没启动的结果也算这一条,防止同一次运行既扣次又退还)、运行结果、模型调用开始、结束、消融结算。 写入时只查四种拒收:只收开着的阶段的提交;替换必须接本人同一阶段、同一对象、同一种提交的最新一版,实现与核验类不能替换; 结果先于确认启动(没启动除外);本轮消融已有格启动后不能关解释阶段。 写提案时作者自评拆成封存。提供打开(核对表结构版本,非账本库不改动)、追加、同步事务、读取、未完成的动作。 第一版的可见性、截止序号、通用唯一键、jev 记录和多余的前提检查已按新版任务书清掉。 三轮全新上下文审阅;flow 传错参数一类的意见按 CLAUDE.md"罕见情况怎么办"第 2 条只记录不加代码。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
// ledger:研究的全部记录怎么存、怎么保证不能改、写入时拒收什么、花钱的动作怎么先登记
|
||||
// (docs/结构.md 第二部分、第四部分)。
|
||||
export { Ledger, type Pending } from "./ledger.ts";
|
||||
export {
|
||||
type AppendKind,
|
||||
type Content,
|
||||
type Input,
|
||||
type Kind,
|
||||
type LedgerRecord,
|
||||
type StoredSubmission,
|
||||
type SubmissionInput,
|
||||
type SubmissionSlot,
|
||||
} from "./kinds.ts";
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Type, type Static, type TSchema } from "typebox";
|
||||
import { Value } from "typebox/value";
|
||||
import {
|
||||
Id,
|
||||
JudgedResult,
|
||||
ModelUsage,
|
||||
ProcessResult,
|
||||
Range,
|
||||
RunDefinition,
|
||||
STAGES,
|
||||
SUBMISSION_KINDS,
|
||||
SUBMISSION_SCHEMAS,
|
||||
type Json,
|
||||
type Submission,
|
||||
type SubmissionKind,
|
||||
} from "../vocab/index.ts";
|
||||
|
||||
// 账本记录的种类(docs/结构.md 第四部分的 11 种记录,细分成下面 19 种),每种一份内容定义(TypeBox)。
|
||||
// 下面写"只有一条""只一次"的,由 schema.ts 的唯一约束保证。
|
||||
// 配置、冻结协议、决定、结算的内容由别的模块产生,这里只检查最少的标识字段,其余只检查是 Json。
|
||||
|
||||
const CLOSED = { additionalProperties: false } as const;
|
||||
const Text = Type.String({ minLength: 1 });
|
||||
const AnyJson = Type.Unsafe<Json>({});
|
||||
const Round = Type.Integer({ minimum: 0 });
|
||||
const Version = Type.Integer({ minimum: 1 });
|
||||
const Seq = Type.Integer({ minimum: 1 });
|
||||
const OptionalId = Type.Union([Id, Type.Null()]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 提交(记录 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 一人在同一阶段对同一对象能交好几份的提交种类:提案(每人 1 至 M 条,方法.md 4.3 ②)、
|
||||
* 挑刺(回应时按挑刺编号逐条答复,方法.md 第五部分)。这两种的每一份带一个由程序分配的编号 item,
|
||||
* 替换时按 SubmissionSlot 找同一份的最新一版(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况")。
|
||||
* 其余种类一人对一个对象只交一份,item 在字段定义里就只能为空,所以不能另起一个编号绕开"不能替换"。
|
||||
*/
|
||||
const MULTI_ITEM_KINDS: ReadonlySet<SubmissionKind> = new Set(["proposal", "critique"]);
|
||||
|
||||
/**
|
||||
* 一份提交不论第几版:哪个阶段、谁交的、哪种提交、针对谁(候选或格,没有就空)、这一份的编号(见 MULTI_ITEM_KINDS)。
|
||||
* 提交种类在位置里:版本号只在同一种提交里往下接,别的种类顶替不了不能替换的实现,提案和挑刺用了同一个编号也接不上。
|
||||
*/
|
||||
export interface SubmissionSlot {
|
||||
stageId: string;
|
||||
author: string;
|
||||
kind: SubmissionKind;
|
||||
target: string | null;
|
||||
item: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账本里存的提案不带作者自评:写入时 ledger 把它拆出来另存成一条封存(方法.md 第三部分"作者自评从交上来那一刻起就封存")。
|
||||
* 这样给别人看提案时不用去掉什么。
|
||||
*/
|
||||
const STORED_BODY: Record<SubmissionKind, TSchema> = {
|
||||
...SUBMISSION_SCHEMAS,
|
||||
proposal: Type.Omit(SUBMISSION_SCHEMAS.proposal, ["authorEstimate"]),
|
||||
};
|
||||
|
||||
type StoredBody<K extends SubmissionKind> = K extends "proposal"
|
||||
? Omit<Submission<"proposal">, "authorEstimate">
|
||||
: Submission<K>;
|
||||
|
||||
/** 一份提交的一版。version 从 1 起,替换时加 1,被替换的就是上一版。 */
|
||||
type SubmissionOf<Body extends Record<SubmissionKind, unknown>> = {
|
||||
[K in SubmissionKind]: Omit<SubmissionSlot, "kind"> & { version: number; kind: K; content: Body[K] };
|
||||
}[SubmissionKind];
|
||||
|
||||
export type StoredSubmission = SubmissionOf<{ [K in SubmissionKind]: StoredBody<K> }>;
|
||||
/** 写入时交给 ledger 的提交:提案带着作者自评。 */
|
||||
export type SubmissionInput = SubmissionOf<{ [K in SubmissionKind]: Submission<K> }>;
|
||||
|
||||
/** 某种提交一版的完整字段定义:外层的位置和版本号,加上按提交种类检查的 content。 */
|
||||
function submissionSchema(kind: SubmissionKind, body: TSchema): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
stageId: Id,
|
||||
author: Id,
|
||||
target: OptionalId,
|
||||
item: MULTI_ITEM_KINDS.has(kind) ? Id : Type.Null(),
|
||||
version: Version,
|
||||
kind: Type.Literal(kind),
|
||||
content: body,
|
||||
},
|
||||
CLOSED,
|
||||
);
|
||||
}
|
||||
|
||||
const SUBMISSION_KIND_NAMES = Object.keys(SUBMISSION_KINDS) as SubmissionKind[];
|
||||
const SUBMISSION_FORMS = {
|
||||
input: Object.fromEntries(SUBMISSION_KIND_NAMES.map((k) => [k, submissionSchema(k, SUBMISSION_SCHEMAS[k])])),
|
||||
stored: Object.fromEntries(SUBMISSION_KIND_NAMES.map((k) => [k, submissionSchema(k, STORED_BODY[k])])),
|
||||
} as Record<"input" | "stored", Record<SubmissionKind, TSchema>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 种类表
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KINDS = {
|
||||
/** 1 研究配置:冻结的配置、步骤清单、规则、方法参数、提示词哈希。内容由配置模块产生,这里只检查是 Json。 */
|
||||
config: AnyJson,
|
||||
|
||||
/** 2 阶段开:阶段名、第几轮(第零轮是 0)、这个阶段的应交名单。阶段编号由 flow 起,一个编号只开一次;同一个阶段名可以开好几次(如讨论)。 */
|
||||
stageOpen: Type.Object(
|
||||
{ stageId: Id, stage: Type.Enum(STAGES, { type: "string" }), round: Round, roster: Type.Array(Id, { minItems: 1 }) },
|
||||
CLOSED,
|
||||
),
|
||||
/** 2 阶段关:关闭即公开,此后这个阶段不再收提交。一个阶段只关一次。 */
|
||||
stageClose: Type.Object({ stageId: Id }, CLOSED),
|
||||
|
||||
/** 3 提交的每一版(经 submissions 收下后由 flow 写入)。字段定义按提交种类选,见 checkContent。 */
|
||||
submission: Type.Unsafe<StoredSubmission>({}),
|
||||
/**
|
||||
* 3 退回:被退回的那份内容原样存(它可能正是因为不合格式才被退回),连同退回理由。
|
||||
* 理由来自 Jev 的,jev 里带原始概率和结论;程序检查退回的,jev 为空。
|
||||
*/
|
||||
rejection: Type.Object(
|
||||
{
|
||||
stageId: Id,
|
||||
author: Id,
|
||||
kind: Type.Enum(SUBMISSION_KIND_NAMES, { type: "string" }),
|
||||
submitted: AnyJson,
|
||||
reason: Text,
|
||||
jev: Type.Union([Type.Object({ probabilities: AnyJson, conclusion: AnyJson }, CLOSED), Type.Null()]),
|
||||
},
|
||||
CLOSED,
|
||||
),
|
||||
/** 3 私人笔记的每一版:每次修改追加一版,以版本号最大的为准。 */
|
||||
note: Type.Object({ author: Id, version: Version, body: Text }, CLOSED),
|
||||
|
||||
/** 4 冻结协议:每个候选一份。内容由 flow 合成,先存 Json。 */
|
||||
protocol: Type.Object({ candidate: Id, protocol: AnyJson }, CLOSED),
|
||||
|
||||
/** 5 作者自评的封存:proposal 是那一版提案记录的序号。只由 ledger 在写提案时一并写入。 */
|
||||
seal: Type.Object({ proposal: Seq, range: Range }, CLOSED),
|
||||
/** 5 解封:放出哪几条封存(封存记录的序号)。合并而成的候选有几位作者就有几条。 */
|
||||
unseal: Type.Object({ candidate: Id, seals: Type.Array(Seq, { minItems: 1 }) }, CLOSED),
|
||||
|
||||
/** 6 冻结代码:候选或消融格的代码版本号(由 workspace 给出)和因子位置表。位置表先存 Json。 */
|
||||
code: Type.Object({ object: Id, version: Text, locations: AnyJson }, CLOSED),
|
||||
|
||||
/** 7 运行·登记要跑(占名额):运行编号由 flow 起,一个编号只登记一次;用途、轮次、对象(候选或格;基线没有)、运行定义。 */
|
||||
runPlanned: Type.Object(
|
||||
{
|
||||
run: Id,
|
||||
purpose: Type.Enum(["baseline", "forward", "ablation"], { type: "string" }),
|
||||
round: Round,
|
||||
object: OptionalId,
|
||||
definition: RunDefinition,
|
||||
},
|
||||
CLOSED,
|
||||
),
|
||||
/** 7 运行·确认启动(扣次)。一次运行只有一条,而且和"结果是没启动"只能有一样。 */
|
||||
runStarted: Type.Object({ run: Id }, CLOSED),
|
||||
/** 7 运行·结果:进程层面的结果,以及拿到产物时的独立判分(没有就空)。一次运行只有一条。 */
|
||||
runResult: Type.Object({ run: Id, process: ProcessResult, judged: Type.Union([JudgedResult, Type.Null()]) }, CLOSED),
|
||||
|
||||
/**
|
||||
* 8 决定:选批、采纳、选格、押的因子、入口分配、判重合并。decision 是决定种类。
|
||||
* 输入输出存 Json:ledger 不认识 rules。判重合并的输入里带 Jev 的原始概率。
|
||||
*/
|
||||
decision: Type.Object(
|
||||
{
|
||||
decision: Id,
|
||||
round: Round,
|
||||
rule: Text,
|
||||
ruleVersion: Type.Integer({ minimum: 1 }),
|
||||
params: AnyJson,
|
||||
seed: Type.Union([Text, Type.Null()]),
|
||||
input: AnyJson,
|
||||
output: AnyJson,
|
||||
},
|
||||
CLOSED,
|
||||
),
|
||||
|
||||
/** 9 结算:一次消融一条,各格结局、配合关系、解释的确认或否定等先存 Json。 */
|
||||
settlement: Type.Object({ ablation: Id, result: AnyJson }, CLOSED),
|
||||
|
||||
/** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */
|
||||
callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED),
|
||||
/** 10 模型调用每次回复的用量,reply 从 1 起。 */
|
||||
callUsage: Type.Object({ call: Id, reply: Type.Integer({ minimum: 1 }), usage: ModelUsage }, CLOSED),
|
||||
/** 10 模型调用结束:正常结束,或失败并写明原因。一次调用只有一条。 */
|
||||
callEnded: Type.Union([
|
||||
Type.Object({ call: Id, ending: Type.Literal("completed") }, CLOSED),
|
||||
Type.Object({ call: Id, ending: Type.Literal("failed"), reason: Text }, CLOSED),
|
||||
]),
|
||||
|
||||
/** 11 事件:违规、"按配置跳过"、"没测"及原因、停下的原因、运行状态未知等。event 是事件种类。 */
|
||||
event: Type.Object({ event: Id, reason: Text, objects: Type.Array(Id) }, CLOSED),
|
||||
} as const;
|
||||
|
||||
export type Kind = keyof typeof KINDS;
|
||||
export type Content<K extends Kind> = Static<(typeof KINDS)[K]>;
|
||||
/** 调用方能追加的种类:封存只由 ledger 在写提案时一并写。 */
|
||||
export type AppendKind = Exclude<Kind, "seal">;
|
||||
/** 写入时交的内容:除了提交(提案带作者自评),都和存下的内容相同。 */
|
||||
export type Input<K extends AppendKind> = K extends "submission" ? SubmissionInput : Content<K>;
|
||||
|
||||
/** 一条记录。序号就是写入的先后。 */
|
||||
export type LedgerRecord<K extends Kind = Kind> = {
|
||||
[P in K]: { seq: number; kind: P; hash: string; content: Content<P> };
|
||||
}[K];
|
||||
|
||||
function errorsOf(schema: TSchema, value: unknown): string {
|
||||
return Value.Errors(schema, value)
|
||||
.slice(0, 3)
|
||||
.map((e) => `${e.instancePath || "/"} ${e.message}`)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按种类检查内容。提交按提交种类选字段定义:写入时用带作者自评的那份(input),存下和读出时用不带的(stored)。
|
||||
*/
|
||||
export function checkContent(kind: Kind, content: unknown, form: "input" | "stored"): void {
|
||||
let schema: TSchema = KINDS[kind];
|
||||
let label = kind as string;
|
||||
if (kind === "submission") {
|
||||
const sub = (content as { kind?: unknown } | null)?.kind;
|
||||
if (typeof sub !== "string" || !Object.hasOwn(SUBMISSION_KINDS, sub)) {
|
||||
throw new Error(`账本记录"submission"的提交种类不认识:${String(sub)}`);
|
||||
}
|
||||
schema = SUBMISSION_FORMS[form][sub as SubmissionKind];
|
||||
label = `submission(提交种类 ${sub})`;
|
||||
}
|
||||
if (!Value.Check(schema, content)) {
|
||||
throw new Error(`账本记录"${label}"的内容不符合定义:${errorsOf(schema, content)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
||||
import { canonical, hash } from "../vocab/index.ts";
|
||||
import {
|
||||
checkContent,
|
||||
KINDS,
|
||||
type AppendKind,
|
||||
type Content,
|
||||
type Input,
|
||||
type Kind,
|
||||
type LedgerRecord,
|
||||
type SubmissionInput,
|
||||
type SubmissionSlot,
|
||||
} from "./kinds.ts";
|
||||
import { checkWrite, type WriteState } from "./rejections.ts";
|
||||
import { CREATE_SCHEMA, schemaState } from "./schema.ts";
|
||||
|
||||
// 一份研究的账本:一个 SQLite 文件、一张只追加的表(docs/结构.md 第四部分)。
|
||||
// 同一时刻只有一个控制进程打开它,由 plugin 的文件锁保证,这里不另做判断。
|
||||
// 谁能看到哪些记录不归这里,由 materials 按任务定;ledger 把记录原样交给程序。
|
||||
|
||||
interface Row {
|
||||
seq: number;
|
||||
kind: string;
|
||||
content: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/** 还没有结果的花钱动作(方法.md 第九部分)。恢复时 flow 逐个核对,怎么核对不归 ledger。 */
|
||||
export interface Pending {
|
||||
/** 登记了、还没有结果的运行,以及它有没有"确认启动"记录。按登记先后排。 */
|
||||
runs: Array<{ plan: LedgerRecord<"runPlanned">; started: boolean }>;
|
||||
/** 开始了、还没有结束的模型调用。按开始先后排。 */
|
||||
calls: Array<LedgerRecord<"callStarted">>;
|
||||
}
|
||||
|
||||
export class Ledger {
|
||||
readonly path: string;
|
||||
readonly #db: DatabaseSync;
|
||||
#inTransaction = false;
|
||||
|
||||
private constructor(path: string) {
|
||||
this.path = path;
|
||||
this.#db = new DatabaseSync(path);
|
||||
}
|
||||
|
||||
/** 打开一份研究的账本:新文件就建表,旧文件核对表结构版本号,对不上就报错。 */
|
||||
static open(path: string): Ledger {
|
||||
const ledger = new Ledger(path);
|
||||
if (schemaState(ledger.#db, path) === "new") {
|
||||
ledger.#atomic(() => {
|
||||
for (const sql of CREATE_SCHEMA) ledger.#db.exec(sql);
|
||||
});
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务:里面的几次追加要么全部写进去,要么一条都不写(方法.md 4.3 ⑱)。
|
||||
* fn 必须是同步函数:await 之后的写入发生在事务提交之后,回滚管不到它。
|
||||
* 所以返回值类型排除了 Promise,`npm run check` 会挡住 async 函数。不支持嵌套,嵌套时 SQLite 直接报错。
|
||||
*/
|
||||
transaction<T>(fn: () => T extends PromiseLike<unknown> ? never : T): T {
|
||||
return this.#atomic(fn as () => T);
|
||||
}
|
||||
|
||||
#atomic<T>(fn: () => T): T {
|
||||
this.#db.exec("BEGIN IMMEDIATE");
|
||||
this.#inTransaction = true;
|
||||
try {
|
||||
const result = fn();
|
||||
this.#db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.#db.exec("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
this.#inTransaction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条记录,返回存下的那一条。先按种类检查内容,再做写入时的四种拒收检查(rejections.ts)。
|
||||
* 写提案时,作者自评从提案里拆出来,在同一个事务里另存成一条封存,返回的是提案那一条。
|
||||
*/
|
||||
append<K extends AppendKind>(kind: K, content: Input<K>): LedgerRecord<K> {
|
||||
const write = (): LedgerRecord<K> => {
|
||||
if (kind !== "submission") return this.#put(kind, content as Content<K>);
|
||||
const input = content as SubmissionInput;
|
||||
checkContent("submission", input, "input");
|
||||
if (input.kind !== "proposal") return this.#put(kind, content as Content<K>);
|
||||
const { authorEstimate, ...proposal } = input.content;
|
||||
const record = this.#put("submission", { ...input, content: proposal });
|
||||
this.#put("seal", { proposal: record.seq, range: authorEstimate });
|
||||
return record as LedgerRecord<K>;
|
||||
};
|
||||
return this.#inTransaction ? write() : this.#atomic(write);
|
||||
}
|
||||
|
||||
#put<K extends Kind>(kind: K, content: Content<K>): LedgerRecord<K> {
|
||||
checkContent(kind, content, "stored");
|
||||
checkWrite(kind, content, this.#writeState);
|
||||
const digest = hash(content);
|
||||
const result = this.#db
|
||||
.prepare("INSERT INTO records (kind, content, hash) VALUES (?, ?, ?)")
|
||||
.run(kind, canonical(content), digest);
|
||||
return { seq: Number(result.lastInsertRowid), kind, hash: digest, content } as LedgerRecord<K>;
|
||||
}
|
||||
|
||||
/** 写入时拒收检查要读的状态,和写入在同一个事务里读。 */
|
||||
readonly #writeState: WriteState = {
|
||||
stageClosed: (stageId) => this.stageClosed(stageId),
|
||||
stageOpen: (stageId) =>
|
||||
this.#first("stageOpen", "json_extract(content, '$.stageId') = ?", stageId) as LedgerRecord<"stageOpen"> | undefined,
|
||||
latest: (slot) => this.latestSubmission(slot),
|
||||
runStarted: (run) => this.#first("runStarted", "json_extract(content, '$.run') = ?", run) !== undefined,
|
||||
ablationStarted: (round) =>
|
||||
this.#one(
|
||||
`SELECT s.seq FROM records s JOIN records p
|
||||
ON p.kind = 'runPlanned' AND json_extract(p.content, '$.run') = json_extract(s.content, '$.run')
|
||||
WHERE s.kind = 'runStarted'
|
||||
AND json_extract(p.content, '$.purpose') = 'ablation'
|
||||
AND json_extract(p.content, '$.round') = ?`,
|
||||
round,
|
||||
) !== undefined,
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 读取
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 全部记录,按序号排;读出的每条都按种类重新检查内容。 */
|
||||
records(): LedgerRecord[] {
|
||||
return this.#all("SELECT * FROM records ORDER BY seq").map((row) => this.#record(row));
|
||||
}
|
||||
|
||||
/** 这个阶段关了没有。 */
|
||||
stageClosed(stageId: string): boolean {
|
||||
return this.#first("stageClose", "json_extract(content, '$.stageId') = ?", stageId) !== undefined;
|
||||
}
|
||||
|
||||
/** 某份提交的最新一版(本人在这个阶段对这个对象、这个编号交的这种提交里版本号最大的那一版),一版都没有就是 undefined。 */
|
||||
latestSubmission(slot: SubmissionSlot): LedgerRecord<"submission"> | undefined {
|
||||
const row = this.#one(
|
||||
`SELECT * FROM records
|
||||
WHERE kind = 'submission'
|
||||
AND json_extract(content, '$.stageId') = ?
|
||||
AND json_extract(content, '$.author') = ?
|
||||
AND json_extract(content, '$.kind') = ?
|
||||
AND json_extract(content, '$.target') IS ?
|
||||
AND json_extract(content, '$.item') IS ?
|
||||
ORDER BY json_extract(content, '$.version') DESC LIMIT 1`,
|
||||
slot.stageId,
|
||||
slot.author,
|
||||
slot.kind,
|
||||
slot.target,
|
||||
slot.item,
|
||||
);
|
||||
return row === undefined ? undefined : (this.#record(row) as LedgerRecord<"submission">);
|
||||
}
|
||||
|
||||
/** 未完成的动作:登记了但没有结果的运行,开始了但没有结束的模型调用。按运行编号、调用编号对上。 */
|
||||
pending(): Pending {
|
||||
const runs = this.#all(
|
||||
`SELECT * FROM records p WHERE p.kind = 'runPlanned'
|
||||
AND NOT EXISTS (SELECT 1 FROM records r WHERE r.kind = 'runResult'
|
||||
AND json_extract(r.content, '$.run') = json_extract(p.content, '$.run'))
|
||||
ORDER BY p.seq`,
|
||||
).map((row) => {
|
||||
const plan = this.#record(row) as LedgerRecord<"runPlanned">;
|
||||
return { plan, started: this.#writeState.runStarted(plan.content.run) };
|
||||
});
|
||||
const calls = this.#all(
|
||||
`SELECT * FROM records c WHERE c.kind = 'callStarted'
|
||||
AND NOT EXISTS (SELECT 1 FROM records e WHERE e.kind = 'callEnded'
|
||||
AND json_extract(e.content, '$.call') = json_extract(c.content, '$.call'))
|
||||
ORDER BY c.seq`,
|
||||
).map((row) => this.#record(row) as LedgerRecord<"callStarted">);
|
||||
return { runs, calls };
|
||||
}
|
||||
|
||||
/** 某种记录里满足条件的第一条(按序号),没有是 undefined。 */
|
||||
#first(kind: Kind, where: string, ...params: SQLInputValue[]): LedgerRecord | undefined {
|
||||
const row = this.#one(`SELECT * FROM records WHERE kind = ? AND ${where} ORDER BY seq LIMIT 1`, kind, ...params);
|
||||
return row === undefined ? undefined : this.#record(row);
|
||||
}
|
||||
|
||||
#one(sql: string, ...params: SQLInputValue[]): Row | undefined {
|
||||
return this.#db.prepare(sql).get(...params) as Row | undefined;
|
||||
}
|
||||
|
||||
#all(sql: string, ...params: SQLInputValue[]): Row[] {
|
||||
return this.#db.prepare(sql).all(...params) as unknown as Row[];
|
||||
}
|
||||
|
||||
/** 把一行还原成记录,并按种类检查内容;种类不认识或内容不符合定义都直接报错。 */
|
||||
#record(row: Row): LedgerRecord {
|
||||
if (!Object.hasOwn(KINDS, row.kind)) {
|
||||
throw new Error(`账本第 ${row.seq} 条记录的种类"${row.kind}"不认识`);
|
||||
}
|
||||
const kind = row.kind as Kind;
|
||||
const content: unknown = JSON.parse(row.content);
|
||||
checkContent(kind, content, "stored");
|
||||
return { seq: Number(row.seq), kind, hash: row.hash, content } as LedgerRecord;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SUBMISSION_KINDS, type Stage } from "../vocab/index.ts";
|
||||
import type { Content, Kind, LedgerRecord, SubmissionSlot } from "./kinds.ts";
|
||||
|
||||
// 写入时由 ledger 拒绝的四种情况(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况"),在写入的同一个事务里查。
|
||||
// 只有这四种。记录之间别的先后(例如运行结果前要先有登记)和提交种类与阶段对不对得上都不查:
|
||||
// flow 调 ledger 是模块之间的调用,不是系统边界。"只发生一次"由 schema.ts 的唯一约束管。
|
||||
// 提交内容的字段归 submissions,该不该开下一个阶段归 flow。
|
||||
|
||||
/** 检查要读的几样当前状态,由 Ledger 在同一个事务里提供。 */
|
||||
export interface WriteState {
|
||||
stageClosed(stageId: string): boolean;
|
||||
/** 开这个阶段的那条记录,没有是 undefined。 */
|
||||
stageOpen(stageId: string): LedgerRecord<"stageOpen"> | undefined;
|
||||
/** 某份提交现在的最新一版,一版都没有是 undefined。 */
|
||||
latest(slot: SubmissionSlot): LedgerRecord<"submission"> | undefined;
|
||||
runStarted(run: string): boolean;
|
||||
/** 这一轮有没有消融格的运行已经确认启动。 */
|
||||
ablationStarted(round: number): boolean;
|
||||
}
|
||||
|
||||
/** 实现和核验阶段的提交不能替换(方法.md 第五部分总原则):实现、修复、核验、消融实现、消融核验。 */
|
||||
const NOT_REPLACEABLE: ReadonlySet<Stage> = new Set([
|
||||
"implementation",
|
||||
"verification",
|
||||
"ablationImplementation",
|
||||
"ablationVerification",
|
||||
]);
|
||||
|
||||
export function checkWrite(kind: Kind, content: unknown, state: WriteState): void {
|
||||
if (kind === "submission") checkSubmission(content as Content<"submission">, state);
|
||||
if (kind === "runResult") checkRunResult(content as Content<"runResult">, state);
|
||||
if (kind === "stageClose") checkStageClose(content as Content<"stageClose">, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 第 1 条:只收开着的阶段的提交,即有开阶段记录、没有关阶段记录。
|
||||
* 第 2 条:新的一版必须正好接在本人在这个阶段、对同一个对象的最新一版后面(第一版是 1);
|
||||
* 实现、修复、核验、消融实现、消融核验只能交第一版。
|
||||
*/
|
||||
function checkSubmission(s: Content<"submission">, state: WriteState): void {
|
||||
const where = `提交 ${s.kind}(阶段 ${s.stageId},作者 ${s.author},对象 ${s.target ?? "无"},编号 ${s.item ?? "无"},第 ${s.version} 版)`;
|
||||
if (state.stageOpen(s.stageId) === undefined || state.stageClosed(s.stageId)) {
|
||||
throw new Error(`拒收${where}:阶段 ${s.stageId} 没有开着,只收开着的阶段的提交`);
|
||||
}
|
||||
const stage = SUBMISSION_KINDS[s.kind].stage;
|
||||
if (s.version > 1 && NOT_REPLACEABLE.has(stage)) {
|
||||
throw new Error(`拒收${where}:${stage} 阶段的提交不能替换(方法.md 第五部分)`);
|
||||
}
|
||||
const latest = state.latest(s)?.content.version ?? 0;
|
||||
if (s.version !== latest + 1) {
|
||||
throw new Error(
|
||||
latest === 0
|
||||
? `拒收${where}:本人在这个阶段对这个对象还没有交过,第一份应是第 1 版`
|
||||
: `拒收${where}:要替换的第 ${s.version - 1} 版不是本人在这个阶段对这个对象的最新一版(最新是第 ${latest} 版)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 第 3 条:运行结果要写在"确认启动"之后。例外是"没启动":它本来就没有启动记录(方法.md 第七部分)。 */
|
||||
function checkRunResult(r: Content<"runResult">, state: WriteState): void {
|
||||
if (r.process.ending !== "notStarted" && !state.runStarted(r.run)) {
|
||||
throw new Error(`拒收运行 ${r.run} 的结果(${r.process.ending}):这次运行还没有"确认启动"记录`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 第 4 条:这一轮已经有消融格确认启动了,就不能再关解释阶段(方法.md 4.3 ⑭:解释冻结必须早于任何一格启动)。 */
|
||||
function checkStageClose(c: Content<"stageClose">, state: WriteState): void {
|
||||
const open = state.stageOpen(c.stageId);
|
||||
if (open?.content.stage === "explanation" && state.ablationStarted(open.content.round)) {
|
||||
throw new Error(
|
||||
`不能关解释阶段 ${c.stageId}:第 ${open.content.round} 轮已经有消融格确认启动,解释冻结必须早于任何一格启动(方法.md 4.3 ⑭)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// 账本的表结构(docs/结构.md 第四部分"全部只追加"):只有一张表,每一行是序号、种类、内容、内容的哈希。
|
||||
// 表结构一改就把版本号加 1;旧文件版本号对不上时打开报错,不迁移,也不逐字核对建表语句。
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
/** 新文件要执行的建表语句,由 Ledger 在一个事务里执行,最后写入版本号。 */
|
||||
export const CREATE_SCHEMA = [
|
||||
// 序号用 AUTOINCREMENT:只增不复用,就是写入的先后。STRICT 让列的类型真的生效。
|
||||
`CREATE TABLE records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
) STRICT`,
|
||||
// 整张表禁止修改、禁止删除。要更正就追加一条新记录。
|
||||
`CREATE TRIGGER records_no_update BEFORE UPDATE ON records
|
||||
BEGIN SELECT RAISE(ABORT, '账本只能追加:禁止修改记录'); END`,
|
||||
`CREATE TRIGGER records_no_delete BEFORE DELETE ON records
|
||||
BEGIN SELECT RAISE(ABORT, '账本只能追加:禁止删除记录'); END`,
|
||||
// 唯一约束:有自己编号、按定义只发生一次的记录,同一个编号只能有一条,重复就报错。其余记录照写,不设唯一键。
|
||||
// 记录之间的先后(例如结束前要先有开始)不在这里查。
|
||||
`CREATE UNIQUE INDEX stage_open_once ON records (json_extract(content, '$.stageId')) WHERE kind = 'stageOpen'`,
|
||||
`CREATE UNIQUE INDEX stage_close_once ON records (json_extract(content, '$.stageId')) WHERE kind = 'stageClose'`,
|
||||
`CREATE UNIQUE INDEX run_planned_once ON records (json_extract(content, '$.run')) WHERE kind = 'runPlanned'`,
|
||||
// "确认启动"只能有一条,而且"确认没启动"(结果是没启动)也算在这一条里:
|
||||
// 两样都有,就是同一次运行既扣次又退还(方法.md 第七部分"什么时候扣次数")。
|
||||
`CREATE UNIQUE INDEX run_started_or_not_started_once ON records (json_extract(content, '$.run'))
|
||||
WHERE kind = 'runStarted' OR (kind = 'runResult' AND json_extract(content, '$.process.ending') = 'notStarted')`,
|
||||
`CREATE UNIQUE INDEX run_result_once ON records (json_extract(content, '$.run')) WHERE kind = 'runResult'`,
|
||||
`CREATE UNIQUE INDEX call_started_once ON records (json_extract(content, '$.call')) WHERE kind = 'callStarted'`,
|
||||
`CREATE UNIQUE INDEX call_ended_once ON records (json_extract(content, '$.call')) WHERE kind = 'callEnded'`,
|
||||
`CREATE UNIQUE INDEX settlement_once ON records (json_extract(content, '$.ablation')) WHERE kind = 'settlement'`,
|
||||
`PRAGMA user_version=${SCHEMA_VERSION}`,
|
||||
];
|
||||
|
||||
/**
|
||||
* 看这个文件是新文件还是当前版本的账本,别的情况报错;是账本才设连接参数,不改别人的库。
|
||||
* synchronous=FULL:提交即落盘。账本存的是"预测冻结在结果之前"的证据,掉电丢掉最后几条等于丢证据。
|
||||
* 版本号存在 SQLite 自带的 user_version 里;新文件的 user_version 是 0,而且没有任何表。
|
||||
*/
|
||||
export function schemaState(db: DatabaseSync, path: string): "new" | "current" {
|
||||
const version = Number((db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version);
|
||||
const objects = Number((db.prepare("SELECT count(*) AS n FROM sqlite_master").get() as { n: number }).n);
|
||||
const state = version === SCHEMA_VERSION ? "current" : version === 0 && objects === 0 ? "new" : undefined;
|
||||
if (state !== undefined) {
|
||||
db.exec("PRAGMA journal_mode=WAL");
|
||||
db.exec("PRAGMA synchronous=FULL");
|
||||
return state;
|
||||
}
|
||||
throw new Error(
|
||||
`账本 ${path} 的表结构版本是 ${version}(库里已有 ${objects} 个表、索引或触发器),本程序只认版本 ${SCHEMA_VERSION}。` +
|
||||
"旧研究不迁移,请另开一份新研究",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user