app/lib/pages/save.server.ts
264 lines and 19 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| constructor | 43 to 46 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| registeredPage | 53 to 67 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:59> | 59 to 59 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| compile | 77 to 85 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| addressBook | 88 to 107 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:98> | 98 to 98 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:99> | 99 to 99 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| idsOf | 100 to 105 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| judge | 110 to 115 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readPage | 118 to 132 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| decidePage | 138 to 151 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| writeRow | 153 to 183 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| deletePageRow | 186 to 196 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| rowIsCurrent | 199 to 204 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| savePage | 207 to 283 | 12 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| rowIsCurrent | 228 to 228 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| commit | 229 to 235 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| write | 262 to 265 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| recordDivergence | 268 to 268 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/db/dictionary.ts, app/db/index.ts, app/db/pages.ts, app/db/phages.ts, app/db/procedures.ts, app/lib/cache-purge.server.ts, app/lib/carrel/errors.server.ts, app/lib/content-pages.mjs, app/lib/content/load-pipeline.server.ts, app/lib/dictionary-entries.mjs, app/lib/editor/converge.mjs, app/lib/editor/github.server.ts, app/lib/editor/publish-policy.mjs, app/lib/editor/write-path.server.ts, app/lib/pages/compile.mjs, app/lib/pages/links.mjs, app/lib/pages/protected.mjs, app/lib/phages/compile.mjs, app/lib/search/records.mjs, app/lib/search/replace.server.ts. Imported by app/lib/carrel/page-handler.server.ts, app/lib/dictionary/save.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts, app/lib/phages/save.server.ts.
Source
// The one page save, called by the site-api adapter for Carrel (app/lib/pages/adapter.server.ts), and the
// read the operator's get_page tool answers. Content is edited through Carrel and nothing else: there is no
// save tool and no admin editor. A save compiles the file the way CI does (compile.mjs), judges its internal
// links (links.mjs), refuses it with every message if it fails, commits it to the repository (unless it is
// byte-identical, commitUnlessUnchanged), then writes its D1 row and search records, so the page changes on
// the next request with no build or deploy. The commit is never reverted when the D1 write fails;
// the error says the commit landed and how to repair the row. A save cannot create a path: a page's
// address is structure and lives in CONTENT_PAGE_PATHS, in code.
import matter from "gray-matter";
import { listBlogPosts } from "~/db";
import { getPublishedEntryByPath } from "~/db/dictionary";
import { listPublishedPageHtml } from "~/db/pages";
import { listPhages } from "~/db/phages";
import { listPublishedProcedures } from "~/db/procedures";
import { ContentInvalid } from "~/lib/carrel/errors.server";
import { purgePages, type PurgeOutcome } from "~/lib/cache-purge.server";
import { loadPipeline } from "~/lib/content/load-pipeline.server";
import {
CONTENT_PAGES_FROM_DATA,
CONTENT_PAGES_OWN_ROUTE,
PAGE_FILE_PATHS,
contentPageSearchUid,
} from "~/lib/content-pages.mjs";
import { convergeWithRetry } from "~/lib/editor/converge.mjs";
import { blobGuard, commitFiles, readFile } from "~/lib/editor/github.server";
import { commitUnlessUnchanged, UNCHANGED_NOTE } from "~/lib/editor/write-path.server";
import { PolicyError, WRITE_CAPABILITIES, type Actor } from "~/lib/editor/publish-policy.mjs";
import type { DictionaryEntry } from "~/lib/dictionary-entries.mjs";
import { PHAGES_PAGE_PATH, type Phage } from "~/lib/phages/compile.mjs";
import { recordsForPages } from "~/lib/search/records.mjs";
import { replaceSearchRecords } from "~/lib/search/replace.server";
import { compilePage, pagePathForSlug, pageSlug, pageSourcePath } from "./compile.mjs";
import { idsIn, pageLinkErrors, type AddressBook } from "./links.mjs";
import { protectedPageReason } from "./protected.mjs";
type PageEnv = Env & { GITHUB_TOKEN?: string };
/** A file the validator refused: 422, with every message, so the caller can fix its own edit. */
export class PageInvalid extends ContentInvalid {
constructor(path: string, errors: string[]) {
super(`The page "${path}" was not saved: it fails ${errors.length} check(s). Nothing was committed.`, errors);
this.name = "PageInvalid";
}
}
/**
* The page a path names. A path outside CONTENT_PAGE_PATHS is refused here, before any read or write: a
* save cannot create a path, and the error says so. /cv is generated from data and has no file.
*/
function registeredPage(path: string) {
const clean = path.trim();
if (!PAGE_FILE_PATHS.includes(clean)) {
throw new PageInvalid(clean, [
`"${clean}" is not a registered page path. A save cannot create a new path: a page's address is structure, ` +
"so a new page's path is added to CONTENT_PAGE_PATHS (app/lib/content-pages.mjs) in code first. " +
`Registered paths: ${PAGE_FILE_PATHS.filter((p) => !CONTENT_PAGES_FROM_DATA.includes(p)).join(", ")}`,
]);
}
if (CONTENT_PAGES_FROM_DATA.includes(clean)) {
throw new PageInvalid(clean, [`${clean} is generated from structured data (CONTENT_PAGES_FROM_DATA); edit the data, not a markdown copy`]);
}
const slug = pageSlug(clean);
return { path: clean, slug, file: pageSourcePath(slug) };
}
/**
* The one compile door: the save and sync_pages both read a file through it (no link check; see judge). A page
* that opens with a dictionary entry carries it in its twin and its search record, so the compile reads the
* published entry from D1 (docs/DICTIONARY.md); a dictionary save passes the entry it is about to write
* instead (null for none), because D1 does not hold it yet. The phage page draws its table and sections from the
* phage rows (docs/PHAGES.md), so the compile reads them from D1; a phage save passes the set it is about to
* write instead, for the same reason.
*/
export async function compile(env: PageEnv, slug: string, raw: string, entry?: DictionaryEntry | null, phages?: Phage[]) {
const { renderBody, findWideDashes } = await loadPipeline();
const path = pagePathForSlug(slug);
const lead = entry !== undefined ? entry : path ? await getPublishedEntryByPath(env, path) : null;
// A page that states a phage fact by token ({{phages.count}}) reads the rows too, whichever page it is.
const needsPhages = path === PHAGES_PAGE_PATH || raw.includes("{{phage");
const rows = phages ?? (needsPhages ? await listPhages(env) : undefined);
return compilePage({ slug, raw, pipeline: { renderBody, findWideDashes }, entry: lead ?? undefined, phages: rows });
}
/** The address book the links are judged against: what D1 holds now, with this page's own HTML in place. */
async function addressBook(env: PageEnv, own: { path: string; html: string }): Promise<AddressBook> {
const [procedures, posts, pageHtml] = await Promise.all([
listPublishedProcedures(env),
listBlogPosts(env, { perPage: 1000 }),
listPublishedPageHtml(env),
]);
const htmlOf = new Map(pageHtml);
htmlOf.set(own.path, own.html);
const ids = new Map<string, ReadonlySet<string>>();
return {
procedurePaths: new Set(procedures.map((p) => p.path)),
posts: new Map(posts.posts.map((p) => [`/writing/${p.slug}`, true])),
idsOf: (path) => {
const html = htmlOf.get(path);
if (html === undefined) return null;
if (!ids.has(path)) ids.set(path, idsIn(html));
return ids.get(path) ?? null;
},
};
}
/** Compile and link check together: every message the file has now, which is what CI and a save both refuse. */
async function judge(env: PageEnv, slug: string, raw: string) {
const compiled = await compile(env, slug, raw);
if (!compiled.ok) return compiled;
const errors = pageLinkErrors(compiled.page, await addressBook(env, compiled.page));
return errors.length > 0 ? { ok: false as const, errors } : compiled;
}
/** The file, and its front matter and body as parsed. */
export async function readPage(env: PageEnv, path: string) {
const { slug, file } = registeredPage(path);
const existing = await readFile(env, file);
if (!existing) return null;
const parsed = matter(existing.content);
const judged = await judge(env, slug, existing.content);
return {
slug,
path,
raw: existing.content,
draft: parsed.data.draft === true,
record: { data: parsed.data, body: parsed.content },
errors: judged.ok ? [] : judged.errors,
};
}
/**
* An operator may edit, unpublish and republish a page, never publish one for the first time: the same
* rule as posts (the first-publish rule, `WRITE_CAPABILITIES` in `publish-policy.mjs`), read from the prior FILE, the only authority.
*/
function decidePage(actor: Actor, incomingDraft: boolean, prior: string | null) {
const may = WRITE_CAPABILITIES[actor.kind];
if (!may.write) {
throw new PolicyError("Refused: this credential is READ ONLY and may not save a page.", "smoke-is-read-only");
}
const everPublished = prior !== null && matter(prior).data.draft !== true;
if (!may.firstPublish && !incomingDraft && !everPublished) {
throw new PolicyError(
"Refused: publishing a page for the first time is reserved to the human admin. Save it with " +
"draft: true, and ask Dustin to publish it.",
"first-publish-requires-admin",
);
}
}
export async function writeRow(env: PageEnv, compiled: Extract<Awaited<ReturnType<typeof compile>>, { ok: true }>) {
const db = env.DB;
const r = compiled.record;
const uid = compiled.searchInput.uid;
// A page with a route of its own (About) has never been in the search index, so it gets no record.
const records = compiled.draft || CONTENT_PAGES_OWN_ROUTE.includes(r.path) ? [] : recordsForPages([compiled.searchInput]);
await db.batch([
db
.prepare(
`INSERT INTO pages (slug, path, title, description, status, record, markdown, source_path,
source_blob_sha, synced_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, unixepoch())
ON CONFLICT(slug) DO UPDATE SET path = excluded.path, title = excluded.title,
description = excluded.description, status = excluded.status, record = excluded.record,
markdown = excluded.markdown, source_path = excluded.source_path,
source_blob_sha = excluded.source_blob_sha, synced_at = excluded.synced_at`,
)
.bind(
r.slug,
r.path,
r.title,
r.description,
compiled.draft ? "draft" : "published",
JSON.stringify(r),
compiled.markdown,
compiled.sourcePath,
compiled.sourceBlobSha,
),
...replaceSearchRecords(db, uid, records),
]);
}
/** The row of a page whose file is gone and its search records: the one removal sync_pages makes. */
export async function deletePageRow(env: PageEnv, row: { slug: string; path: string }) {
// /terms is the dataset license: a sync that finds its file gone repairs nothing here, it refuses (job_044f96426efa).
const keep = protectedPageReason(row.path);
if (keep) throw new PolicyError(`Refused: ${keep}.`, "terms-cannot-be-removed");
await env.DB.batch([
env.DB.prepare(`DELETE FROM pages WHERE slug = ?1`).bind(row.slug),
env.DB.prepare(`DELETE FROM search_docs WHERE doc_uid = ?1`).bind(contentPageSearchUid(row.path)),
env.DB.prepare(`INSERT INTO search_identity (search_identity) VALUES ('rebuild')`),
env.DB.prepare(`INSERT INTO search_prose (search_prose) VALUES ('rebuild')`),
]);
}
/** True when D1's row for the page was compiled from exactly this file (the git blob sha matches). */
async function rowIsCurrent(env: PageEnv, slug: string, blobSha: string) {
const row = await env.DB.prepare("SELECT source_blob_sha FROM pages WHERE slug = ?1")
.bind(slug)
.first<{ source_blob_sha: string | null }>();
return row?.source_blob_sha === blobSha;
}
/** The page save. */
export async function savePage(
env: PageEnv,
options: { path: string; raw: string; expectedHeadSha?: string | null;
/** The blob sha of this file as Carrel loaded it; undefined means no check. */
expectedBlobSha?: string | null; isNew: boolean; actor: Actor },
) {
const { raw, actor } = options;
const { path, slug, file } = registeredPage(options.path);
const existing = await readFile(env, file);
if (options.isNew && existing) throw new PageInvalid(path, [`the page ${path} already has a file (${file}); pass isNew false to edit it`]);
if (!options.isNew && !existing) throw new PageInvalid(path, [`the page ${path} has no file yet; pass isNew to create it`]);
const compiled = await judge(env, slug, raw);
if (!compiled.ok) throw new PageInvalid(path, compiled.errors);
decidePage(actor, compiled.draft, existing?.content ?? null);
const tag = actor.kind === "operator" ? ` [operator:${actor.id}]` : actor.kind === "carrel" ? ` [carrel:${actor.changeId}]` : "";
// After the gate, so a file that fails validation is still refused even when it is unchanged.
const written = await commitUnlessUnchanged(env, {
existing,
raw,
rowIsCurrent: () => rowIsCurrent(env, slug, compiled.sourceBlobSha),
commit: () =>
commitFiles(env, {
expectedHeadSha: options.expectedHeadSha,
expectedBlobs: blobGuard(file, options.expectedBlobSha),
message: `${options.isNew ? "Add" : "Update"} page: ${compiled.record.title}${tag}`,
changes: [{ path: file, content: raw }],
}),
});
const unchanged = written.action !== "commit";
if (written.action === "noop") {
return {
slug,
path,
commitSha: written.commitSha,
sourceBlobSha: compiled.sourceBlobSha,
unchanged: true,
note: UNCHANGED_NOTE,
created: false,
draft: compiled.draft,
purged: null as PurgeOutcome,
};
}
const { commitSha, blobShas } = written;
if (blobShas[file] && blobShas[file] !== compiled.sourceBlobSha) {
throw new Error(
`The page "${path}" WAS committed as ${commitSha}, but the committed bytes (${blobShas[file]}) are not ` +
`the bytes compiled (${compiled.sourceBlobSha}), so its page was not updated. Run the content sync to ` +
`rebuild the row from the repository.`,
);
}
let purged: PurgeOutcome = false;
await convergeWithRetry({
write: async () => {
await writeRow(env, compiled);
purged = await purgePages(`save page ${path}`);
},
// Nothing kept in KV for a page: the thrown error names the commit and the repair (the content sync),
// and the next ship's sync converges the row from the repository anyway.
recordDivergence: async () => undefined,
slug,
commitSha,
});
return {
slug,
path,
commitSha,
sourceBlobSha: compiled.sourceBlobSha,
unchanged,
created: options.isNew,
draft: compiled.draft,
purged,
};
}