initial commit
This commit is contained in:
118
lib/store.ts
Normal file
118
lib/store.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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" }));
|
||||
}
|
||||
Reference in New Issue
Block a user