src/adapter.ts
215 lines and 9 functions in DrDustinEdwards/site-api, at commit d3b340f on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| capabilitiesOf | 156 to 174 | 11 | 100% | WatchCRAP 11 | Ok100% |
| contentVersion | 191 to 194 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:193> | 193 to 193 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| constructor | 198 to 201 | 1 | 100% | OkCRAP 1 | Critical0% |
| constructor | 205 to 208 | 1 | 100% | OkCRAP 1 | Critical33.3% |
| constructor | 213 to 216 | 2 | 100% | OkCRAP 2 | Watch66.7% |
| describeUses | 220 to 222 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:221> | 221 to 221 | 2 | 100% | OkCRAP 2 | No data: changed since the last mutation run |
| constructor | 226 to 229 | 1 | 100% | OkCRAP 1 | Critical50% |
Imports
It imports src/contract.ts. Imported by src/index.ts, src/local.ts, src/server.ts, src/server/content.ts, src/server/http.ts, src/server/media.ts, src/server/mentions.ts, src/testing.ts.
Source
// What each site implements. The package owns the route, the key, the rate limit and the shape of
// every body; the adapter owns the site's storage, its rules and its render.
import type {
Capabilities,
ContentDoc,
ContentList,
ListQuery,
MediaDetail,
MediaItem,
MediaList,
MediaLens,
MediaListQuery,
MediaUploadLimits,
MediaUse,
MentionList,
MentionListQuery,
MentionStatus,
PreviewInput,
PublishInput,
Revision,
SaveDraftInput,
ScheduleInput,
SiteInfo,
UnpublishInput,
WriteResult,
} from "./contract.js";
export interface ContentAdapter {
list(query: ListQuery): Promise<ContentList>;
/** null when the id does not exist. */
get(id: string): Promise<ContentDoc | null>;
/**
* Creates when expectedVersion is null and the id is free. Throws VersionConflictError when
* expectedVersion is not what the site holds, including a non-null version for a missing id.
*/
saveDraft(id: string, input: SaveDraftInput): Promise<WriteResult>;
/** A first publish from Carrel's key is allowed (design decision 2): Carrel decides who triggers it. */
publish(id: string, input: PublishInput): Promise<WriteResult>;
schedule(id: string, input: ScheduleInput): Promise<WriteResult>;
/** Unpublish returns the item to draft. A delete is the separate, optional `delete` below. */
unpublish(id: string, input: UnpublishInput): Promise<WriteResult>;
/** Newest first. null when the id does not exist. */
revisions(id: string): Promise<Revision[] | null>;
/** The source as it was at a version; null when the id or the version does not exist. */
revisionSource(id: string, version: string): Promise<string | null>;
/**
* Optional (v0.3.0): deletes the item. Throws VersionConflictError when expectedVersion is not
* what the site holds, NotFoundError for a missing id, and RefusedError when the site's own rules
* refuse. A site without it answers `DELETE /content/:id` with 501. The write and whatever the
* site must do afterward (its cache purge, say) are one unit, so the site runs them together here.
*/
delete?(id: string, input: { expectedVersion: string; changeId: string }): Promise<void>;
/**
* Optional (v0.6.0): sets the item's whole tag set, already unique, and returns the write like a
* save does. Throws VersionConflictError, NotFoundError (checked first, for a missing id) or
* RefusedError like the writes. A site without it answers `PUT /content/:id/tags` with 501.
*/
setTags?(id: string, input: { tags: string[]; expectedVersion: string; changeId: string }): Promise<WriteResult>;
}
export interface PreviewAdapter {
/** The full page HTML, from the site's own route and pipeline, exactly as it would publish. */
render(input: PreviewInput): Promise<string>;
}
/** One upload, already checked by the package against the site's own limits and the file's bytes. */
export interface MediaUpload {
bytes: Uint8Array;
/** One of the site's declared types, without parameters. */
contentType: string;
filename: string;
alt: string;
changeId: string;
}
/**
* The media group (v0.2.0). Files stay in the site's own storage and are served by the site; Carrel
* only lists, uploads and asks for deletes. The site's reference check decides every delete.
*/
export interface MediaAdapter {
/** What the site accepts. The package refuses any other type, and anything larger, unread. */
limits: MediaUploadLimits;
/**
* Optional (v0.6.0): the lenses `list` answers. A site that names any also sorts by `sort` and `dir`
* and says so in the answer's `sorted`. The package answers a lens not named here with 501.
*/
lenses?: readonly MediaLens[];
list(query: MediaListQuery): Promise<MediaList>;
/** The file and every place it is used. null when the id does not exist. */
get(id: string): Promise<MediaDetail | null>;
upload(input: MediaUpload): Promise<MediaItem>;
/**
* Deletes the file. Throws MediaInUseError naming each use when the site's reference check finds
* one, NotFoundError for a missing id, and RefusedError when the check could not run: a delete the
* site could not check is never made.
*/
delete(id: string, input: { changeId: string }): Promise<void>;
/**
* Optional (v0.4.0), each answering 501 when absent. Every one throws VersionConflictError when
* expectedVersion is not what the site holds, NotFoundError for a missing id and RefusedError when
* the site's own rules refuse, and returns the file's version after the write. A file's version
* identifies its editable metadata, so each write here moves it. The write and whatever must
* follow it (a cache purge) are one unit, run together in the method.
*/
setAlt?(id: string, input: { alt: string; expectedVersion: string; changeId: string }): Promise<{ version: string }>;
/** `tags` is the whole set after the write, already in the contract's spelling. */
setTags?(id: string, input: { tags: string[]; expectedVersion: string; changeId: string }): Promise<{ version: string }>;
/** Soft delete: hides the file from list and removes nothing. Both trash and restore, or neither. */
trash?(id: string, input: { expectedVersion: string; changeId: string }): Promise<{ version: string }>;
restore?(id: string, input: { expectedVersion: string; changeId: string }): Promise<{ version: string }>;
}
/**
* The mentions group (v0.5.0), optional per site. Every write throws VersionConflictError when
* expectedVersion is not what the site holds, NotFoundError for a missing id and RefusedError when
* the site's own rules refuse. The write and the cache purge it needs are one unit, run together in
* the method, and the method reports whether the purge worked.
*/
export interface MentionsAdapter {
/** Newest first. `counts` and `expiring` cover the whole queue, not the page. */
list(query: MentionListQuery): Promise<MentionList>;
/** Approve or reject. Refuse (RefusedError) a mention that is not yet verified or that failed verification. */
decide(
id: string,
input: { decision: "approve" | "reject"; expectedVersion: string; changeId: string },
): Promise<{ status: MentionStatus; version: string; purged: boolean | null }>;
/**
* Optional (v0.6.0): takes an approved or rejected mention back to pending, as it was before the
* decision, so a decision can be undone. Refuse (RefusedError) any other status. Absent: a reset answers 501.
*/
reset?(
id: string,
input: { expectedVersion: string; changeId: string },
): Promise<{ status: MentionStatus; version: string; purged: boolean | null }>;
/** Removes the only copy of what a stranger sent. */
delete(id: string, input: { expectedVersion: string; changeId: string }): Promise<{ purged: boolean | null }>;
/** Removes failed and rejected mentions past the site's retention windows, and reports how many. */
sweep(input: { changeId: string }): Promise<{ failed: number; rejected: number }>;
}
export interface SiteAdapter {
site: SiteInfo;
content: ContentAdapter;
preview: PreviewAdapter;
/** Absent on a site with no media manager: the media routes then answer 501, as in v0.1.0. */
media?: MediaAdapter;
/** Absent on a site that does not receive webmentions: the mentions routes then answer 501. */
mentions?: MentionsAdapter;
}
/**
* Stage 2 implemented content and preview; v0.2.0 adds media for a site whose adapter has it. The
* other groups answer 501 until their stage.
*/
export function capabilitiesOf(adapter: SiteAdapter): Capabilities {
return {
content: true,
preview: true,
media: Boolean(adapter.media),
inbox: false,
insight: false,
publications: false,
...(adapter.media ? { mediaUpload: adapter.media.limits } : {}),
...(adapter.content.delete ? { contentDelete: true } : {}),
...(adapter.content.setTags ? { contentTags: true } : {}),
...(adapter.media?.setAlt ? { mediaAlt: true } : {}),
...(adapter.media?.lenses?.length ? { mediaLenses: [...adapter.media.lenses] } : {}),
...(adapter.media?.setTags ? { mediaTags: true } : {}),
...(adapter.media?.trash && adapter.media.restore ? { mediaTrash: true } : {}),
...(adapter.mentions ? { mentions: true } : {}),
...(adapter.mentions?.reset ? { mentionReset: true } : {}),
};
}
/** @deprecated Since v0.2.0 capabilities depend on the adapter: use capabilitiesOf(adapter). */
export const CAPABILITIES: Capabilities = {
content: true,
preview: true,
media: false,
inbox: false,
insight: false,
publications: false,
};
/**
* A content version made from the fields that identify an item's own content: the first 32 hex
* characters of the SHA-256 of their JSON. For a site with no version column; pass the same fields,
* in the same order, on every read and write.
*/
export async function contentVersion(parts: readonly unknown[]): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
}
/** The expectedVersion on a write is not what the site holds. currentVersion null: no such id. */
export class VersionConflictError extends Error {
constructor(readonly currentVersion: string | null, message = "The content changed since it was loaded.") {
super(message);
this.name = "VersionConflictError";
}
}
export class NotFoundError extends Error {
constructor(message = "No such content.") {
super(message);
this.name = "NotFoundError";
}
}
/** The site's reference check found the file in use: the delete is refused, naming every use. */
export class MediaInUseError extends Error {
constructor(readonly usedBy: MediaUse[], message?: string) {
super(message ?? `The file is in use: ${describeUses(usedBy)}. Remove it from each first.`);
this.name = "MediaInUseError";
}
}
/** "Title (detail); Other (detail)", one entry per use, for the refusal a writer reads. */
export function describeUses(uses: MediaUse[]): string {
return uses.map((u) => `${u.title || u.id} (${u.detail})`).join("; ");
}
/** The site's own rules refused the write, such as a render failure or a policy. Shown to the writer. */
export class RefusedError extends Error {
constructor(message: string) {
super(message);
this.name = "RefusedError";
}
}