Skip to the content

Capsid menu

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

The functions of index.server.ts
FunctionLinesComplexityCoveredCRAPMutation
summaryRow45 to 581100%OkCRAP 1No data: mutation testing has not run on this repo yet
writeBody60 to 671100%OkCRAP 1No data: mutation testing has not run on this repo yet
indexDoc70 to 813100%OkCRAP 3No data: mutation testing has not run on this repo yet
removeFromIndex84 to 891100%OkCRAP 1No data: mutation testing has not run on this repo yet
refreshIndex98 to 1728100%WatchCRAP 8No data: mutation testing has not run on this repo yet
<anonymous:123>123 to 1231100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:126>126 to 1261100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:143>143 to 1431100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:154>154 to 1574100%OkCRAP 4No data: mutation testing has not run on this repo yet
ftsQuery178 to 1853100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:183>183 to 1832100%OkCRAP 2No data: mutation testing has not run on this repo yet
writingKinds197 to 1991100%OkCRAP 1No data: mutation testing has not run on this repo yet
searchItems201 to 2031100%OkCRAP 1No data: mutation testing has not run on this repo yet
searchItemsPage209 to 2437100%WatchCRAP 7No data: mutation testing has not run on this repo yet
kindsIn245 to 2531100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:252>252 to 2521100%OkCRAP 1No data: mutation testing has not run on this repo yet
sitePaths261 to 2739100%WatchCRAP 9No 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;
}