forked from bai/curriculum-project-hub
281 lines
10 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|