forked from bai/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user