Files
com.gregkilmer.www/services/content/src/service.ts
T
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00

348 lines
14 KiB
TypeScript

/**
* The content service core. Every change, from the vault or from the write
* API, goes through `propose`, which runs the rules and either accepts the
* change into the store or holds it for review.
*
* The HTTP API, the MCP server, the admin page and any later editing tool all
* call these methods; none of them write notes any other way.
*/
import { basename } from "node:path";
import { checkNote, checkVault, formatNote, notePath, outgoingLinks, slugify, typeDef, type CheckedNote } from "@gk/schema";
import { defaultRules, evaluate, isLimited, type RuleSettings } from "./rules.ts";
import type { Author, HeldChange, HeldReason, StoredNote, Store } from "./store.ts";
import { hashText, isNotePath, type Vault } from "./vault.ts";
export type Outcome =
| { status: "accepted"; path: string; note: StoredNote }
| { status: "held"; path: string; held: HeldChange }
| { status: "rejected"; path: string | null; reasons: HeldReason[] }
| { status: "unchanged"; path: string };
export type Change =
| { op: "create"; data: Record<string, unknown>; body?: string }
| { op: "update"; path: string; fields?: Record<string, unknown>; body?: string };
export interface ServiceOptions {
store: Store;
vault: Vault;
rules?: Partial<RuleSettings>;
/** Injectable clock, for tests. */
now?: () => Date;
/** Called whenever a change is held, for notifications. */
onHold?: (held: HeldChange) => void;
}
/** How long after a note disappears a new note can still claim its old address. */
const MOVE_WINDOW_MS = 10 * 60_000;
export class ContentService {
readonly store: Store;
readonly vault: Vault;
readonly rules: RuleSettings;
private readonly now: () => Date;
private readonly onHold?: (held: HeldChange) => void;
constructor(o: ServiceOptions) {
this.store = o.store;
this.vault = o.vault;
this.rules = { ...defaultRules, ...o.rules };
this.now = o.now ?? (() => new Date());
this.onHold = o.onHold;
}
/** What the admin page shows as the service's own health. */
readonly health: {
startedAt: string;
lastScan: { at: string; changed: number; held: number } | null;
lastError: { at: string; message: string } | null;
} = { startedAt: new Date().toISOString(), lastScan: null, lastError: null };
noteError(err: unknown) {
this.health.lastError = { at: this.iso(), message: err instanceof Error ? err.message : String(err) };
}
private iso = () => this.now().toISOString();
private today = () => this.iso().slice(0, 10);
// ------------------------------------------------------------ from the vault
/** Read one file from the vault and take it in if it changed. */
ingest(path: string): Outcome {
const text = this.vault.read(path);
if (text === null) return this.removed(path);
const hash = hashText(text);
if (this.store.seenHash(path) === hash) return { status: "unchanged", path };
this.store.markSeen(path, hash);
const current = this.store.get(path);
if (current && !current.removed && current.hash === hash) return { status: "unchanged", path };
return this.propose(path, text, "greg", "vault");
}
/** The file is gone. The note leaves the site; its history stays. */
removed(path: string): Outcome {
this.store.markSeen(path, null);
this.store.supersede(path, this.iso());
const note = this.store.remove(path, "greg", this.iso());
return note ? { status: "accepted", path, note } : { status: "unchanged", path };
}
/**
* Compare the whole folder with what was seen before. Removals go first, so
* a note moved between folders frees its name before it is read again.
*/
scan(): { changed: number; held: number } {
const present = new Set(this.vault.listNotes());
let changed = 0;
let held = 0;
for (const path of this.store.seenPaths()) {
if (!present.has(path)) {
this.removed(path);
changed++;
}
}
for (const path of present) {
const o = this.ingest(path);
if (o.status !== "unchanged") changed++;
if (o.status === "held") held++;
}
this.health.lastScan = { at: this.iso(), changed, held };
return { changed, held };
}
// ------------------------------------------------------------ the write API
/** One change from the API, made as `author`. Accepted changes are written into the vault. */
apply(change: Change, author: Author): Outcome {
let data: Record<string, unknown>;
let body: string;
let path: string;
if (change.op === "create") {
data = { ...change.data, publish: change.data.publish ?? false, created: change.data.created ?? this.today() };
body = change.body ?? "";
if (!typeDef(String(data.type))) return this.reject(null, "invalid", `Unknown type "${String(data.type)}"`);
if (typeof data.title !== "string" || !data.title.trim()) return this.reject(null, "invalid", "title: A title is required");
path = notePath(data);
if (this.vault.exists(path) || (this.store.get(path) && !this.store.get(path)!.removed)) {
return this.reject(path, "exists", `A note already exists at ${path}; update it instead`);
}
} else {
path = change.path;
if (!isNotePath(path) || path.startsWith("/") || path.split("/").includes("..")) {
return this.reject(null, "invalid", "path: Not a note path inside the Site folder");
}
// Take in any edit made in Obsidian first, so it is never overwritten.
this.ingest(path);
const current = this.store.get(path);
if (!current || current.removed) return this.reject(path, "missing", `No note at ${path}`);
if (this.store.pendingFor(path)) {
return this.reject(path, "waiting", "This note has a change waiting for review; decide on that first");
}
data = { ...current.data };
for (const [k, v] of Object.entries(change.fields ?? {})) {
if (k === "type") return this.reject(path, "invalid", "type: A note's type cannot change");
if (v === null) delete data[k];
else data[k] = v;
}
body = change.body ?? current.body;
}
data.updated = this.today();
data.updatedBy = author;
const text = formatNote(data, body);
return this.propose(path, text, author, "api");
}
// ------------------------------------------------------------ review
acceptHeld(id: number): Outcome {
const h = this.store.heldById(id);
if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id");
const checked = checkNote(h.text);
if (!checked.ok) {
return this.reject(h.path, "invalid", "This change is not a valid note, so it cannot go live. Fix the note in Obsidian.");
}
const clash = this.clashes(h.path, checked);
if (clash.length) return { status: "rejected", path: h.path, reasons: clash };
if (h.source === "api") {
const onDisk = this.vault.read(h.path);
const current = this.store.get(h.path);
if (onDisk !== null && hashText(onDisk) !== (current && !current.removed ? current.hash : null)) {
return this.reject(h.path, "conflict", "The note changed in Obsidian since this was proposed");
}
this.writeToVault(h.path, h.text);
}
const note = this.acceptText(h.path, h.text, checked, h.author, "accept");
this.store.decide(id, "accepted", this.iso());
return { status: "accepted", path: h.path, note };
}
/**
* Turn a held change down. With `restore`, a held edit from Obsidian is
* replaced in the vault by the version the site is showing.
*/
rejectHeld(id: number, opts: { restore?: boolean } = {}): Outcome {
const h = this.store.heldById(id);
if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id");
this.store.decide(id, "rejected", this.iso());
const current = this.store.get(h.path);
if (opts.restore && h.source === "vault" && current && !current.removed) this.writeToVault(h.path, current.text);
return current ? { status: "accepted", path: h.path, note: current } : { status: "unchanged", path: h.path };
}
/** Put an earlier accepted version back, in the vault and on the site. */
rollback(path: string, rev: number): Outcome {
const r = this.store.revision(path, rev);
if (!r) return this.reject(path, "missing", `No revision ${rev} for ${path}`);
const checked = checkNote(r.text);
if (!checked.ok) return this.reject(path, "invalid", "That version no longer passes the note rules");
const clash = this.clashes(path, checked);
if (clash.length) return { status: "rejected", path, reasons: clash };
this.store.supersede(path, this.iso());
this.writeToVault(path, r.text);
const note = this.acceptText(path, r.text, checked, "admin", "rollback");
return { status: "accepted", path, note };
}
// ------------------------------------------------------------ reading
/** Problems across the whole folder: links to notes that do not exist, and the like. */
problems() {
const notes = this.store.list().map((n) => ({ path: n.path, type: n.type, data: n.data, body: n.body }));
return checkVault(notes, this.vault.listAttachments());
}
// ------------------------------------------------------------ internals
private reject(path: string | null, code: string, message: string): Outcome {
return { status: "rejected", path, reasons: [{ code, message }] };
}
private slugOf(checked: CheckedNote): string {
if (!checked.ok) return "";
return String(checked.data.slug ?? slugify(String(checked.data.title)));
}
/** Name and slug clashes with other live notes. */
private clashes(path: string, checked: CheckedNote): HeldReason[] {
const reasons: HeldReason[] = [];
if (!checked.ok) return reasons;
const other = this.liveOther(this.store.byName(basename(path, ".md")), path);
if (other) reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${other}` });
const def = typeDef(checked.type);
if (!def?.singleton) {
const s = this.liveOther(this.store.bySlug(checked.type, this.slugOf(checked)), path);
if (s) reasons.push({ code: "duplicate-slug", message: `Another ${checked.type} already uses this address: ${s}` });
}
return reasons;
}
/** Another live note's path, after making sure that note's file still exists. */
private liveOther(note: StoredNote | null, path: string): string | null {
if (!note || note.path === path) return null;
if (!this.vault.exists(note.path)) {
// Its file is gone (moved or deleted) and the watcher has not caught up.
this.removed(note.path);
return null;
}
return note.path;
}
private propose(path: string, text: string, author: Author, source: "vault" | "api"): Outcome {
const checked = checkNote(text);
const name = basename(path, ".md");
const slug = this.slugOf(checked);
const window = new Date(this.now().getTime() - this.rules.agentBatchWindowMinutes * 60_000).toISOString();
const current = this.store.get(path);
const nameTakenBy = checked.ok ? this.liveOther(this.store.byName(name), path) : null;
const slugTakenBy =
checked.ok && !typeDef(checked.type)?.singleton ? this.liveOther(this.store.bySlug(checked.type, slug), path) : null;
const verdict = evaluate(
{ path, name, slug, text, author, checked },
{
current,
nameTakenBy,
slugTakenBy,
recentAgentChanges: isLimited(author) ? this.store.changesSince(author, window) : 0,
settings: this.rules,
},
);
if (verdict.decision === "accept" && checked.ok) {
if (source === "api") this.writeToVault(path, text);
const note = this.acceptText(path, text, checked, author, "accept");
return { status: "accepted", path, note };
}
if (verdict.decision === "hold" && source === "api" && verdict.reasons.some((r) => r.code === "invalid")) {
// An API caller gets the problems back at once instead of a broken note in the queue.
return { status: "rejected", path, reasons: verdict.reasons };
}
const reasons = verdict.decision === "hold" ? verdict.reasons : [];
const held = this.store.hold({
path,
author,
source,
text,
baseHash: current && !current.removed ? current.hash : null,
reasons,
at: this.iso(),
});
this.onHold?.(held);
return { status: "held", path, held };
}
private writeToVault(path: string, text: string) {
// Mark it seen first, so the watcher recognises the service's own write.
this.store.markSeen(path, hashText(text));
this.vault.write(path, text);
}
private acceptText(path: string, text: string, checked: CheckedNote & { ok: true }, author: Author, action: "accept" | "rollback"): StoredNote {
const at = this.iso();
const data = checked.data;
const slug = this.slugOf(checked);
const hash = hashText(text);
const vaultNote = { path, type: checked.type, data, body: checked.body };
return this.store.transaction(() => {
const prev = this.store.get(path);
// The old address keeps working after a rename or a slug change.
if (prev && !prev.removed && prev.type === checked.type && prev.slug !== slug) {
this.store.addRedirect(prev.type, prev.slug, path, at);
}
if (!prev || prev.removed) {
const since = new Date(this.now().getTime() - MOVE_WINDOW_MS).toISOString();
const moved = this.store
.recentlyRemoved(since)
.find((n) => n.path !== path && n.type === checked.type && (n.hash === hash || n.title === data.title));
if (moved) {
this.store.retarget(moved.path, path);
if (moved.slug !== slug) this.store.addRedirect(moved.type, moved.slug, path, at);
}
}
// A slug in use again belongs to its note, not to a redirect.
this.store.dropRedirect(checked.type, slug);
this.store.supersede(path, at);
return this.store.accept(
{
path,
type: checked.type,
name: basename(path, ".md"),
slug,
title: String(data.title),
publish: data.publish === true,
data,
body: checked.body,
text,
hash,
author,
acceptedAt: at,
},
action,
outgoingLinks(vaultNote),
);
});
}
}