/** * 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; list(projectDir: string, prefix?: string): Promise; head(projectDir: string, filePath: string): Promise; read(projectDir: string, filePath: string, at?: VersionId): Promise; commit(projectDir: string, filePath: string, req: CommitRequest): Promise; remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise; diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise; history(projectDir: string, filePath: string, limit?: number): Promise; } 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; counter: number; } /** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */ function createKeySerializer(): (key: string, fn: () => Promise) => Promise { const tails = new Map>(); return (key: string, fn: () => Promise): Promise => { 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(); const serialize = createKeySerializer(); /* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */ interface PersistedVersion extends Omit { content: string; // base64 } function loadPersisted(): void { if (persistPath === undefined) return; try { const raw = JSON.parse(readFileSync(persistPath, "utf8")) as { repos: Record }>; }; for (const [dir, repo] of Object.entries(raw.repos)) { const files = new Map(); 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 }> = {}; for (const [dir, repo] of repos) { const files: Record = {}; 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 => { 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 => { 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; }, }; }