Files
curriculum-project-hub/hub/src/database/filelib/versionStore.ts
T

281 lines
10 KiB
TypeScript

/**
* VersionStore port(契约 C1)+ 开发用内存实现。
*
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
*
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { FileLibError } from "./model.js";
export type VersionId = string;
export interface CommitRequest {
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
readonly baseVersion: VersionId | null;
readonly content: string | Buffer;
readonly message?: string | undefined;
readonly author?: string | undefined;
}
export type CommitResult =
| { readonly status: "ok"; readonly version: VersionId }
| { readonly status: "conflict"; readonly currentVersion: VersionId };
export interface VersionInfo {
readonly version: VersionId;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export interface VersionStore {
init(projectDir: string): Promise<void>;
list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
head(projectDir: string, filePath: string): Promise<VersionId>;
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
}
interface StoredVersion {
readonly version: VersionId;
readonly content: Buffer;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
readonly deleted: boolean;
}
interface Repo {
/** 每文件一条版本链(D16:版本是文件级的,互不干扰)。 */
readonly files: Map<string, StoredVersion[]>;
counter: number;
}
/** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
function toBuffer(content: string | Buffer): Buffer {
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
}
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
function naiveDiff(fromText: string, toText: string): string {
const a = fromText.split("\n");
const b = toText.split("\n");
const out: string[] = ["--- a", "+++ b"];
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const al = a[i];
const bl = b[i];
if (al === bl) {
if (al !== undefined) out.push(` ${al}`);
} else {
if (al !== undefined) out.push(`-${al}`);
if (bl !== undefined) out.push(`+${bl}`);
}
}
return out.join("\n");
}
export function createInMemoryVersionStore(persistPath?: string): VersionStore {
const repos = new Map<string, Repo>();
const serialize = createKeySerializer();
/* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */
interface PersistedVersion extends Omit<StoredVersion, "content"> {
content: string; // base64
}
function loadPersisted(): void {
if (persistPath === undefined) return;
try {
const raw = JSON.parse(readFileSync(persistPath, "utf8")) as {
repos: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }>;
};
for (const [dir, repo] of Object.entries(raw.repos)) {
const files = new Map<string, StoredVersion[]>();
for (const [filePath, versions] of Object.entries(repo.files)) {
files.set(filePath, versions.map((v) => ({ ...v, content: Buffer.from(v.content, "base64") })));
}
repos.set(dir, { files, counter: repo.counter });
}
} catch { /* 无快照或损坏 → 空库起步(开发语义) */ }
}
function savePersisted(): void {
if (persistPath === undefined) return;
const out: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }> = {};
for (const [dir, repo] of repos) {
const files: Record<string, PersistedVersion[]> = {};
for (const [filePath, versions] of repo.files) {
files[filePath] = versions.map((v) => ({ ...v, content: v.content.toString("base64") }));
}
out[dir] = { counter: repo.counter, files };
}
try {
mkdirSync(path.dirname(persistPath), { recursive: true });
const tmp = `${persistPath}.tmp`;
writeFileSync(tmp, JSON.stringify({ repos: out }), "utf8");
renameSync(tmp, persistPath);
} catch { /* 快照失败不影响开发使用 */ }
}
loadPersisted();
function requireRepo(projectDir: string): Repo {
const repo = repos.get(projectDir);
if (repo === undefined) {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
return repo;
}
function liveVersion(repo: Repo, filePath: string): StoredVersion {
const chain = repo.files.get(filePath);
const latest = chain?.[chain.length - 1];
if (chain === undefined || latest === undefined || latest.deleted) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return latest;
}
function findVersion(repo: Repo, filePath: string, version: VersionId): StoredVersion {
const found = repo.files.get(filePath)?.find((v) => v.version === version);
if (found === undefined) {
throw new FileLibError(404, "version_not_found", `version not found: ${filePath}@${version}`);
}
return found;
}
return {
async init(projectDir) {
await serialize(projectDir, async () => {
// S7:幂等,重复调用不报错、不重建。
const created = repos.get(projectDir) === undefined;
repos.set(projectDir, repos.get(projectDir) ?? { files: new Map(), counter: 0 });
if (created) savePersisted();
});
},
async list(projectDir, prefix) {
const repo = requireRepo(projectDir);
const out: FileEntry[] = [];
for (const [path, chain] of repo.files) {
const latest = chain[chain.length - 1];
if (latest === undefined || latest.deleted) continue;
if (prefix !== undefined && !path.startsWith(prefix)) continue;
out.push({ path, size: latest.content.byteLength });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
return liveVersion(requireRepo(projectDir), filePath).version;
},
async read(projectDir, filePath, at) {
const repo = requireRepo(projectDir);
if (at !== undefined) return findVersion(repo, filePath, at).content;
return liveVersion(repo, filePath).content;
},
async commit(projectDir, filePath, req) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
// S2:新建(baseVersion null)要求文件当前不存在;否则要求 baseVersion 精确等于当前版本(S1)。
if (req.baseVersion === null) {
if (currentVersion !== null) return { status: "conflict", currentVersion };
} else if (req.baseVersion !== currentVersion) {
return {
status: "conflict",
currentVersion: currentVersion ?? req.baseVersion,
};
}
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: toBuffer(req.content),
message: req.message ?? `commit ${version}`,
author: req.author,
committedAt: new Date().toISOString(),
deleted: false,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
if (currentVersion === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
if (baseVersion !== currentVersion) return { status: "conflict", currentVersion };
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: Buffer.alloc(0),
message: `remove ${filePath}`,
author: undefined,
committedAt: new Date().toISOString(),
deleted: true,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
const repo = requireRepo(projectDir);
const a = findVersion(repo, filePath, from);
const b = findVersion(repo, filePath, to);
return naiveDiff(a.content.toString("utf8"), b.content.toString("utf8"));
},
async history(projectDir, filePath, limit) {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const infos: VersionInfo[] = chain.map((v) => ({
version: v.version,
message: v.message,
author: v.author,
committedAt: v.committedAt,
}));
const ordered = infos.reverse();
return limit !== undefined ? ordered.slice(0, limit) : ordered;
},
};
}