feat(filelib)!: VersionStore 改为真 git,一项目一仓库

内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
This commit is contained in:
2026-07-27 15:49:26 +08:00
parent a306c58db2
commit 82241afb56
16 changed files with 1150 additions and 40 deletions
+46 -9
View File
@@ -17,7 +17,20 @@ import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32;
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
/** 单文件上限的出厂默认值(OPEN-5 初值)。实际生效值见 `resolveMaxFileBytes`。 */
export const FILE_CONTENT_MAX_BYTES_DEFAULT = 10 * 1024 * 1024;
/**
* 单文件字节上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB。
* 非法值(非正整数/NaN)按缺省处理 —— 配置写错不该让上传静默变成 0 上限。
*/
export function resolveMaxFileBytes(raw: string | undefined = process.env["HUB_FILELIB_MAX_FILE_BYTES"]): number {
if (raw === undefined || raw.trim() === "") return FILE_CONTENT_MAX_BYTES_DEFAULT;
const parsed = Number(raw.trim());
if (!Number.isSafeInteger(parsed) || parsed <= 0) return FILE_CONTENT_MAX_BYTES_DEFAULT;
return parsed;
}
const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
@@ -52,6 +65,8 @@ export function validateFilePath(raw: string): string {
export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient;
readonly versionStore: VersionStore;
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
readonly maxFileBytes?: number | undefined;
}
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
@@ -101,13 +116,25 @@ function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding
: { encoding: "utf8", content: buffer.toString("utf8") };
}
function checkSize(content: string | Buffer): void {
function checkSize(content: string | Buffer, maxBytes: number): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > FILE_CONTENT_MAX_BYTES) {
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`);
if (bytes > maxBytes) {
throw new FileLibError(413, "file_too_large", `file exceeds ${maxBytes} bytes`);
}
}
/**
* commit message 的默认文案:`【用户名】修改了【路径】`。
* 用户名取 displayName,缺失回退 userId(权限判定从不看它)。
* 显式传 message 的调用方优先 —— 这里只填空缺。
*/
export function defaultCommitMessage(actor: FileLibActor, filePath: string): string {
const who = actor.displayName === undefined || actor.displayName.trim() === ""
? actor.userId
: actor.displayName.trim();
return `${who}】修改了【${filePath}`;
}
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
@@ -221,14 +248,21 @@ export async function commitFile(
): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content);
checkSize(content, deps.maxFileBytes ?? resolveMaxFileBytes());
const project = await requireProject(deps, actor, projectId, "EDIT");
// 调用方传了非空 message 则用它,否则回退默认文案。
// 空串必须当作没传:`git commit -m ""` 会以 empty commit message 失败。
const trimmedMessage = input.message?.trim();
const message = trimmedMessage === undefined || trimmedMessage === ""
? defaultCommitMessage(actor, filePath)
: trimmedMessage;
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion,
content,
message: input.message,
author: actor.userId,
message,
// 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
author: { userId: actor.userId, displayName: actor.displayName },
});
if (result.status === "conflict") {
@@ -247,7 +281,7 @@ export async function commitFile(
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message: input.message ?? null },
{ version: result.version, message },
);
return { version: result.version };
}
@@ -261,7 +295,10 @@ export async function deleteFile(
): Promise<void> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion);
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion, {
userId: actor.userId,
displayName: actor.displayName,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion,
+408
View File
@@ -0,0 +1,408 @@
/**
* VersionStore(契约 C1)的真 git 实现 —— ADR-0030。
*
* 一个项目一个 git 仓库,位于 <storageRoot>/<nodeId>(nodeId 是 uuid;
* 文件夹不落盘,见 ADR-0030 Context)。VersionId = commit hash。
*
* D16 文件级版本的映射(ADR-0030 Decision):一次写只碰一个路径、只产生一个
* commit;某文件的版本 = `git log -1 -- <path>` 的 hash。因此 a.md 的提交不出现在
* b.md 的 log 里,两者 baseVersion 互不失效 —— 尽管 commit 本身是仓库级对象。
*
* 不引 npm 依赖:三条 execFile 就够,见 ADR-0030 Alternatives。
*/
import { execFile } from "node:child_process";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { FileLibError } from "./model.js";
import type {
CommitAuthor,
CommitRequest,
CommitResult,
FileEntry,
VersionId,
VersionInfo,
VersionStore,
} from "./versionStore.js";
/** 无 author 时的固定身份(ADR-0030 Consequences:不再是 undefined)。 */
const FALLBACK_AUTHOR = "filelib";
/** 提交者 email 域名:`<userId>@filelib.paradigm-edu.net`。 */
const AUTHOR_EMAIL_DOMAIN = "filelib.paradigm-edu.net";
/**
* 每次调用都带的加固(ADR-0030 Decision)。项目仓库是老师上传的**数据**,
* 不是可信代码:hooks 必须禁用,全局/系统 gitconfig 必须隔离,否则仓库内容
* 或开发者机器上的配置就能改变服务端行为。
*/
const HARDENING_ARGS = ["-c", "core.hooksPath=", "-c", "commit.gpgsign=false"] as const;
/**
* 仓库定位参数。**这两个不能省**:git 默认会沿目录树**向上**找 `.git`,
* 而 storage root 很可能就在另一个 git 仓库里(本地开发的默认值
* `hub/.filelib-repos` 就在本 repo 内)。不钉死的后果是项目目录没自己的 `.git`
* 时,所有命令默默落到**外层仓库**上 —— 轻则 `git add` 报 ignored,
* 重则把老师的文件提交进源码仓。
*/
function repoArgs(projectDir: string): readonly string[] {
return [`--git-dir=${path.join(projectDir, ".git")}`, `--work-tree=${projectDir}`];
}
const HARDENING_ENV = {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
// 文件名永远不被重解释为 pathspec magic(`:(glob)` 等)。
GIT_LITERAL_PATHSPECS: "1",
// 仓库不得因为凭据提示卡住一个 HTTP 请求。
GIT_TERMINAL_PROMPT: "0",
} as const;
/**
* 继承来的这几个会劫持全部命令(比如 hub 自身被一个 git hook 启动时),
* 必须从子进程 env 里**删掉**而不是置空 —— 置空在 git 里的含义并不统一。
* 我们只认 repoArgs 里显式传的那一份。
*/
const STRIPPED_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY"] as const;
function childEnv(extra: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, ...HARDENING_ENV, ...extra };
for (const key of STRIPPED_ENV) delete env[key];
return env;
}
interface GitResult {
readonly stdout: Buffer;
readonly code: number;
readonly stderr: string;
}
/** execFile 的 args 数组形式:不拼 shell,文件名不参与命令解析。 */
function runGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, [...repoArgs(cwd), ...args], env);
}
/**
* 不钉 --git-dir 的调用。**只给 `git init` 用** —— 那一刻 `.git` 尚不存在,
* 钉上去 git 会直接报错。init 自己总是在 cwd 建仓,不会向上找。
*/
function runGitBare(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, args, env);
}
function execGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
): Promise<GitResult> {
return new Promise((resolve, reject) => {
execFile(
"git",
[...HARDENING_ARGS, ...args],
{
cwd,
encoding: "buffer",
env: childEnv(env),
maxBuffer: 64 * 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(String(stdout));
const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr);
if (error === null) {
resolve({ stdout: out, code: 0, stderr: err });
return;
}
const code = (error as NodeJS.ErrnoException & { code?: number | string }).code;
if (code === "ENOENT") {
// 坑:spawn 的 ENOENT 有两种来源且**报错完全一样**(都是 path:"git"、
// syscall:"spawn git") —— git 真的不在 PATH 上,或者 cwd 目录不存在。
// 后者在这里是常态(DB 里有 storageDir、磁盘上却没建过,比如内存 store
// 时代留下的旧项目),必须报 repo_not_found 而不是冤枉 git 没装。
void access(cwd).then(
() => reject(new FileLibError(500, "git_missing", "git executable not found on PATH")),
() => reject(new FileLibError(404, "repo_not_found", `repository directory missing: ${cwd}`)),
);
return;
}
// 非零退出是常规控制流(文件不存在、空仓库等),交给调用点判断。
resolve({ stdout: out, code: typeof code === "number" ? code : 1, stderr: err });
},
);
});
}
async function requireGit(cwd: string, args: readonly string[], env?: Record<string, string>): Promise<Buffer> {
const res = await runGit(cwd, args, env);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
async function requireGitBare(cwd: string, args: readonly string[]): Promise<Buffer> {
const res = await runGitBare(cwd, args);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
/**
* S4:同仓库写操作串行化。git 的并发写会在 index.lock 上打架,
* 串行化把它变成干净的 conflict 返回值而不是锁错误。仅进程内有效(ADR-0030)。
*/
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;
};
}
/**
* 仓库内相对路径的再校验。fileService.validateFilePath 已经把过一遍,
* 但 ADR-0030 把这条从卫生升级为安全边界 —— 本层不信调用方。
*/
function safeRelPath(filePath: string): string {
const normalized = filePath.normalize("NFC");
if (normalized === "" || path.isAbsolute(normalized) || normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
const segments = normalized.split("/");
for (const segment of segments) {
if (segment === "" || segment === "." || segment === ".." || segment === ".git") {
throw new FileLibError(400, "invalid_path", `unsafe path segment in: ${filePath}`);
}
}
// 解析后必须仍在仓库内(符号链接由 git 自身不跟随 + 此处前缀检查共同兜住)。
const resolved = path.posix.normalize(normalized);
if (resolved.startsWith("..") || path.isAbsolute(resolved)) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
return resolved;
}
/**
* 提交者身份 → git author/committer。
* name = displayName(缺失回退 userId);email = `<userId>@filelib.paradigm-edu.net`。
* email 用 userId 而不用 displayName:昵称会改,身份追溯不能跟着漂。
* name 里的换行/`<`/`>` 必须清掉 —— 它们会破坏 git 的 ident 行格式。
*/
function authorEnv(author: CommitAuthor | undefined): Record<string, string> {
const rawName = author?.displayName;
const fallback = author?.userId ?? FALLBACK_AUTHOR;
const name = (rawName === undefined || rawName.trim() === "" ? fallback : rawName.trim())
.replace(/[<>\n\r]/g, " ")
.trim();
const localPart = (author?.userId ?? FALLBACK_AUTHOR).replace(/[^\w.-]/g, "_");
const email = `${localPart}@${AUTHOR_EMAIL_DOMAIN}`;
return {
GIT_AUTHOR_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_AUTHOR_EMAIL: email,
GIT_COMMITTER_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_COMMITTER_EMAIL: email,
};
}
export function createGitVersionStore(): VersionStore {
const serialize = createKeySerializer();
/**
* 未 init → repo_not_found。
* 不能用 `git rev-parse --git-dir`:repoArgs 已把 --git-dir 钉死,rev-parse 会
* 原样回显它而不验证存在;而不钉死时它又会向上找到外层仓库。所以直接
* 测文件系统:项目目录里必须有属于它自己的 `.git`。
*/
async function requireRepo(projectDir: string): Promise<void> {
try {
await access(path.join(projectDir, ".git"));
} catch {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
}
/** 该路径在 HEAD 上的当前版本;不存在(或从未提交)→ null。 */
async function currentVersion(projectDir: string, filePath: string): Promise<VersionId | null> {
const exists = await runGit(projectDir, ["cat-file", "-e", `HEAD:${filePath}`]);
if (exists.code !== 0) return null; // 空仓库、已删除、或从无此文件
const log = await runGit(projectDir, ["log", "-1", "--format=%H", "--", filePath]);
if (log.code !== 0) return null;
const hash = log.stdout.toString("utf8").trim();
return hash === "" ? null : hash;
}
async function headVersion(projectDir: string, filePath: string): Promise<VersionId> {
const version = await currentVersion(projectDir, filePath);
if (version === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return version;
}
/** commit id 必须存在,否则 version_not_found(而非把 git 错误透出去)。 */
async function requireCommit(projectDir: string, version: VersionId): Promise<void> {
const res = await runGit(projectDir, ["rev-parse", "--verify", "--quiet", `${version}^{commit}`]);
if (res.code !== 0) {
throw new FileLibError(404, "version_not_found", `version not found: ${version}`);
}
}
/** 提交暂存区里已备好的单个路径。返回新 commit hash。 */
async function commitPath(
projectDir: string,
filePath: string,
message: string,
author: CommitAuthor | undefined,
): Promise<VersionId> {
const env = authorEnv(author);
await requireGit(projectDir, ["commit", "--quiet", "--allow-empty", "-m", message, "--", filePath], env);
const hash = await requireGit(projectDir, ["rev-parse", "HEAD"]);
return hash.toString("utf8").trim();
}
return {
/** S7 幂等:已有仓库就不重建。 */
async init(projectDir) {
await serialize(projectDir, async () => {
await mkdir(projectDir, { recursive: true });
try {
await access(path.join(projectDir, ".git"));
return; // 已是仓库,不清空
} catch { /* 继续 init */ }
// init 用 runGitBare:此时 .git 尚不存在,钉 --git-dir 反而会让 git 报错。
await requireGitBare(projectDir, ["init", "--quiet"]);
// 默认分支名不依赖宿主 git 版本/配置(全局配置已被隔离)。
await requireGit(projectDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
});
},
async list(projectDir, prefix) {
await requireRepo(projectDir);
const res = await runGit(projectDir, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
if (res.code !== 0) return []; // 空仓库(无 HEAD)
const out: FileEntry[] = [];
for (const record of res.stdout.toString("utf8").split("\0")) {
if (record === "") continue;
// 形如:"<mode> <type> <object> <size>\t<path>"
const tab = record.indexOf("\t");
if (tab === -1) continue;
const meta = record.slice(0, tab).split(/\s+/);
const entryPath = record.slice(tab + 1);
if (meta[1] !== "blob") continue;
if (prefix !== undefined && !entryPath.startsWith(prefix)) continue;
out.push({ path: entryPath, size: Number.parseInt(meta[3] ?? "0", 10) || 0 });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
await requireRepo(projectDir);
return headVersion(projectDir, safeRelPath(filePath));
},
async read(projectDir, filePath, at) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
if (at === undefined) {
await headVersion(projectDir, rel); // 存在性 → 404 file_not_found
const res = await runGit(projectDir, ["show", `HEAD:${rel}`]);
if (res.code !== 0) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
return res.stdout;
}
await requireCommit(projectDir, at);
const res = await runGit(projectDir, ["show", `${at}:${rel}`]);
if (res.code !== 0) {
// commit 存在但该版本里没有这个路径。
throw new FileLibError(404, "version_not_found", `version not found: ${rel}@${at}`);
}
return res.stdout;
},
async commit(projectDir, filePath, req: CommitRequest): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
// S2:baseVersion=null 表新建,已存在即 conflict;
// S1:否则要求 baseVersion 精确等于当前版本。
if (req.baseVersion === null) {
if (current !== null) return { status: "conflict", currentVersion: current };
} else if (req.baseVersion !== current) {
return { status: "conflict", currentVersion: current ?? req.baseVersion };
}
const abs = path.join(projectDir, rel);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, req.content);
await requireGit(projectDir, ["add", "--", rel]);
const message = req.message ?? (req.baseVersion === null ? `create ${rel}` : `update ${rel}`);
const version = await commitPath(projectDir, rel, message, req.author);
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion, author): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
if (current === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
if (baseVersion !== current) return { status: "conflict", currentVersion: current };
await requireGit(projectDir, ["rm", "--quiet", "--", rel]);
const version = await commitPath(projectDir, rel, `remove ${rel}`, author);
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
await requireCommit(projectDir, from);
await requireCommit(projectDir, to);
const res = await runGit(projectDir, ["diff", from, to, "--", rel]);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git diff failed: ${res.stderr.trim()}`);
}
return res.stdout.toString("utf8");
},
async history(projectDir, filePath, limit) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
const args = ["log", "--format=%H%x1f%an%x1f%aI%x1f%s%x1e"];
if (limit !== undefined) args.push(`-${limit}`);
args.push("--", rel);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: VersionInfo[] = [];
for (const record of res.stdout.toString("utf8").split("\x1e")) {
const line = record.trim();
if (line === "") continue;
const [version, author, committedAt, message] = line.split("\x1f");
if (version === undefined) continue;
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
});
}
return out; // git log 已是新→旧
},
};
}
+2
View File
@@ -43,5 +43,7 @@ export async function requireFileLibActor(
return {
userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
displayName: auth.user.displayName,
};
}
+2
View File
@@ -21,6 +21,8 @@ export interface FileLibRouteDeps {
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
readonly exportAdapters: readonly ExportAdapter[];
/** 单文件字节上限(`HUB_FILELIB_MAX_FILE_BYTES`)。 */
readonly maxFileBytes: number;
}
/** 组装 treeService 依赖(路由处理内直接使用)。 */
+8
View File
@@ -35,6 +35,11 @@ export interface FileLibActor {
readonly userId: string;
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
readonly isWebsiteAdmin: boolean;
/**
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
* 权限判定一律用 userId。缺失时回退到 userId。
*/
readonly displayName?: string | undefined;
}
export interface TreeServiceDeps {
@@ -229,6 +234,8 @@ export async function createNode(
}
if (input.kind === "PROJECT") {
// ADR-0030:项目扁平居于同一根下、以 uuid 命名;名字不进路径(所以 rename
// 不动磁盘)。FOLDER 永不赋值 —— 文件夹不落盘,只存在于 DB 的 parentId/pathIds。
storageDir = path.join(deps.storageRoot, id);
}
@@ -308,6 +315,7 @@ export async function createNode(
});
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
// ADR-0030:这一步真的建目录并 `git init`;宿主无 git 则此处 provision_failed。
if (input.kind === "PROJECT" && storageDir !== null) {
try {
await deps.versionStore.init(storageDir);
+33 -9
View File
@@ -1,12 +1,14 @@
/**
* VersionStore port(契约 C1)+ 开发用内存实现。
* VersionStore port(契约 C1)+ **仅测试用**的内存实现。
*
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
* 生产实现是 `gitVersionStore.ts`(一项目一 git 仓库,VersionId = commit hash),
* 见 ADR-0030。本文件留下来只为让不关心版本落盘的测试快速起个 store;它的
* VersionId 是每仓库计数器(`v1`/`v2`),与生产**不同形**,不要据此写断言。
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
*
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复。ADR-0030 之后
* 已无生产调用点 —— 生产走 git,不再有这份进程级 JSON 快照
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -15,12 +17,21 @@ import { FileLibError } from "./model.js";
export type VersionId = string;
/**
* 提交者身份。`userId` 是稳定主键(追溯用),`displayName` 只影响展示。
* git 实现把它们映射成 `name <userId@域名>`(ADR-0030)。
*/
export interface CommitAuthor {
readonly userId: string;
readonly displayName?: string | undefined;
}
export interface CommitRequest {
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
readonly baseVersion: VersionId | null;
readonly content: string | Buffer;
readonly message?: string | undefined;
readonly author?: string | undefined;
readonly author?: CommitAuthor | undefined;
}
export type CommitResult =
@@ -45,7 +56,13 @@ export interface VersionStore {
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>;
/** 删除也是一次提交,所以同样带提交者身份。 */
remove(
projectDir: string,
filePath: string,
baseVersion: VersionId,
author?: CommitAuthor,
): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
}
@@ -80,6 +97,13 @@ function toBuffer(content: string | Buffer): Buffer {
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
}
/** VersionInfo.author 是展示字符串;语义与 git 实现对齐(取 displayName,回退 userId)。 */
function authorLabel(author: CommitAuthor | undefined): string | undefined {
if (author === undefined) return undefined;
const name = author.displayName;
return name === undefined || name.trim() === "" ? author.userId : name.trim();
}
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
function naiveDiff(fromText: string, toText: string): string {
const a = fromText.split("\n");
@@ -221,7 +245,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
version,
content: toBuffer(req.content),
message: req.message ?? `commit ${version}`,
author: req.author,
author: authorLabel(req.author),
committedAt: new Date().toISOString(),
deleted: false,
});
@@ -231,7 +255,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
});
},
async remove(projectDir, filePath, baseVersion) {
async remove(projectDir, filePath, baseVersion, author) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
@@ -247,7 +271,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
version,
content: Buffer.alloc(0),
message: `remove ${filePath}`,
author: undefined,
author: authorLabel(author),
committedAt: new Date().toISOString(),
deleted: true,
});