app/lib/carrel/roster-handler.server.ts
104 lines and 14 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| titleOf | 23 to 23 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| rosterHandler | 25 to 119 | 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 | 27 to 41 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| owns | 44 to 44 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| list | 49 to 65 | 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:51> | 51 to 61 | 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:63> | 63 to 63 | 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:64> | 64 to 64 | 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 | 67 to 85 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| saveDraft | 88 to 94 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publish | 96 to 102 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| unpublish | 104 to 109 | 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 | 111 to 113 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| revisionSource | 115 to 117 | 2 | 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/roster.ts, app/lib/carrel/content-kinds.server.ts, app/lib/carrel/errors.server.ts, app/lib/editor/github.server.ts, app/lib/roster/compile.mjs, app/lib/roster/save.server.ts. Imported by app/lib/carrel/site-adapter.server.ts.
Source
// The roster kind of Carrel's /content group (docs/ROSTER.md): ids are `roster.<year>` (the registry encodes
// them), the year being the cohort's file key (content/roster/2025.md). Every write is saveRoster, the one
// path a cohort reaches the repository by, so a cohort saved through Carrel is validated by the code CI
// runs, committed, written to D1 and live on the next request with no deploy. There is no editor in the
// site's admin for this kind and no operator save tool. A cohort has no draft state: it is public when it
// is saved, so saving and publishing are the same write and unpublishing is refused.
import { RefusedError, VersionConflictError } from "@dustinedwards/site-api";
import type { ContentSummary, WriteResult } from "@dustinedwards/site-api";
import { listRosterRows } from "~/db/roster";
import type { ContentKindHandler } from "~/lib/carrel/content-kinds.server";
import { asSiteApiError } from "~/lib/carrel/errors.server";
import { fileRevisions, fileRevisionSource, readFile } from "~/lib/editor/github.server";
import { ROSTER_PAGE_PATH, rosterPath } from "~/lib/roster/compile.mjs";
import { COHORT_SLUG, saveRoster } from "~/lib/roster/save.server";
type RosterEnv = Env & { GITHUB_TOKEN?: string };
/** A revision's version is a commit sha; an item's own version is the blob sha of its file. */
/** The title Carrel shows: the cohort's year, which is all a cohort is named. */
const titleOf = (slug: string) => `${slug} cohort`;
export function rosterHandler(env: RosterEnv): ContentKindHandler {
/** One write through saveRoster as the Carrel actor, answered in the package's terms. */
async function write(slug: string, changeId: string, raw: string, expectedVersion: string | null): Promise<WriteResult> {
try {
const saved = await saveRoster(env, {
slug,
raw,
expectedBlobSha: expectedVersion ?? undefined,
isNew: expectedVersion === null,
actor: { kind: "carrel", changeId },
});
return { id: slug, version: saved.sourceBlobSha, status: "published", changeId };
} catch (error) {
// A RosterInvalid carries every message the validator gave; asSiteApiError passes them on.
return asSiteApiError(env, error, rosterPath(slug));
}
}
/** A slug that is not a year owns no file, so a read of it is a missing id and never a path to read. */
const owns = (slug: string) => COHORT_SLUG.test(slug);
return {
kind: "roster",
async list(query) {
const needle = query.q?.toLowerCase();
const summaries: ContentSummary[] = (await listRosterRows(env)).map((row) => ({
id: row.slug,
kind: "roster",
title: titleOf(row.slug),
status: "published",
// A cohort has no page of its own: it is listed on the program page, under its heading.
path: `${ROSTER_PAGE_PATH}#year-${row.year}`,
publishAt: null,
publishedAt: null,
updatedAt: row.syncedAt.toISOString(),
}));
return summaries
.filter((s) => !query.status || s.status === query.status)
.filter((s) => !needle || s.title.toLowerCase().includes(needle) || s.id.includes(needle));
},
async get(slug) {
if (!owns(slug)) return null;
const file = await readFile(env, rosterPath(slug));
if (!file) return null;
const row = await env.DB.prepare(`SELECT synced_at FROM roster WHERE slug = ?1`).bind(slug).first<{ synced_at: number }>();
return {
id: slug,
kind: "roster",
title: titleOf(slug),
status: "published",
path: `${ROSTER_PAGE_PATH}#year-${slug}`,
publishAt: null,
publishedAt: null,
updatedAt: row ? new Date(row.synced_at * 1000).toISOString() : null,
format: "markdown",
source: file.content,
version: file.sha,
};
},
// Creates a cohort (no version) or edits one (its version), as every kind does; either way it is public.
async saveDraft(slug, input) {
if (!owns(slug)) throw new RefusedError(`"${slug}" is not a cohort file key; a cohort is named for its year, such as 2025.`);
const file = await readFile(env, rosterPath(slug));
if (input.expectedVersion === null && file) throw new VersionConflictError(file.sha);
if (input.expectedVersion !== null && !file) throw new VersionConflictError(null);
return write(slug, input.changeId, input.source, input.expectedVersion);
},
async publish(slug, input) {
if (!owns(slug)) throw new RefusedError(`"${slug}" is not a cohort file key; a cohort is named for its year, such as 2025.`);
// Read even when source is sent: a version for an id that does not exist is a conflict.
const file = await readFile(env, rosterPath(slug));
if (!file) throw new VersionConflictError(null);
return write(slug, input.changeId, input.source ?? file.content, input.expectedVersion);
},
async unpublish() {
throw new RefusedError(
"A roster cohort has no draft state, so it cannot be unpublished. To remove a cohort, delete its file " +
"from content/roster/ in a commit; the next content sync removes its row.",
);
},
revisions(slug) {
return fileRevisions(env, owns(slug) ? rosterPath(slug) : null);
},
revisionSource(slug, version) {
return fileRevisionSource(env, owns(slug) ? rosterPath(slug) : null, version);
},
};
}