app/lib/carrel/site-adapter.server.ts
294 lines and 20 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| statusOf | 57 to 61 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| effectivePublishAt | 64 to 67 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| previewRow | 74 to 108 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| previewPost | 111 to 113 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| carrelSiteAdapter | 115 to 325 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| write | 125 to 145 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| existing | 148 to 152 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| list | 157 to 179 | 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:160> | 160 to 175 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:177> | 177 to 177 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:178> | 178 to 178 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| get | 181 to 203 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| saveDraft | 207 to 218 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publish | 220 to 227 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| schedule | 229 to 242 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| unpublish | 244 to 247 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| revisions | 249 to 251 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| revisionSource | 253 to 255 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| delete | 260 to 273 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| preview | 277 to 302 | 5 | 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/index.ts, app/db/schema.ts, app/lib/carrel/content-kinds.server.ts, app/lib/carrel/cv-handler.server.ts, app/lib/carrel/dictionary-handler.server.ts, app/lib/carrel/document-handler.server.ts, app/lib/carrel/errors.server.ts, app/lib/carrel/front-matter-lines.ts, app/lib/carrel/media-adapter.server.ts, app/lib/carrel/page-handler.server.ts, app/lib/carrel/phage-handler.server.ts, app/lib/carrel/procedure-handler.server.ts, app/lib/carrel/publication-handler.server.ts, app/lib/carrel/registry-handler.server.ts, app/lib/carrel/roster-handler.server.ts, app/lib/content/slug.mjs, app/lib/context.ts, app/lib/editor/frontmatter.ts, app/lib/editor/github.server.ts, app/lib/editor/post-rows.server.ts, app/lib/editor/publish-policy.mjs, app/lib/editor/publish.server.ts, app/lib/seo.ts. Imported by app/lib/carrel/site-api.server.ts.
Source
// The dustinedwards.info side of Carrel's site API (the site-api package): the package owns the
// route, the key, the rate limit and every body's shape; this adapter maps its content and preview
// calls onto the site's own save, render and history code. Nothing here writes around savePost.
// Media (v0.2.0) is its own module, over the site's media code.
import { NotFoundError, RefusedError, VersionConflictError, type SiteAdapter } from "@dustinedwards/site-api";
import type { ContentStatus, ContentSummary, WriteResult } from "@dustinedwards/site-api";
import { getTableColumns } from "drizzle-orm";
import { RouterContextProvider } from "react-router";
import { listAllPostsForAdmin, previewBlogPost, type PostRow } from "~/db";
import { posts } from "~/db/schema";
import { carrelMediaAdapter } from "~/lib/carrel/media-adapter.server";
import { postPath } from "~/lib/content/slug.mjs";
import { cloudflareContext, nonceContext, postPreviewContext } from "~/lib/context";
import { contentRegistry, type ContentKindHandler } from "~/lib/carrel/content-kinds.server";
import { publicationHandler } from "~/lib/carrel/publication-handler.server";
import { asSiteApiError } from "~/lib/carrel/errors.server";
import { withPublication } from "~/lib/carrel/front-matter-lines";
import { documentHandler } from "~/lib/carrel/document-handler.server";
import { dictionaryHandler } from "~/lib/carrel/dictionary-handler.server";
import { pageHandler } from "~/lib/carrel/page-handler.server";
import { cvHandler } from "~/lib/carrel/cv-handler.server";
import { procedureHandler } from "~/lib/carrel/procedure-handler.server";
import { phageHandler } from "~/lib/carrel/phage-handler.server";
import { registryHandlers } from "~/lib/carrel/registry-handler.server";
import { rosterHandler } from "~/lib/carrel/roster-handler.server";
import { fileRevisions, fileRevisionSource, readFile } from "~/lib/editor/github.server";
import { parsePost } from "~/lib/editor/frontmatter";
import {
deletePost,
renderRecord,
savePost,
} from "~/lib/editor/publish.server";
import { postColumnValues, type PostRecord } from "~/lib/editor/post-rows.server";
import { readState } from "~/lib/editor/publish-policy.mjs";
import { SITE, SITE_ORIGIN } from "~/lib/seo";
export { withPublication };
type CarrelEnv = Env & { GITHUB_TOKEN?: string };
type RenderDocument = (request: Request, context: RouterContextProvider) => Promise<Response>;
/**
* A revision's version is a commit sha. A post's own version is different: the git blob sha of its file at
* head, so only a change to that post moves it, and saving another post no longer makes this one look changed.
*/
/** A preview of a post not yet in D1 needs an id no stored row has, for its neighbours' tie-break. */
const PREVIEW_ROW_ID = Number.MAX_SAFE_INTEGER;
/** The slug a preview renders at when Carrel sends no id: a new post that has none yet. */
const UNSAVED_PREVIEW_SLUG = "carrel-preview";
/** Draft, scheduled or published, from the file itself, which is the only authority on status. */
function statusOf(raw: string, now = Date.now()): ContentStatus {
if (readState(raw).draft) return "draft";
const at = parsePost(raw).publishAt.trim();
return at && Date.parse(at) > now ? "scheduled" : "published";
}
/** The effective publication time, as the pipeline computes it: publish_at, else the date. */
function effectivePublishAt(raw: string): number {
const fields = parsePost(raw);
return Date.parse(fields.publishAt.trim() || `${fields.date}T00:00:00.000Z`);
}
/**
* The drizzle row getBlogPost would read once this record were synced: the synced columns decoded
* through the schema's own column mappers, and the columns the sync does not write kept from the
* stored row. A column neither supplies is an error, so a new column cannot quietly go missing here.
*/
async function previewRow(env: CarrelEnv, record: PostRecord): Promise<PostRow> {
const stored = await env.DB.prepare(
`SELECT id, excerpt, og_image, created_at, updated_at FROM posts WHERE slug = ?1`,
)
.bind(record.slug)
.first<{
id: number;
excerpt: string | null;
og_image: string | null;
created_at: number;
updated_at: number;
}>();
const now = Math.floor(Date.now() / 1000);
const raw: Record<string, unknown> = {
id: stored?.id ?? PREVIEW_ROW_ID,
kind: "post",
excerpt: stored?.excerpt ?? null,
og_image: stored?.og_image ?? null,
created_at: stored?.created_at ?? now,
...postColumnValues(record),
};
// postColumnValues' `updated_at` is NULL when the post has no frontmatter `updated`: the stored one stands.
raw.updated_at ??= stored?.updated_at ?? now;
const row: Record<string, unknown> = {};
for (const [key, column] of Object.entries(getTableColumns(posts))) {
if (!(column.name in raw)) {
throw new Error(`the Carrel preview has no value for posts.${column.name}; add it to postColumnValues`);
}
const value = raw[column.name];
row[key] = value === null || value === undefined ? null : column.mapFromDriverValue(value as never);
}
return row as PostRow;
}
/** What the blog loader reads for a preview: getBlogPost's shape, for a record not yet in D1. */
export async function previewPost(env: CarrelEnv, record: PostRecord) {
return previewBlogPost(env, await previewRow(env, record), record.tags);
}
export function carrelSiteAdapter(options: {
env: CarrelEnv;
ctx: ExecutionContext;
/** The origin the preview's page request is made against: the request's own. */
origin: string;
renderDocument: RenderDocument | null;
}): SiteAdapter {
const { env, ctx, origin, renderDocument } = options;
/** One write through savePost as the Carrel actor, answered in the package's terms. */
async function write(
id: string,
changeId: string,
raw: string,
expectedVersion: string | null,
): Promise<WriteResult> {
try {
const saved = await savePost(env, {
slug: id,
raw,
// A create (null) is guarded by isNew; every other write by the post's own blob sha.
expectedBlobSha: expectedVersion ?? undefined,
isNew: expectedVersion === null,
actor: { kind: "carrel", changeId },
});
// The record was rendered from the stamped file that was committed, so this is that file's blob sha.
return { id, version: saved.record.sourceBlobSha, status: statusOf(raw), changeId };
} catch (error) {
return asSiteApiError(env, error, postPath(id));
}
}
/** The stored file for a write that names a version; a version for a missing id is a conflict. */
async function existing(id: string): Promise<string> {
const file = await readFile(env, postPath(id));
if (!file) throw new VersionConflictError(null);
return file.content;
}
const postHandler: ContentKindHandler = {
kind: "post",
async list(query) {
const now = Date.now();
const needle = query.q?.toLowerCase();
const summaries: ContentSummary[] = (await listAllPostsForAdmin(env)).map((row) => {
const at = row.publishAt ? row.publishAt.getTime() : null;
const status: ContentStatus =
row.status === "draft" ? "draft" : at !== null && at > now ? "scheduled" : "published";
return {
id: row.slug,
kind: "post",
title: row.title,
status,
// D1 cannot tell a withdrawn post from one never published, so a draft has no public path.
path: status === "draft" ? null : `/writing/${row.slug}`,
publishAt: status === "scheduled" && at !== null ? new Date(at).toISOString() : null,
publishedAt: status === "published" && at !== null ? new Date(at).toISOString() : null,
updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null,
};
});
return summaries
.filter((s) => !query.status || s.status === query.status)
.filter((s) => !needle || s.title.toLowerCase().includes(needle) || s.id.includes(needle));
},
async get(id) {
const file = await readFile(env, postPath(id));
if (!file) return null;
const updated = await env.DB.prepare(`SELECT updated_at FROM posts WHERE slug = ?1`)
.bind(id)
.first<{ updated_at: number }>();
const fields = parsePost(file.content);
const { firstPublished } = readState(file.content);
const status = statusOf(file.content);
return {
id,
kind: "post",
title: fields.title || id,
status,
path: firstPublished ? `/writing/${id}` : null,
publishAt: status === "scheduled" ? new Date(Date.parse(fields.publishAt)).toISOString() : null,
publishedAt: firstPublished ? `${firstPublished}T00:00:00.000Z` : null,
updatedAt: updated ? new Date(updated.updated_at * 1000).toISOString() : null,
format: "markdown",
source: file.content,
version: file.sha,
};
},
// Keeps the stored status, as the contract's reference adapter does: a save to a live post
// edits it live, and a new post starts as a draft.
async saveDraft(id, input) {
const file = await readFile(env, postPath(id));
if (input.expectedVersion === null && file) throw new VersionConflictError(file.sha);
if (input.expectedVersion !== null && !file) throw new VersionConflictError(null);
const raw = file
? withPublication(input.source, {
draft: readState(file.content).draft,
publishAt: parsePost(file.content).publishAt.trim() || null,
})
: withPublication(input.source, { draft: true });
return write(id, input.changeId, raw, input.expectedVersion);
},
async publish(id, input) {
// Read even when source is sent: a version for an id that does not exist is a conflict.
const stored = await existing(id);
const source = input.source ?? stored;
// A post whose time is still ahead would publish invisible, so publishing now means now.
const due = effectivePublishAt(source) > Date.now() ? new Date().toISOString() : undefined;
return write(id, input.changeId, withPublication(source, { draft: false, publishAt: due }), input.expectedVersion);
},
async schedule(id, input) {
const at = Date.parse(input.publishAt);
if (!(at > Date.now())) {
throw new RefusedError("publishAt is not in the future; publish it now instead.");
}
const stored = await existing(id);
const source = input.source ?? stored;
return write(
id,
input.changeId,
withPublication(source, { draft: false, publishAt: new Date(at).toISOString() }),
input.expectedVersion,
);
},
async unpublish(id, input) {
const source = await existing(id);
return write(id, input.changeId, withPublication(source, { draft: true }), input.expectedVersion);
},
revisions(id) {
return fileRevisions(env, postPath(id));
},
revisionSource(id, version) {
return fileRevisionSource(env, postPath(id), version);
},
// The post's own delete, the one the admin uses: the file's commit, the D1 rows, the cache purge and the
// Ask index as one unit. The version is checked here for a clean answer and again inside the commit, at
// the head it is built on. A check that cannot run (GitHub unreadable) throws, so nothing is deleted.
async delete(id, input) {
const file = await readFile(env, postPath(id));
if (!file) throw new NotFoundError(`No post "${id}" exists.`);
if (file.sha !== input.expectedVersion) throw new VersionConflictError(file.sha);
try {
await deletePost(env, {
slug: id,
expectedBlobSha: input.expectedVersion,
actor: { kind: "carrel", changeId: input.changeId },
});
} catch (error) {
return asSiteApiError(env, error, postPath(id));
}
},
// The page the save would publish: the same record renderAndWrite builds, rendered through the
// blog route and the root layout in-process, so nothing is written and nothing is cached.
async preview(previewSlug, input) {
if (!renderDocument) throw new Error("the document handler is not on this request's context");
const slug = previewSlug ?? UNSAVED_PREVIEW_SLUG;
let record: PostRecord;
try {
record = await renderRecord(env, slug, input.source);
} catch (error) {
return asSiteApiError(env, error, postPath(slug));
}
const post = await previewPost(env, record);
// Every context the Renderer sets for a public path: the nonce is undefined there, as a
// public page carries none (workers/csp.mjs).
const context = new RouterContextProvider();
context.set(cloudflareContext, { env, ctx });
context.set(nonceContext, undefined);
context.set(postPreviewContext, post);
const response = await renderDocument(
new Request(`${origin}/writing/${slug}`, { headers: { accept: "text/html" } }),
context,
);
if (!response.ok) {
throw new RefusedError(`The page did not render: the blog route answered ${response.status}.`);
}
return response.text();
},
};
const registry = contentRegistry([
postHandler,
publicationHandler(env),
pageHandler(env),
documentHandler(env),
cvHandler(env, ctx),
dictionaryHandler(env),
rosterHandler(env),
phageHandler(env),
procedureHandler(env),
// One per registry kind (app/kb/registry/kinds.mjs); none until a kind is defined.
...registryHandlers(env),
]);
return {
site: { id: "dustinedwards-info", name: SITE.name, origin: SITE_ORIGIN },
content: registry.adapter,
preview: { render: registry.render },
media: carrelMediaAdapter(env),
};
}