96 lines
2.8 KiB
JavaScript
96 lines
2.8 KiB
JavaScript
|
|
// Atomic, locked access to calendar.json so concurrent runs (write-daily, publish-tick,
|
||
|
|
// research refill) can't clobber each other. Every mutation re-reads under an exclusive
|
||
|
|
// lockfile, mutates, and writes — no long-held in-memory copies.
|
||
|
|
import { existsSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
|
||
|
|
|
||
|
|
const LOCK_STALE_MS = 10 * 60 * 1000; // a lock older than this is presumed orphaned
|
||
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
|
|
||
|
|
async function acquire(calPath, timeoutMs = 120000) {
|
||
|
|
const lp = `${calPath}.lock`;
|
||
|
|
const start = Date.now();
|
||
|
|
for (;;) {
|
||
|
|
try {
|
||
|
|
const fd = openSync(lp, "wx"); // exclusive create — fails if it exists
|
||
|
|
writeFileSync(lp, `${process.pid} ${new Date().toISOString()}`);
|
||
|
|
closeSync(fd);
|
||
|
|
return lp;
|
||
|
|
} catch {
|
||
|
|
try {
|
||
|
|
if (Date.now() - statSync(lp).mtimeMs > LOCK_STALE_MS) {
|
||
|
|
unlinkSync(lp);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
/* lock vanished — retry */
|
||
|
|
}
|
||
|
|
if (Date.now() - start > timeoutMs) throw new Error(`calendar lock timeout: ${lp}`);
|
||
|
|
await sleep(200);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const release = (lp) => {
|
||
|
|
try {
|
||
|
|
unlinkSync(lp);
|
||
|
|
} catch {
|
||
|
|
/* already gone */
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
export async function withCalendarLock(calPath, fn) {
|
||
|
|
const lp = await acquire(calPath);
|
||
|
|
try {
|
||
|
|
return await fn();
|
||
|
|
} finally {
|
||
|
|
release(lp);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const readCalendar = (calPath) =>
|
||
|
|
existsSync(calPath) ? JSON.parse(readFileSync(calPath, "utf8")) : [];
|
||
|
|
|
||
|
|
const writeCalendar = (calPath, cal) => writeFileSync(calPath, JSON.stringify(cal, null, 2));
|
||
|
|
|
||
|
|
/** Locked read-modify-write of one entry (Object.assign patch). Returns the updated entry. */
|
||
|
|
export async function updateEntry(calPath, slug, patch) {
|
||
|
|
return withCalendarLock(calPath, () => {
|
||
|
|
const cal = readCalendar(calPath);
|
||
|
|
const e = cal.find((x) => x.slug === slug);
|
||
|
|
if (e) {
|
||
|
|
Object.assign(e, patch);
|
||
|
|
writeCalendar(calPath, cal);
|
||
|
|
}
|
||
|
|
return e;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Locked append if the slug is not already present. */
|
||
|
|
export async function addEntry(calPath, entry) {
|
||
|
|
return withCalendarLock(calPath, () => {
|
||
|
|
const cal = readCalendar(calPath);
|
||
|
|
if (!cal.some((x) => x.slug === entry.slug)) {
|
||
|
|
cal.push(entry);
|
||
|
|
writeCalendar(calPath, cal);
|
||
|
|
}
|
||
|
|
return entry;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Locked merge: append each new entry whose slug isn't present. Returns count added. */
|
||
|
|
export async function mergeEntries(calPath, entries) {
|
||
|
|
return withCalendarLock(calPath, () => {
|
||
|
|
const cal = readCalendar(calPath);
|
||
|
|
const have = new Set(cal.map((x) => x.slug));
|
||
|
|
let added = 0;
|
||
|
|
for (const e of entries) {
|
||
|
|
if (!have.has(e.slug)) {
|
||
|
|
cal.push(e);
|
||
|
|
have.add(e.slug);
|
||
|
|
added++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
writeCalendar(calPath, cal);
|
||
|
|
return added;
|
||
|
|
});
|
||
|
|
}
|