app/lib/index.server.ts
248 lines and 17 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| summaryRow | 45 to 58 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| writeBody | 60 to 67 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| indexDoc | 70 to 81 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| removeFromIndex | 84 to 89 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| refreshIndex | 98 to 172 | 8 | 100% | WatchCRAP 8 | No data: mutation testing has not run on this repo yet |
| <anonymous:123> | 123 to 123 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:126> | 126 to 126 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:143> | 143 to 143 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:154> | 154 to 157 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| ftsQuery | 178 to 185 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:183> | 183 to 183 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| writingKinds | 197 to 199 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| searchItems | 201 to 203 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| searchItemsPage | 209 to 243 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| kindsIn | 245 to 253 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:252> | 252 to 252 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| sitePaths | 261 to 273 | 9 | 100% | WatchCRAP 9 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/db/schema.ts, app/lib/sites.server.ts. Imported by app/lib/content.server.ts, app/lib/mcp/tools.ts, app/lib/refresh.server.ts, app/lib/waiting.server.ts, app/routes/editor.tsx, app/routes/project.tsx.
Source
// The one view's index of a site's content: what the site reports, cached in D1 with FTS5 over title
// and body, refreshed on every save and by the 15-minute cron. The site stays the authority.
import type { ContentDoc, ContentStatus, ContentSummary } from "@dustinedwards/site-api";
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { siteItems } from "~/db/schema";
import { siteClient, type SiteId } from "~/lib/sites.server";
/**
* Workers Paid allows 10,000 subrequests per invocation by default, and a D1 call counts as one
* (developers.cloudflare.com/workers/platform/limits/#subrequests, checked 2026-09-27; Workers Free
* stays at 50). Carrel is on Workers Paid.
*/
export const SUBREQUEST_LIMIT = 10_000;
const PAGE_SIZE = 200;
const MAX_PAGES = 10;
/**
* Bodies read per refresh. A refresh costs at most MAX_PAGES list calls, two D1 calls (the known
* rows and the summaries' batch), and two per body (its fetch and its batch): 12 + 2 x 200 = 412,
* inside a tenth of SUBREQUEST_LIMIT, so the cron's other work in the same invocation (the health check,
* other sites' refreshes) always has room. The rest are reported as pending and read on the next run.
*/
export const MAX_BODY_FETCHES = 200;
/** The most subrequests one refresh can make, from the counts above. */
export const REFRESH_MAX_SUBREQUESTS = MAX_PAGES + 2 + 2 * MAX_BODY_FETCHES;
export type IndexedItem = {
itemId: string;
kind: string;
title: string;
status: ContentStatus;
path: string | null;
publishAt: string | null;
publishedAt: string | null;
updatedAt: string | null;
};
type ProjectRef = { id: number; site: SiteId };
function summaryRow(projectId: number, item: ContentSummary, syncedAt: string) {
return {
projectId,
itemId: item.id,
kind: item.kind,
title: item.title,
status: item.status,
path: item.path,
publishAt: item.publishAt,
publishedAt: item.publishedAt,
updatedAt: item.updatedAt,
syncedAt,
};
}
function writeBody(db: D1Database, projectId: number, itemId: string, title: string, body: string) {
return [
db.prepare("DELETE FROM site_items_fts WHERE project_id = ? AND item_id = ?").bind(projectId, itemId),
db
.prepare("INSERT INTO site_items_fts (project_id, item_id, title, body) VALUES (?, ?, ?, ?)")
.bind(projectId, itemId, title, body),
];
}
/** Indexes one item from a document already in hand, as after a save: no extra request to the site. */
export async function indexDoc(db: D1Database, projectId: number, doc: ContentDoc, now = new Date()): Promise<void> {
const row = summaryRow(projectId, doc, now.toISOString());
const d = drizzle(db);
await d
.insert(siteItems)
.values({ ...row, bodyUpdatedAt: doc.updatedAt ?? "" })
.onConflictDoUpdate({
target: [siteItems.projectId, siteItems.itemId],
set: { ...row, bodyUpdatedAt: doc.updatedAt ?? "" },
});
await db.batch(writeBody(db, projectId, doc.id, doc.title, doc.source));
}
/** Drops one item from the index after the site deleted it. The index is a cache; the site stays the authority. */
export async function removeFromIndex(db: D1Database, projectId: number, itemId: string): Promise<void> {
await db.batch([
db.prepare("DELETE FROM site_items WHERE project_id = ? AND item_id = ?").bind(projectId, itemId),
db.prepare("DELETE FROM site_items_fts WHERE project_id = ? AND item_id = ?").bind(projectId, itemId),
]);
}
export type RefreshResult = { listed: number; fetched: number; removed: number; pending: number };
/**
* Lists every item, upserts the summaries, drops items the site no longer lists, and fetches the
* body of up to MAX_BODY_FETCHES items whose updatedAt moved since their body was read. The rest are
* reported as pending and caught up on the next run.
*/
export async function refreshIndex(
env: Env,
project: ProjectRef,
fetcher?: typeof fetch,
now = new Date(),
): Promise<RefreshResult> {
const client = siteClient(env, project.site, fetcher);
const items: ContentSummary[] = [];
let cursor: string | undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const result = await client.list({ limit: PAGE_SIZE, cursor });
items.push(...result.items);
if (!result.nextCursor) break;
cursor = result.nextCursor;
}
const d = drizzle(env.DB);
const syncedAt = now.toISOString();
const known = new Map(
(
await d
.select({ itemId: siteItems.itemId, bodyUpdatedAt: siteItems.bodyUpdatedAt })
.from(siteItems)
.where(eq(siteItems.projectId, project.id))
.all()
).map((r) => [r.itemId, r.bodyUpdatedAt]),
);
const listed = new Set(items.map((i) => i.id));
const statements: D1PreparedStatement[] = [];
for (const item of items) {
const row = summaryRow(project.id, item, syncedAt);
statements.push(
env.DB.prepare(
`INSERT INTO site_items (project_id, item_id, kind, title, status, path, publish_at, published_at, updated_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (project_id, item_id) DO UPDATE SET kind = excluded.kind, title = excluded.title,
status = excluded.status, path = excluded.path, publish_at = excluded.publish_at,
published_at = excluded.published_at, updated_at = excluded.updated_at, synced_at = excluded.synced_at`,
).bind(
row.projectId, row.itemId, row.kind, row.title, row.status, row.path,
row.publishAt, row.publishedAt, row.updatedAt, row.syncedAt,
),
);
}
const gone = [...known.keys()].filter((id) => !listed.has(id));
for (const itemId of gone) {
statements.push(
env.DB.prepare("DELETE FROM site_items WHERE project_id = ? AND item_id = ?").bind(project.id, itemId),
env.DB.prepare("DELETE FROM site_items_fts WHERE project_id = ? AND item_id = ?").bind(project.id, itemId),
);
}
if (statements.length > 0) await env.DB.batch(statements);
// body_updated_at is null until a body is read, and "" once read from an item with no updatedAt,
// so a site that omits updatedAt is read once rather than on every run.
const stale = items.filter((i) => {
const readAt = known.get(i.id);
return readAt === undefined || readAt === null || readAt !== (i.updatedAt ?? "");
});
const due = stale.slice(0, MAX_BODY_FETCHES);
for (const item of due) {
const doc = await client.get(item.id);
await env.DB.batch([
...writeBody(env.DB, project.id, doc.id, doc.title, doc.source),
env.DB.prepare("UPDATE site_items SET body_updated_at = ? WHERE project_id = ? AND item_id = ?").bind(
doc.updatedAt ?? "",
project.id,
doc.id,
),
]);
}
return { listed: items.length, fetched: due.length, removed: gone.length, pending: stale.length - due.length };
}
/**
* The search as an FTS5 expression: each word quoted (so punctuation is text, never syntax), all
* required, the last one a prefix so results follow typing.
*/
export function ftsQuery(q: string): string | null {
const words = q.match(/[\p{L}\p{N}]+/gu) ?? [];
if (words.length === 0) return null;
return words
.slice(0, 8)
.map((w, i, all) => `"${w.replaceAll('"', '""')}"${i === all.length - 1 ? "*" : ""}`)
.join(" ");
}
export type Filters = { q?: string; status?: ContentStatus; kind?: string };
/**
* The kinds Carrel presents as writing, read from the site's own metadata rather than named here:
* site-api reports a kind only as a free string. A kind is writing when any of its items has a page
* on the site (a path) or is not yet published. A data kind (the dustinedwards lab registry:
* equipment, reagent, primer, strain) is published with no page at all, so it stays in the index but
* out of every writing list and its count, whatever it is called. A site-api field that says
* "writing" would replace this rule.
*/
function writingKinds(projectId: number): SQL {
return sql`${siteItems.kind} IN (SELECT kind FROM site_items WHERE project_id = ${projectId} AND (path IS NOT NULL OR status != 'published'))`;
}
export async function searchItems(db: D1Database, projectId: number, filters: Filters): Promise<IndexedItem[]> {
return (await searchItemsPage(db, projectId, filters, { offset: 0, limit: 500 })).items;
}
/**
* One page of searchItems, in the same order: `limit` items from `offset`, and whether more follow.
* One row past the page is read to answer that, never a count.
*/
export async function searchItemsPage(
db: D1Database,
projectId: number,
filters: Filters,
page: { offset: number; limit: number },
): Promise<{ items: IndexedItem[]; hasMore: boolean }> {
const conditions: SQL[] = [eq(siteItems.projectId, projectId), writingKinds(projectId)];
if (filters.status) conditions.push(eq(siteItems.status, filters.status));
if (filters.kind) conditions.push(eq(siteItems.kind, filters.kind));
const match = filters.q ? ftsQuery(filters.q) : null;
if (filters.q && !match) return { items: [], hasMore: false };
if (match) {
conditions.push(
sql`${siteItems.itemId} IN (SELECT item_id FROM site_items_fts WHERE site_items_fts MATCH ${match} AND project_id = ${projectId})`,
);
}
const rows = await drizzle(db)
.select({
itemId: siteItems.itemId,
kind: siteItems.kind,
title: siteItems.title,
status: siteItems.status,
path: siteItems.path,
publishAt: siteItems.publishAt,
publishedAt: siteItems.publishedAt,
updatedAt: siteItems.updatedAt,
})
.from(siteItems)
.where(and(...conditions))
.orderBy(desc(siteItems.updatedAt), asc(siteItems.itemId))
.limit(page.limit + 1)
.offset(page.offset)
.all();
return { items: rows.slice(0, page.limit), hasMore: rows.length > page.limit };
}
export async function kindsIn(db: D1Database, projectId: number): Promise<string[]> {
const rows = await drizzle(db)
.selectDistinct({ kind: siteItems.kind })
.from(siteItems)
.where(and(eq(siteItems.projectId, projectId), writingKinds(projectId)))
.orderBy(asc(siteItems.kind))
.all();
return rows.map((r) => r.kind);
}
/**
* Where each item lives on the site, for links written in the editor: its own path when the site
* gives one, otherwise the path its published siblings of the same kind follow (a draft post takes
* the address the site's published posts have, with its own id). An item whose kind shows no such
* pattern has no known address yet, and is left out of the link targets.
*/
export function sitePaths(items: readonly Pick<IndexedItem, "itemId" | "kind" | "path">[]): Map<string, string> {
const prefixes = new Map<string, string>();
for (const i of items) {
if (i.path && !prefixes.has(i.kind) && i.path.endsWith(`/${i.itemId}`)) prefixes.set(i.kind, i.path.slice(0, -i.itemId.length));
}
const paths = new Map<string, string>();
for (const i of items) {
const prefix = prefixes.get(i.kind);
const path = i.path ?? (prefix ? `${prefix}${i.itemId}` : null);
if (path) paths.set(i.itemId, path);
}
return paths;
}