// Drawings live as JSON files in the session workspace:
/.json
import { promises as fs } from "node:fs";
import path from "node:path";
import { EventEmitter } from "node:events";
import { normalizeElements, mergeOps, diffOps, MAX_ELEMENTS } from "./lib/model.mjs";
const VERSION = 1;
const STATE_FILE = ".state.json";
const MAX_INSTANCES = 50;
const MAX_SLUG = 48;
const SLUG_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// An error with a code for agent actions and an HTTP status for the canvas API.
export class StoreError extends Error {
constructor(code, message, status = 400) {
super(message);
this.code = code;
this.status = status;
}
}
export function slugifyName(name) {
return String(name || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, MAX_SLUG)
.replace(/-+$/g, "");
}
function cleanName(name) {
return typeof name === "string" ? name.replace(/\s+/g, " ").trim().slice(0, 80) : "";
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let tmpCount = 0;
async function atomicWrite(file, data) {
// The count keeps two writes of one file in the same millisecond from sharing a temp file.
const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.${(tmpCount++).toString(36)}.tmp`;
try {
await fs.writeFile(tmp, data, "utf8");
for (let attempt = 0; ; attempt++) {
try {
await fs.rename(tmp, file);
return;
} catch (err) {
// Windows can briefly lock a file that an antivirus or indexer is reading.
if (attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(err.code)) throw err;
await sleep(40 * (attempt + 1));
}
}
} catch (err) {
// A write that fails part way (a full disk, say) leaves part of the temp file, and every
// retry picks a new name, so remove it.
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
// File system errors name the temp file, which changes on every try, so keep just "CODE: description".
// Otherwise each retry would read as a new problem and warn the user again.
function saveProblem(err) {
const message = String(err?.message || err);
return err?.code && err.syscall ? message.split(", ")[0] : message;
}
function serialize(doc) {
const { elements, ...meta } = doc;
const head = JSON.stringify(meta, null, 2).replace(/\n}$/, "");
const body = elements.length
? `[\n${elements.map((e) => " " + JSON.stringify(e)).join(",\n")}\n ]`
: "[]";
return `${head},\n "elements": ${body}\n}\n`;
}
export class DrawingStore extends EventEmitter {
constructor(dir, { log = () => {} } = {}) {
super();
this.dir = dir;
this.exportsDir = path.join(dir, "exports");
this.log = log;
this.docs = new Map();
this.timers = new Map();
this.saving = new Map();
this.saveErrors = new Map();
this.stateTimer = null;
// State writes run one at a time, so an older one cannot finish after a newer one.
this.stateSaving = Promise.resolve();
this.listTimer = null;
this.state = { lastOpened: null, instances: {} };
this.ready = this.#load();
}
async #load() {
await fs.mkdir(this.dir, { recursive: true });
const names = await fs.readdir(this.dir).catch(() => []);
for (const name of names) {
if (!name.endsWith(".json") || name.startsWith(".")) continue;
const id = name.slice(0, -5);
// Ids made before the length fix could run a little past MAX_SLUG, so allow some slack.
if (!SLUG_ID.test(id) || id.length > MAX_SLUG + 16) continue;
try {
const raw = JSON.parse(await fs.readFile(path.join(this.dir, name), "utf8"));
if (Array.isArray(raw?.elements) && raw.elements.length > MAX_ELEMENTS) {
this.log(`Draw: ${name} has ${raw.elements.length} elements, so only the first ${MAX_ELEMENTS} were loaded.`);
}
this.docs.set(id, this.#clean(raw, id));
} catch (err) {
this.log(`Draw: skipping unreadable drawing ${name}: ${err.message}`);
}
}
try {
const raw = JSON.parse(await fs.readFile(path.join(this.dir, STATE_FILE), "utf8"));
if (raw && typeof raw === "object") {
this.state.lastOpened = typeof raw.lastOpened === "string" ? raw.lastOpened : null;
if (raw.instances && typeof raw.instances === "object") this.state.instances = { ...raw.instances };
}
} catch {
// No state yet.
}
}
#clean(raw, id) {
const now = new Date().toISOString();
return {
version: VERSION,
id,
name: cleanName(raw?.name) || id,
rev: Number.isInteger(raw?.rev) && raw.rev >= 0 ? raw.rev : 0,
createdAt: typeof raw?.createdAt === "string" ? raw.createdAt : now,
updatedAt: typeof raw?.updatedAt === "string" ? raw.updatedAt : now,
elements: normalizeElements(raw?.elements),
};
}
filePath(id) {
return path.join(this.dir, `${id}.json`);
}
list() {
return [...this.docs.values()]
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map((d) => ({ id: d.id, name: d.name, updatedAt: d.updatedAt, count: d.elements.length }));
}
get(id) {
return (typeof id === "string" && this.docs.get(id)) || null;
}
// The drawing nameOrId means: the one with exactly that id, or else the one with that name
// (ignoring case), or else the one whose id or name has the same slug ("my plan!" finds "My
// Plan"). An exact id always wins, so it is the way to pick one of several drawings that share
// a name. When a name could mean more than one drawing, this throws instead of guessing.
find(nameOrId) {
const raw = cleanName(nameOrId);
if (!raw) return null;
const byId = this.docs.get(raw);
if (byId) return byId;
const docs = [...this.docs.values()];
const only = (matches) => {
if (matches.length > 1) {
const list = matches.map((d) => `${d.id} ("${d.name}")`).join(", ");
throw new StoreError("ambiguous_name", `More than one drawing matches "${raw}": ${list}. Pass the id of the one you want.`);
}
return matches[0] || null;
};
const lower = raw.toLowerCase();
const byName = only(docs.filter((d) => d.name.toLowerCase() === lower));
if (byName) return byName;
const slug = slugifyName(raw);
return slug ? only(docs.filter((d) => d.id === slug || slugifyName(d.name) === slug)) : null;
}
#nextName() {
const taken = new Set([...this.docs.values()].map((d) => d.name.toLowerCase()));
let n = this.docs.size + 1;
while (taken.has(`drawing ${n}`)) n++;
return `Drawing ${n}`;
}
create(name) {
const finalName = cleanName(name) || this.#nextName();
const root = slugifyName(finalName) || "drawing";
let id = root;
for (let i = 2; this.docs.has(id); i++) {
const suffix = `-${i}`;
id = root.slice(0, MAX_SLUG - suffix.length).replace(/-+$/, "") + suffix;
}
const now = new Date().toISOString();
const doc = { version: VERSION, id, name: finalName, rev: 0, createdAt: now, updatedAt: now, elements: [] };
this.docs.set(id, doc);
this.#scheduleSave(id, 0);
// Soon rather than now, so a copy is listed once its elements are in.
this.#listSoon();
return doc;
}
#require(id) {
const doc = this.get(id);
if (!doc) throw new Error(`No drawing with id "${id}".`);
return doc;
}
// `ops` are the element-level ops that made the change (see diffOps), or null when no
// element changed.
#touch(doc, origin, ops = null) {
doc.rev += 1;
doc.updatedAt = new Date().toISOString();
this.#scheduleSave(doc.id);
this.emit("change", doc, origin, ops);
this.#listSoon();
}
// An edit changes the drawing's count and time in the drawings list, so the list goes out
// again, once for a burst of edits rather than once per edit.
#listSoon() {
if (this.listTimer) return;
this.listTimer = setTimeout(() => {
this.listTimer = null;
this.emit("list");
}, 300);
this.listTimer.unref?.();
}
rename(id, name) {
const doc = this.#require(id);
const finalName = cleanName(name);
if (!finalName) throw new Error("A drawing name cannot be empty.");
if (finalName === doc.name) return doc;
doc.name = finalName;
this.#touch(doc, "rename");
return doc;
}
// A change that would go over MAX_ELEMENTS fails with an error, instead of the extra
// elements quietly disappearing.
mutate(id, fn, origin = "agent") {
const doc = this.#require(id);
const next = normalizeElements(fn(doc.elements), Infinity);
if (next.length > MAX_ELEMENTS) {
const fmt = (n) => n.toLocaleString("en-US");
throw new StoreError(
"too_many_elements",
`A drawing can hold up to ${fmt(MAX_ELEMENTS)} elements, and this change would make ${fmt(next.length)}.`,
);
}
const ops = diffOps(new Map(doc.elements.map((e) => [e.id, e])), next);
doc.elements = next;
this.#touch(doc, origin, ops);
return doc;
}
applyOps(id, ops, origin) {
return this.mutate(id, (elements) => mergeOps(elements, ops), origin);
}
replace(id, elements, origin) {
return this.mutate(id, () => elements, origin);
}
async remove(id) {
const doc = this.#require(id);
clearTimeout(this.timers.get(id));
this.timers.delete(id);
this.docs.delete(id);
await (this.saving.get(id) || Promise.resolve()).catch(() => {});
try {
await fs.rm(this.filePath(id), { force: true });
} catch (err) {
// The file is still there, so keep the drawing rather than have it come back after a restart.
if (!this.docs.has(id)) this.docs.set(id, doc);
this.#scheduleSave(id);
this.emit("list");
throw new StoreError("delete_failed", `Could not delete "${doc.name}": ${err.message}`, 500);
}
this.saving.delete(id);
this.saveErrors.delete(id);
if (this.state.lastOpened === id) this.state.lastOpened = null;
for (const [inst, drawing] of Object.entries(this.state.instances)) {
if (drawing === id) delete this.state.instances[inst];
}
this.#scheduleState();
this.emit("list");
}
drawingForInstance(instanceId) {
return this.get(this.state.instances[instanceId]);
}
bindInstance(instanceId, drawingId) {
const instances = this.state.instances;
delete instances[instanceId];
instances[instanceId] = drawingId;
const keys = Object.keys(instances);
for (const key of keys.slice(0, Math.max(0, keys.length - MAX_INSTANCES))) delete instances[key];
this.state.lastOpened = drawingId;
this.#scheduleState();
}
get lastOpened() {
return this.get(this.state.lastOpened);
}
async writeExport(drawingId, ext, data) {
await fs.mkdir(this.exportsDir, { recursive: true });
const file = path.join(this.exportsDir, `${drawingId}.${ext}`);
await atomicWrite(file, data);
return file;
}
#scheduleSave(id, delay = 200) {
clearTimeout(this.timers.get(id));
this.timers.set(id, setTimeout(() => this.#save(id), delay));
}
#save(id) {
this.timers.delete(id);
const run = (this.saving.get(id) || Promise.resolve())
.catch(() => {})
.then(async () => {
const doc = this.docs.get(id);
if (doc) await atomicWrite(this.filePath(id), serialize(doc));
})
.then(
() => this.#saved(id, null),
(err) => {
this.#saved(id, err);
throw err;
},
);
run.catch(() => {});
this.saving.set(id, run);
return run;
}
// Tracks failed writes: panels get a "save" event when a drawing starts or stops failing, and
// the write is retried until the disk takes it, since the edit only exists in memory until then.
#saved(id, err) {
const failure = this.saveErrors.get(id);
if (!err) {
if (failure) {
this.saveErrors.delete(id);
this.log(`Draw: saved ${id} again.`);
this.emit("save", id, null);
}
return;
}
const attempts = (failure ? failure.attempts : 0) + 1;
const message = saveProblem(err);
this.saveErrors.set(id, { message, attempts });
if (!failure || failure.message !== message) {
this.log(`Draw: failed to save ${id}: ${err.message}`);
this.emit("save", id, message);
}
if (this.docs.has(id) && !this.timers.has(id)) {
const timer = setTimeout(() => this.#save(id), Math.min(30000, 1000 * 2 ** (attempts - 1)));
timer.unref?.();
this.timers.set(id, timer);
}
}
// Why the latest write of a drawing failed, or null when it is safely on disk.
saveError(id) {
return this.saveErrors.get(id)?.message || null;
}
// Writes a drawing now instead of after the usual short wait, and waits for the write.
// Resolves with the reason it failed, or null once the drawing is on disk.
async persist(id) {
if (this.timers.has(id)) {
clearTimeout(this.timers.get(id));
this.#save(id);
}
await this.saving.get(id)?.catch(() => {});
return this.saveError(id);
}
#scheduleState() {
clearTimeout(this.stateTimer);
this.stateTimer = setTimeout(() => this.#saveState(), 300);
}
#saveState() {
this.stateTimer = null;
// Each write takes the state as it is when its turn comes, so the last one has every change.
this.stateSaving = this.stateSaving
.then(() => atomicWrite(path.join(this.dir, STATE_FILE), JSON.stringify(this.state, null, 2) + "\n"))
.catch((err) => this.log(`Draw: failed to save state: ${err.message}`));
return this.stateSaving;
}
async flush() {
const pending = [...this.timers.keys()];
for (const id of pending) {
clearTimeout(this.timers.get(id));
this.#save(id);
}
const results = await Promise.allSettled([...this.saving.values()]);
if (this.stateTimer) {
clearTimeout(this.stateTimer);
this.#saveState();
}
// Also waits for a state write that had already started.
await this.stateSaving;
const failed = results.filter((r) => r.status === "rejected");
if (failed.length) {
const what = failed.length === 1 ? "a drawing" : `${failed.length} drawings`;
throw new StoreError("save_failed", `Could not save ${what}: ${failed[0].reason.message}`, 500);
}
}
}