Skip to the content

Capsid menu

app/db/schema.ts

702 lines and 30 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of schema.ts
FunctionLinesComplexityCoveredCRAPMutation
<anonymous:66>66 to 761No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:90>90 to 901No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:93>93 to 931No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:95>95 to 981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:156>156 to 1661No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:184>184 to 1901No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:224>224 to 2331No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:271>271 to 2801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:301>301 to 3091No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:339>339 to 3431No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:366>366 to 3701No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:399>399 to 4081No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:420>420 to 4201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:445>445 to 4481No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:467>467 to 4721No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:495>495 to 4981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:532>532 to 5321No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:553>553 to 5571No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:566>566 to 5661No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:580>580 to 5801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:591>591 to 5961No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:614>614 to 6181No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:637>637 to 6371No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:642>642 to 6461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:655>655 to 6551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:658>658 to 6581No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:663>663 to 6681No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:690>690 to 6931No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:712>712 to 7201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:733>733 to 7331No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports no other module of this repo. Imported by app/db/client.ts, app/db/cv.ts, app/db/dictionary.ts, app/db/media.ts, app/db/members.ts, app/db/pages.ts, app/db/phages.ts, app/db/posts.ts, app/db/procedure-versions.ts, app/db/procedures.ts, app/db/publications.ts, app/db/registry.ts, app/db/roster.ts, app/db/settings.ts, app/db/webmentions.ts, app/lib/carrel/media-adapter.server.ts, app/lib/carrel/site-adapter.server.ts, app/lib/citations.server.ts, app/lib/media/actions.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts, app/lib/search/zero-result.server.ts, app/routes/admin.mentions.tsx.

Source

import { sql } from "drizzle-orm";
import {
  type AnySQLiteColumn,
  check,
  index,
  integer,
  primaryKey,
  real,
  sqliteTable,
  text,
  uniqueIndex,
} from "drizzle-orm/sqlite-core";

/** Pages and posts, split by `kind`. FTS5 mirrors (title, body) through triggers in drizzle/0001_init.sql. */
export const posts = sqliteTable(
  "posts",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    slug: text("slug").notNull().unique(),
    kind: text("kind", { enum: ["page", "post"] }).notNull(),
    title: text("title").notNull(),
    body: text("body").notNull(),
    excerpt: text("excerpt"),
    status: text("status", { enum: ["draft", "published"] }).notNull(),
    publishAt: integer("publish_at", { mode: "timestamp" }),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    html: text("html"),
    description: text("description"),
    coverImage: text("cover_image"),
    coverAlt: text("cover_alt"),
    readingTimeMinutes: integer("reading_time_minutes"),
    sourcePath: text("source_path"),
    /** JSON array of { depth, id, text }, written by the content generator. */
    toc: text("toc"),
    featured: integer("featured", { mode: "boolean" }).notNull().default(false),
    series: text("series"),
    part: integer("part"),
    /** JSON array of { title, url }. */
    furtherReading: text("further_reading"),
    ogTitle: text("og_title"),
    ogDescription: text("og_description"),
    /** JSON array of { slug, title, shared }, computed over the whole corpus. */
    related: text("related"),
    /** JSON array of { slug, title }: posts on THIS site linking here. Not webmentions, which are other sites. */
    backlinks: text("backlinks"),
    /**
     * Not `status`, which is the draft/published visibility column: a published post can still carry
     * `writing_status: draft`, meaning the prose is a draft.
     */
    writingStatus: text("writing_status"),
    assumedAudience: text("assumed_audience"),
    /** JSON array of strings. */
    keyTakeaways: text("key_takeaways"),
    /** JSON array of { date, note }, authored. Not `updatedAt`, which every sync rewrites. */
    changelog: text("changelog"),
    ogImage: text("og_image"),
    /** Git blob sha1 of the source markdown (`gitBlobSha()`), what the content-drift check compares. */
    sourceBlobSha: text("source_blob_sha"),
    renderHash: text("render_hash"),
  },
  (t) => [
    check("posts_kind_check", sql`${t.kind} in ('page', 'post')`),
    check("posts_status_check", sql`${t.status} in ('draft', 'published')`),
    /** Covers `publiclyVisible()`, which every public read composes. */
    index("posts_status_publish_idx").on(t.status, t.publishAt),
    uniqueIndex("posts_source_path_idx").on(t.sourcePath),
    /** Covers the home page's featured selection. */
    index("posts_featured_idx").on(t.featured, t.publishAt),
    /** Covers a series' parts, in order. */
    index("posts_series_idx").on(t.series, t.part),
  ],
);

export const tags = sqliteTable("tags", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  slug: text("slug").notNull().unique(),
  name: text("name").notNull(),
});

export const postTags = sqliteTable(
  "post_tags",
  {
    postId: integer("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    tagId: integer("tag_id")
      .notNull()
      .references(() => tags.id, { onDelete: "cascade" }),
  },
  (t) => [
    primaryKey({ columns: [t.postId, t.tagId] }),
    index("post_tags_tag_idx").on(t.tagId),
  ],
);

export const settings = sqliteTable("settings", {
  key: text("key").primaryKey(),
  value: text("value").notNull(),
});

/**
 * Derived: R2 and `public/` win any conflict. A row with no object is deleted, an object with no row
 * is backfilled, never the reverse. Citations live in `mediaRefs`, never here.
 */
export const media = sqliteTable(
  "media",
  {
    /** R2 object key, or the site-absolute public path when storage='static'. */
    key: text("key").primaryKey(),
    /** 'r2' | 'r2-derived' | 'static'. */
    storage: text("storage").notNull().default("r2"),
    /** 'image' | 'document' | 'other'. */
    kind: text("kind").notNull().default("image"),
    /**
     * 'content' | 'brand' | 'generated' | 'icon'. The picker takes `content` only, because inserting
     * one half of a generated diagram pair produces a broken post.
     */
    role: text("role").notNull().default("content"),
    mime: text("mime"),
    bytes: integer("bytes"),
    /** NULL means "not measured", which an SVG and a PDF legitimately are. */
    width: integer("width"),
    height: integer("height"),
    /** Keys are content-addressed, so the readable upload name survives only here. */
    originalName: text("original_name"),

    /* Authored, and recoverable from nothing: a rebuild merges these rather than truncating. */
    alt: text("alt").notNull().default(""),
    caption: text("caption").notNull().default(""),
    focalX: real("focal_x"),
    focalY: real("focal_y"),

    /**
     * Delimiter-wrapped, `,alpha,beta,` or "", so an exact LIKE match on `art` does not hit `chart`.
     * Only `serialiseTags()` writes it; it strips the delimiter and both LIKE wildcards.
     */
    tags: text("tags").notNull().default(""),

    /**
     * NULL means not trashed. Library state only: R2 and the public URL are untouched, so a trashed
     * asset a post cites keeps rendering. Reconciliation ignores it on purpose.
     */
    trashedAt: text("trashed_at"),

    /** LQIP as a base64 data URI, so it renders with no script. */
    placeholder: text("placeholder"),
    uploadedAt: text("uploaded_at"),
    createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
    updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
  },
  (t) => [
    index("media_uploaded_idx").on(t.uploadedAt),
    index("media_storage_idx").on(t.storage),
    index("media_kind_idx").on(t.kind),
    index("media_role_idx").on(t.role),
    /*
     * Partial in the migration (`WHERE trashed_at IS NOT NULL`). Drizzle cannot model the predicate
     * and the schema test does not compare predicates, so it lives only in the SQL.
     */
    index("media_trashed_idx").on(t.trashedAt),
  ],
);

/**
 * Who cites what, written by the pipeline at render time. A record of what the renderer emitted,
 * never a cache to consult when deciding whether an object exists.
 */
export const mediaRefs = sqliteTable(
  "media_refs",
  {
    mediaKey: text("media_key").notNull(),
    sourceType: text("source_type").notNull(),
    sourceId: text("source_id").notNull(),
    /** markdown-image | figure-directive | frontmatter-cover | link | html | other */
    form: text("form").notNull(),
    /** Line number, so a refusal can name where the citation is. */
    detail: text("detail"),
  },
  (t) => [
    primaryKey({
      columns: [t.mediaKey, t.sourceType, t.sourceId, t.form, t.detail],
    }),
    index("media_refs_key_idx").on(t.mediaKey),
    index("media_refs_source_idx").on(t.sourceType, t.sourceId),
  ],
);

/**
 * The site-wide search index, derived. Every read stays in raw SQL: the visibility check scans raw
 * SQL for the composed predicate and would not see a query-builder read. The two FTS5 mirrors are
 * `CREATE VIRTUAL TABLE` and stay out of drizzle.
 */
export const searchDocs = sqliteTable(
  "search_docs",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    uid: text("uid").notNull().unique(),
    url: text("url").notNull(),
    type: text("type", { enum: ["post", "page"] }).notNull(),
    title: text("title").notNull(),
    body: text("body").notNull(),
    /** Feeds the identity index. Carried by the DOCUMENT record only. */
    tags: text("tags").notNull().default(""),
    /** Feeds filtering and facets. Carried by EVERY record, pipe-delimited. */
    docTags: text("doc_tags").notNull().default(""),
    /** Grouping. For a document record this equals `uid` and `anchor` is null. */
    docUid: text("doc_uid").notNull(),
    docTitle: text("doc_title").notNull(),
    docUrl: text("doc_url").notNull(),
    anchor: text("anchor"),
    ordinal: integer("ordinal").notNull().default(0),
    /**
     * Visibility is carried on the record, not joined: a future `publish_at` must be evaluated per
     * request, or a scheduled post would leak or stay hidden once its date passed.
     */
    status: text("status", { enum: ["draft", "published"] }).notNull(),
    publishAt: integer("publish_at"),
  },
  (t) => [
    check("search_docs_type_check", sql`${t.type} in ('post', 'page')`),
    check("search_docs_status_check", sql`${t.status} in ('draft', 'published')`),
    /** Covers the visibility predicate every public search read composes. */
    index("search_docs_visible_idx").on(t.status, t.publishAt),
    /** Covers the type facet and its count. */
    index("search_docs_type_idx").on(t.type),
    /** Covers collapsing section hits back onto their owning document. */
    index("search_docs_doc_idx").on(t.docUid, t.ordinal),
  ],
);

/**
 * Webmentions from other sites: neither authored nor derived, so no rebuild can repair it.
 * No IP column, ever: `/privacy` relies on its absence. Author and excerpt fields come from pages
 * this site does not control, so they are stored as plain text, never markup.
 */
export const webmentions = sqliteTable(
  "webmentions",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    /** Absolute http(s) URL of the page that linked here. */
    sourceUrl: text("source_url").notNull(),
    /**
     * A slug, not a URL: a stored URL carries the origin it arrived on, so a host change would split
     * one post into two dedup targets.
     */
    targetSlug: text("target_slug").notNull(),
    /**
     * `unverified -> pending | failed`, then `pending -> approved | rejected`. The endpoint writes
     * `unverified` before fetching so the global cap counts it at once. Only `approved` renders.
     */
    status: text("status", {
      enum: ["unverified", "pending", "approved", "rejected", "failed"],
    }).notNull(),
    authorName: text("author_name"),
    authorUrl: text("author_url"),
    /** Plain text, at most 280 characters. Never markup. */
    excerpt: text("excerpt"),
    /** One of the fixed reasons in app/lib/webmention/verify.server.ts. */
    failureReason: text("failure_reason"),
    receivedAt: integer("received_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    verifiedAt: integer("verified_at", { mode: "timestamp" }),
    decidedAt: integer("decided_at", { mode: "timestamp" }),
  },
  (t) => [
    check(
      "webmentions_status_check",
      sql`${t.status} in ('unverified', 'pending', 'approved', 'rejected', 'failed')`,
    ),
    /** One row per (source, target), so re-announcing a mention cannot grow the table. */
    uniqueIndex("webmentions_source_target_idx").on(t.sourceUrl, t.targetSlug),
    /** Covers the moderation queue's grouping and the retention sweep's window. */
    index("webmentions_status_received_idx").on(t.status, t.receivedAt),
  ],
);

/**
 * Zero-result search queries, kept as a demand signal. Neither authored nor derived, so nothing
 * rebuilds it; the retention sweep and the primary key bound it. No IP, user agent, session or
 * cookie column by design: `/privacy` states that as a fact about the schema.
 */
export const zeroResultQueries = sqliteTable(
  "zero_result_queries",
  {
    /** Normalized by the writer, so a repeated miss is one row with a count, not many rows. */
    query: text("query").primaryKey(),
    count: integer("count").notNull().default(1),
    firstSeen: integer("first_seen", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    lastSeen: integer("last_seen", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    /** Capped in the database too. The schema test does not compare check predicates, so the SQL owns the number. */
    check("zero_result_queries_length_check", sql`length(${t.query}) <= 200`),
    check("zero_result_queries_nonempty_check", sql`length(${t.query}) > 0`),
    /** The admin list orders by demand. */
    index("zero_result_queries_count_idx").on(t.count),
    /** The 90-day retention sweep selects on the window. */
    index("zero_result_queries_last_seen_idx").on(t.lastSeen),
  ],
);

export type MediaRef = typeof mediaRefs.$inferSelect;
export type Webmention = typeof webmentions.$inferSelect;

/**
 * Procedures (drizzle/0019_procedures.sql, docs/PROCEDURES.md): derived from content/procedures/*.md by
 * app/kb/procedures/compile.mjs. `record` is the rendered procedure as JSON and `markdown` its twin.
 */
export const procedures = sqliteTable(
  "procedures",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    slug: text("slug").notNull().unique(),
    path: text("path").notNull().unique(),
    profile: text("profile", { enum: ["protocol", "recipe", "computational"] }).notNull(),
    title: text("title").notNull(),
    description: text("description").notNull(),
    status: text("status", { enum: ["draft", "published"] }).notNull(),
    version: text("version"),
    updated: text("updated"),
    record: text("record").notNull(),
    markdown: text("markdown").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    check("procedures_profile_check", sql`${t.profile} in ('protocol', 'recipe', 'computational')`),
    check("procedures_status_check", sql`${t.status} in ('draft', 'published')`),
    index("procedures_status_idx").on(t.status, t.profile),
  ],
);

/**
 * Frozen versions (drizzle/0027_procedure_versions.sql): the rendered copy of every published procedure version, written
 * once and never changed. Unlike `procedures` this is not derived from the file: the triggers refuse an UPDATE or DELETE
 * of a sealed row. `record` is the ProcedureRecord JSON and `markdown` its twin, as the live row held them when frozen.
 */
export const procedureVersions = sqliteTable(
  "procedure_versions",
  {
    slug: text("slug").notNull(),
    version: text("version").notNull(),
    path: text("path").notNull(),
    profile: text("profile", { enum: ["protocol", "recipe", "computational"] }).notNull(),
    record: text("record").notNull(),
    markdown: text("markdown").notNull(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    sealed: integer("sealed").notNull().default(0),
    frozenAt: integer("frozen_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    primaryKey({ columns: [t.slug, t.version] }),
    check("procedure_versions_profile_check", sql`${t.profile} in ('protocol', 'recipe', 'computational')`),
    check("procedure_versions_sealed_check", sql`${t.sealed} in (0, 1)`),
  ],
);

/**
 * Publications (drizzle/0020_publications.sql, docs/PUBLICATIONS.md): derived from content/publications/*.md
 * by app/lib/publications/compile.mjs. `record` is the compiled publication as JSON, `csl` the raw
 * Crossref record and `markdown` the twin.
 */
export const publications = sqliteTable(
  "publications",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    slug: text("slug").notNull().unique(),
    doiKey: text("doi_key"),
    status: text("status", { enum: ["draft", "published"] }).notNull(),
    stage: text("stage", { enum: ["published", "submitted"] }).notNull(),
    type: text("type", { enum: ["article", "review", "chapter", "abstract", "teaching-resource"] }).notNull(),
    title: text("title").notNull(),
    year: integer("year").notNull(),
    selected: integer("selected").notNull().default(0),
    record: text("record").notNull(),
    csl: text("csl"),
    markdown: text("markdown").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    check("publications_status_check", sql`${t.status} in ('draft', 'published')`),
    check("publications_stage_check", sql`${t.stage} in ('published', 'submitted')`),
    check(
      "publications_type_check",
      sql`${t.type} in ('article', 'review', 'chapter', 'abstract', 'teaching-resource')`,
    ),
    index("publications_status_idx").on(t.status, t.year),
    uniqueIndex("publications_doi_key_idx").on(t.doiKey),
  ],
);

/** The citation count a page shows, with no expiry (drizzle/0020_publications.sql). Keyed by the lower-cased DOI. */
export const publicationCitations = sqliteTable(
  "publication_citations",
  {
    doi: text("doi").primaryKey(),
    count: integer("count").notNull(),
    url: text("url"),
    fetchedAt: text("fetched_at").notNull(),
  },
  (t) => [check("publication_citations_count_check", sql`${t.count} >= 0`)],
);

/**
 * Pages (drizzle/0021_pages.sql, docs/PAGES.md): the Research, Teaching and Software prose pages, derived
 * from content/pages/*.md by app/lib/pages/compile.mjs. `record` is the rendered page as JSON and
 * `markdown` its twin.
 */
export const pages = sqliteTable(
  "pages",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    slug: text("slug").notNull().unique(),
    path: text("path").notNull().unique(),
    title: text("title").notNull(),
    description: text("description").notNull(),
    status: text("status", { enum: ["draft", "published"] }).notNull(),
    record: text("record").notNull(),
    markdown: text("markdown").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    check("pages_status_check", sql`${t.status} in ('draft', 'published')`),
    index("pages_status_idx").on(t.status),
  ],
);

/** The CV: one row per file in content/cv/ (migration 0022, docs/CV.md). */
export const cv = sqliteTable(
  "cv",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    slug: text("slug").notNull().unique(),
    type: text("type", {
      enum: ["profile", "appointment", "education", "publication", "grant", "award", "talk", "course", "mentoring", "service", "development"],
    }).notNull(),
    record: text("record").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    check(
      "cv_type_check",
      sql`${t.type} in ('profile', 'appointment', 'education', 'publication', 'grant', 'award', 'talk', 'course', 'mentoring', 'service', 'development')`,
    ),
  ],
);

/**
 * Dictionary entries (drizzle/0023_dictionary.sql, docs/DICTIONARY.md): the entry that opens a named Software
 * page, derived from content/dictionary/<key>.md by app/lib/dictionary/compile.mjs. `record` is the entry as
 * JSON.
 */
export const dictionaryEntries = sqliteTable(
  "dictionary_entries",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    key: text("key").notNull().unique(),
    path: text("path").notNull().unique(),
    term: text("term").notNull(),
    status: text("status", { enum: ["draft", "published"] }).notNull(),
    record: text("record").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    check("dictionary_entries_status_check", sql`${t.status} in ('draft', 'published')`),
    index("dictionary_entries_status_idx").on(t.status),
  ],
);

// The Phage Discovery cohorts (docs/ROSTER.md). Derived from content/roster/*.md, one row per cohort, written
// only by writeRosterRow (app/lib/roster/save.server.ts) and sync:content.
export const roster = sqliteTable("roster", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  slug: text("slug").notNull().unique(),
  year: integer("year").notNull().unique(),
  record: text("record").notNull(),
  sourcePath: text("source_path").notNull().unique(),
  sourceBlobSha: text("source_blob_sha").notNull(),
  syncedAt: integer("synced_at", { mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
});

// The phages of the table on /research/phages (docs/PHAGES.md). Derived from content/phages/*.md, one row per
// phage, written only by writePhageRow (app/lib/phages/save.server.ts) and sync:content. The page is compiled
// from these rows, so a write re-derives the page's own row too.
export const phages = sqliteTable(
  "phages",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    slug: text("slug").notNull().unique(),
    name: text("name").notNull(),
    year: integer("year").notNull(),
    record: text("record").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [index("phages_year_idx").on(t.year)],
);

// The lab registry: the primers, strains, reagents and equipment of docs/REGISTRY.md. Derived from
// content/registry/<kind>/<id>.md, one row per item, written only by writeRegistryRow
// (app/kb/registry/save.server.ts) and sync:content. The shared columns are what every kind has; `record` is the item
// as JSON, validated by its kind's own rules.
export const registry = sqliteTable(
  "registry",
  {
    kind: text("kind").notNull(),
    id: text("id").notNull(),
    name: text("name").notNull(),
    status: text("status").notNull(),
    record: text("record").notNull(),
    sourcePath: text("source_path").notNull().unique(),
    sourceBlobSha: text("source_blob_sha").notNull(),
    syncedAt: integer("synced_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    primaryKey({ columns: [t.kind, t.id] }),
    index("registry_kind_name_idx").on(t.kind, t.name),
    check("registry_status_check", sql`${t.status} in ('published', 'draft')`),
  ],
);

// The Knowledge Base's inventory (docs/KNOWLEDGE-BASE.md step 4): D1 is the source, not a copy of a file, because a
// lot changes daily and is private. Nothing reads or writes these tables yet. A lot names a registry item by address,
// with no foreign key: the registry is rewritten by sync:content.
export const kbLocations = sqliteTable("kb_locations", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull().unique(),
  parentId: integer("parent_id").references((): AnySQLiteColumn => kbLocations.id),
  note: text("note"),
});

export const kbLots = sqliteTable(
  "kb_lots",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    itemKind: text("item_kind").notNull(),
    itemId: text("item_id").notNull(),
    lotNumber: text("lot_number").notNull(),
    expiry: text("expiry"),
    amount: real("amount"),
    unit: text("unit"),
    locationId: integer("location_id").references(() => kbLocations.id),
    received: text("received"),
    opened: text("opened"),
    status: text("status").notNull().default("in_stock"),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    uniqueIndex("kb_lots_item_kind_item_id_lot_number_unique").on(t.itemKind, t.itemId, t.lotNumber),
    index("kb_lots_location_idx").on(t.locationId),
    check("kb_lots_status_check", sql`${t.status} in ('in_stock', 'empty', 'discarded')`),
    check("kb_lots_amount_check", sql`${t.amount} IS NULL OR ${t.amount} >= 0`),
  ],
);

/** Append-only: one row per write to a lot or a location, with the row before and after as JSON. */
export const kbAudit = sqliteTable(
  "kb_audit",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    at: integer("at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    actor: text("actor").notNull(),
    tableName: text("table_name").notNull(),
    rowId: integer("row_id").notNull(),
    action: text("action").notNull(),
    beforeRow: text("before_row"),
    afterRow: text("after_row"),
  },
  (t) => [
    index("kb_audit_row_idx").on(t.tableName, t.rowId, t.at),
    check("kb_audit_action_check", sql`${t.action} in ('insert', 'update', 'delete')`),
    check("kb_audit_table_check", sql`${t.tableName} in ('kb_lots', 'kb_locations')`),
  ],
);

// Run records (docs/KNOWLEDGE-BASE.md step 7, drizzle/0031_kb_runs.sql): append-only, so the migration's triggers
// refuse any UPDATE or DELETE and a correction is a new run naming the one it replaces. A run's lot changes are writes to
// kb_lots, each with its kb_audit row. Private to lab members.
export const kbRuns = sqliteTable(
  "kb_runs",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    uid: text("uid").notNull().unique(),
    procedureSlug: text("procedure_slug").notNull(),
    procedureVersion: text("procedure_version"),
    actor: text("actor").notNull(),
    runOn: text("run_on").notNull(),
    outcome: text("outcome").notNull().default("completed"),
    note: text("note"),
    replacesRunId: integer("replaces_run_id")
      .unique()
      .references((): AnySQLiteColumn => kbRuns.id),
    createdAt: integer("created_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    index("kb_runs_actor_idx").on(t.actor, t.createdAt),
    check("kb_runs_outcome_check", sql`${t.outcome} in ('completed', 'failed', 'abandoned')`),
    check("kb_runs_run_on_check", sql`${t.runOn} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'`),
  ],
);

/** The lots one run used, how much of each, and whether it emptied one. Append-only, with its run. */
export const kbRunLots = sqliteTable(
  "kb_run_lots",
  {
    runId: integer("run_id")
      .notNull()
      .references(() => kbRuns.id),
    lotId: integer("lot_id")
      .notNull()
      .references(() => kbLots.id),
    amountUsed: real("amount_used"),
    unit: text("unit"),
    emptied: integer("emptied").notNull().default(0),
  },
  (t) => [
    primaryKey({ columns: [t.runId, t.lotId] }),
    index("kb_run_lots_lot_idx").on(t.lotId),
    check("kb_run_lots_amount_check", sql`${t.amountUsed} IS NULL OR ${t.amountUsed} >= 0`),
    check("kb_run_lots_emptied_check", sql`${t.emptied} in (0, 1)`),
  ],
);

// The /lab workspace's people (docs/KNOWLEDGE-BASE.md step 5, drizzle/0030_lab_members.sql). D1 is the source: people
// change on /lab/members and nowhere else. The rules are site-runtime's `./members`, the titles `app/lab/titles.mjs`;
// the Primary Owner is PRIMARY_OWNER_EMAIL and never a row.
export const members = sqliteTable(
  "members",
  {
    email: text("email").primaryKey().notNull(),
    name: text("name").notNull().default(""),
    title: text("title").notNull(),
    endDate: text("end_date"),
    status: text("status").notNull().default("active"),
    addedBy: text("added_by").notNull(),
    addedAt: integer("added_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [
    check("members_status_check", sql`${t.status} in ('active', 'removed', 'expired')`),
    check("members_email_check", sql`${t.email} = lower(trim(${t.email})) AND instr(${t.email}, '@') > 1`),
  ],
);

/** Append-only: one row per change to a person or to the term dates, with the Access sync's answer. */
export const memberAudit = sqliteTable(
  "member_audit",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    at: text("at").notNull(),
    actor: text("actor").notNull(),
    actorTitle: text("actor_title").notNull(),
    action: text("action").notNull(),
    memberEmail: text("member_email"),
    beforeRow: text("before_row"),
    afterRow: text("after_row"),
    reason: text("reason"),
    cloudflare: text("cloudflare").notNull().default("pending"),
    cloudflareError: text("cloudflare_error"),
  },
  (t) => [
    index("member_audit_member_idx").on(t.memberEmail, t.at),
    index("member_audit_cloudflare_idx").on(t.cloudflare),
    check(
      "member_audit_action_check",
      sql`${t.action} in ('add', 'change_title', 'renew', 'remove', 'expire', 'review_keep', 'set_terms')`,
    ),
    check("member_audit_cloudflare_check", sql`${t.cloudflare} in ('pending', 'accepted', 'failed', 'not_needed')`),
  ],
);

/** The semester end dates an Owner enters once a year; a lab title's end date defaults to the next one. */
export const memberTerms = sqliteTable(
  "member_terms",
  {
    endDate: text("end_date").primaryKey().notNull(),
    addedBy: text("added_by").notNull(),
    addedAt: integer("added_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [check("member_terms_date_check", sql`${t.endDate} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'`)],
);