Files
2026-10-02 14:49:22 -05:00

115 lines
3.7 KiB
JavaScript

const fs = require("fs/promises");
const path = require("path");
const crypto = require("crypto");
const MAX_DRAFT_BYTES = 100 * 1024 * 1024;
const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
function createSharedDraftStore(directory) {
const root = path.resolve(directory);
const locks = new Map();
function exclusive(id, work) {
const previous = locks.get(id) || Promise.resolve();
const current = previous.catch(() => {}).then(work);
locks.set(id, current);
current.finally(() => {
if (locks.get(id) === current) locks.delete(id);
}).catch(() => {});
return current;
}
function fileFor(id) {
if (!ID_PATTERN.test(String(id || ""))) {
const error = new Error("Invalid project ID.");
error.status = 400;
throw error;
}
return path.join(root, id + ".json");
}
async function read(id) {
let text;
try {
text = await fs.readFile(fileFor(id), "utf8");
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
const draft = JSON.parse(text);
return { draft, revision: crypto.createHash("sha256").update(text).digest("hex") };
}
async function list() {
await fs.mkdir(root, { recursive: true });
const names = (await fs.readdir(root)).filter((name) => name.endsWith(".json") && ID_PATTERN.test(name.slice(0, -5)));
const drafts = {};
const revisions = {};
for (const name of names) {
const id = name.slice(0, -5);
const item = await read(id);
if (item && item.draft && item.draft.meta && item.draft.meta.id === id) {
drafts[id] = item.draft;
revisions[id] = item.revision;
}
}
return { drafts, revisions };
}
async function save(id, draft, expectedRevision) {
fileFor(id);
if (!draft || typeof draft !== "object" || Array.isArray(draft) || !draft.meta || draft.meta.id !== id) {
const error = new Error("Project ID does not match the saved project.");
error.status = 400;
throw error;
}
if (typeof expectedRevision !== "string") {
const error = new Error("A project revision is required.");
error.status = 400;
throw error;
}
const text = JSON.stringify(draft);
if (Buffer.byteLength(text, "utf8") > MAX_DRAFT_BYTES) {
const error = new Error("This project exceeds the 100 MB save limit.");
error.status = 413;
throw error;
}
return exclusive(id, async () => {
await fs.mkdir(root, { recursive: true });
const existing = await read(id);
if ((existing ? existing.revision : "") !== expectedRevision) {
const error = new Error("This project was changed by someone else. Reload it before saving again.");
error.status = 409;
throw error;
}
const target = fileFor(id);
const temporary = path.join(root, "." + id + "." + crypto.randomUUID() + ".tmp");
try {
await fs.writeFile(temporary, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
await fs.rename(temporary, target);
} finally {
await fs.rm(temporary, { force: true });
}
return crypto.createHash("sha256").update(text).digest("hex");
});
}
async function remove(id, expectedRevision) {
return exclusive(id, async () => {
const existing = await read(id);
if (!existing) return false;
if (existing.revision !== expectedRevision) {
const error = new Error("This project was changed by someone else. Reload it before deleting.");
error.status = 409;
throw error;
}
await fs.unlink(fileFor(id));
return true;
});
}
return { root, list, read, save, remove };
}
module.exports = { createSharedDraftStore, MAX_DRAFT_BYTES };