proteinea / src /server /storage.ts
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
// File-based persistence adapter for Tasks, Projects, and Artifacts (JSON on disk, per-task locks).
import { promises as fs } from "fs";
import path from "path";
import crypto from "crypto";
import type { TaskRecord, ProjectRecord, Artifact, ChatMessage } from "@/lib/types";
import { inferTaskTitle } from "@/lib/task-title";
const DATA_DIR = path.join(process.cwd(), ".data");
const TASKS_DIR = path.join(DATA_DIR, "tasks");
const ARTIFACTS_DIR = path.join(DATA_DIR, "artifacts");
const PROJECTS_FILE = path.join(DATA_DIR, "projects.json");
// ---- tiny per-key sequential lock ----
const locks = new Map<string, Promise<unknown>>();
function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
locks.set(
key,
next.catch(() => undefined).finally(() => {
if (locks.get(key) === next) locks.delete(key);
}),
);
return next;
}
// ---- id helpers ----
function randomHex(len: number): string {
return crypto.randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
}
function projectId(): string { return `proj_${randomHex(10)}`; }
function taskId(): string { return `sess_${randomHex(12)}`; }
export function artifactId(): string { return `art_${randomHex(12)}`; }
// ---- fs helpers ----
async function ensureDirs(): Promise<void> {
await fs.mkdir(TASKS_DIR, { recursive: true });
await fs.mkdir(ARTIFACTS_DIR, { recursive: true });
}
async function readJson<T>(file: string): Promise<T | null> {
try {
const buf = await fs.readFile(file, "utf8");
return JSON.parse(buf) as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.${randomHex(6)}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
await fs.rename(tmp, file);
}
function taskFile(id: string): string {
return path.join(TASKS_DIR, `${id}.json`);
}
// ---- projects ----
async function readProjects(): Promise<ProjectRecord[]> {
await ensureDirs();
const existing = await readJson<ProjectRecord[]>(PROJECTS_FILE);
return existing ?? [];
}
async function writeProjects(projects: ProjectRecord[]): Promise<void> {
await writeJsonAtomic(PROJECTS_FILE, projects);
}
export async function ensureDefaultProject(): Promise<ProjectRecord> {
return withLock("projects", async () => {
const projects = await readProjects();
if (projects.length > 0) return projects[0];
const proj: ProjectRecord = {
id: projectId(),
name: "Default",
createdAt: Date.now(),
};
await writeProjects([proj]);
return proj;
});
}
export async function listProjects(): Promise<ProjectRecord[]> {
const projects = await readProjects();
if (projects.length === 0) {
const def = await ensureDefaultProject();
return [def];
}
return projects;
}
async function getProject(id: string): Promise<ProjectRecord | null> {
const projects = await readProjects();
return projects.find((p) => p.id === id) ?? null;
}
// ---- tasks ----
export async function createTask(
projectIdArg: string,
firstMessage: string,
): Promise<TaskRecord> {
const project = await getProject(projectIdArg);
if (!project) throw new Error(`Project not found: ${projectIdArg}`);
const id = taskId();
const now = Date.now();
const title = inferTaskTitle(firstMessage);
const firstMsg: ChatMessage = {
id: `msg_${randomHex(10)}`,
role: "user",
content: firstMessage,
};
const task: TaskRecord = {
id,
projectId: projectIdArg,
title,
createdAt: now,
updatedAt: now,
messages: [firstMsg],
artifacts: [],
};
return withLock(`task:${id}`, async () => {
await writeJsonAtomic(taskFile(id), task);
return task;
});
}
export async function getTask(id: string): Promise<TaskRecord | null> {
return readJson<TaskRecord>(taskFile(id));
}
export async function appendMessage(
id: string,
message: ChatMessage,
): Promise<TaskRecord> {
return withLock(`task:${id}`, async () => {
const task = await readJson<TaskRecord>(taskFile(id));
if (!task) throw new Error(`Task not found: ${id}`);
task.messages.push(message);
task.updatedAt = Date.now();
await writeJsonAtomic(taskFile(id), task);
return task;
});
}
export async function updateTask(
id: string,
patch: Partial<TaskRecord>,
): Promise<TaskRecord> {
return withLock(`task:${id}`, async () => {
const task = await readJson<TaskRecord>(taskFile(id));
if (!task) throw new Error(`Task not found: ${id}`);
const next: TaskRecord = {
...task,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.messages !== undefined ? { messages: patch.messages } : {}),
...(patch.artifacts !== undefined ? { artifacts: patch.artifacts } : {}),
id: task.id,
projectId: task.projectId,
createdAt: task.createdAt,
updatedAt: Date.now(),
};
await writeJsonAtomic(taskFile(id), next);
return next;
});
}
export async function listTasks(projectIdArg: string): Promise<TaskRecord[]> {
await ensureDirs();
const entries = await fs.readdir(TASKS_DIR).catch(() => [] as string[]);
const tasks: TaskRecord[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const task = await readJson<TaskRecord>(path.join(TASKS_DIR, entry));
if (task && task.projectId === projectIdArg) tasks.push(task);
}
tasks.sort((a, b) => b.updatedAt - a.updatedAt);
return tasks;
}
// ---- artifacts ----
interface ArtifactMeta {
artifact: Artifact;
taskId: string;
filename: string;
}
function artifactDir(id: string): string {
return path.join(ARTIFACTS_DIR, id);
}
export async function saveArtifact(
taskIdArg: string,
artifact: Artifact,
body: Buffer | string,
): Promise<void> {
const task = await getTask(taskIdArg);
if (!task) throw new Error(`Task not found: ${taskIdArg}`);
const dir = artifactDir(artifact.id);
await fs.mkdir(dir, { recursive: true });
const filename = artifact.name || "artifact.bin";
const safeName = path.basename(filename);
const blobPath = path.join(dir, safeName);
const data = typeof body === "string" ? Buffer.from(body, "utf8") : body;
await fs.writeFile(blobPath, data);
const meta: ArtifactMeta = { artifact, taskId: taskIdArg, filename: safeName };
await writeJsonAtomic(path.join(dir, "meta.json"), meta);
await withLock(`task:${taskIdArg}`, async () => {
const t = await readJson<TaskRecord>(taskFile(taskIdArg));
if (!t) return;
const idx = t.artifacts.findIndex((a) => a.id === artifact.id);
if (idx >= 0) t.artifacts[idx] = artifact;
else t.artifacts.push(artifact);
t.updatedAt = Date.now();
await writeJsonAtomic(taskFile(taskIdArg), t);
});
}
export async function readArtifact(
id: string,
): Promise<{ artifact: Artifact; body: Buffer } | null> {
const dir = artifactDir(id);
const meta = await readJson<ArtifactMeta>(path.join(dir, "meta.json"));
if (!meta) return null;
const blobPath = path.join(dir, meta.filename);
try {
const body = await fs.readFile(blobPath);
return { artifact: meta.artifact, body };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}