Files
blog-manager/lib/store.ts
2026-11-01 13:23:44 -05:00

119 lines
4.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
import { HttpError } from "./errors";
import { POSTS_FILE, POSTS_REL } from "./paths";
import type { BlogPost } from "./types";
/* blog-posts.json is the store. Every mutation runs read -> check revision
* -> modify -> atomic write inside one in-process queue, so concurrent
* requests can't interleave and an edit made from a stale copy (e.g. after
* a git pull changed the file) is rejected with a 409 instead of clobbering. */
const KEY_ORDER: (keyof BlogPost)[] = [
"id", "slug", "title", "description", "date", "category", "tags", "images", "details",
];
function revisionOf(raw: string): string {
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
}
/** Stable key order + 2-space indent keeps git diffs small and readable. */
function serialize(posts: BlogPost[]): string {
const ordered = posts.map((post) => {
const out: Record<string, unknown> = {};
for (const key of KEY_ORDER) {
const value = post[key];
if (value === undefined) continue;
if (key === "images" && Array.isArray(value) && value.length === 0) continue;
out[key] = value;
}
// Preserve any fields this tool doesn't know about.
for (const [key, value] of Object.entries(post)) {
if (!(key in out) && !KEY_ORDER.includes(key as keyof BlogPost)) out[key] = value;
}
return out;
});
return JSON.stringify(ordered, null, 2) + "\n";
}
export async function readPosts(): Promise<{ posts: BlogPost[]; revision: string }> {
let raw: string;
try {
raw = await readFile(POSTS_FILE, "utf8");
} catch {
throw new HttpError(500, `cannot read ${POSTS_REL} (is REPO_DIR set to the portfolio checkout?)`);
}
let posts: unknown;
try {
posts = JSON.parse(raw);
} catch {
throw new HttpError(500, `${POSTS_REL} is not valid JSON`);
}
if (!Array.isArray(posts)) throw new HttpError(500, `${POSTS_REL} must contain an array`);
return { posts: posts as BlogPost[], revision: revisionOf(raw) };
}
async function writeAtomic(raw: string): Promise<void> {
const tmp = `${POSTS_FILE}.${process.pid}.tmp`;
await writeFile(tmp, raw, "utf8");
try {
await rename(tmp, POSTS_FILE);
} catch (err) {
// Windows can refuse to replace a file another process has open
// (editors, file watchers); fall back to an in-place write.
const code = (err as NodeJS.ErrnoException).code;
if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw err;
await writeFile(POSTS_FILE, raw, "utf8");
await unlink(tmp).catch(() => undefined);
}
}
let tail: Promise<unknown> = Promise.resolve();
/**
* Runs `fn` against the current posts (mutating the array in place) and
* writes the result. Pass the revision the client last saw to get optimistic
* concurrency; omit it to apply unconditionally.
*/
export function mutatePosts<T>(
expectedRevision: string | undefined,
fn: (posts: BlogPost[]) => T | Promise<T>
): Promise<{ result: T; revision: string }> {
const run = tail.then(async () => {
const { posts, revision } = await readPosts();
if (expectedRevision && expectedRevision !== revision) {
throw new HttpError(409, `${POSTS_REL} changed on disk since it was loaded`);
}
const result = await fn(posts);
const raw = serialize(posts);
await writeAtomic(raw);
return { result, revision: revisionOf(raw) };
});
tail = run.catch(() => undefined);
return run;
}
export function findPost(posts: BlogPost[], slug: string): BlogPost {
const post = posts.find((p) => p.slug === slug);
if (!post) throw new HttpError(404, `no post with slug "${slug}"`);
return post;
}
export type Count = { name: string; count: number };
/** Category slug -> post count, sorted by name. */
export function categoryCounts(posts: BlogPost[]): Count[] {
const counts = new Map<string, number>();
for (const p of posts) counts.set(p.category, (counts.get(p.category) ?? 0) + 1);
return [...counts].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
}
/** Tag -> post count, sorted by name (case-insensitive). */
export function tagCounts(posts: BlogPost[]): Count[] {
const counts = new Map<string, number>();
for (const p of posts) for (const t of p.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
return [...counts]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
}