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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| <anonymous:66> | 66 to 76 | 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:90> | 90 to 90 | 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:93> | 93 to 93 | 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:95> | 95 to 98 | 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:156> | 156 to 166 | 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:184> | 184 to 190 | 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:224> | 224 to 233 | 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:271> | 271 to 280 | 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:301> | 301 to 309 | 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:339> | 339 to 343 | 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:366> | 366 to 370 | 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:399> | 399 to 408 | 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:420> | 420 to 420 | 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:445> | 445 to 448 | 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:467> | 467 to 472 | 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:495> | 495 to 498 | 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:532> | 532 to 532 | 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:553> | 553 to 557 | 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:566> | 566 to 566 | 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:580> | 580 to 580 | 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:591> | 591 to 596 | 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:614> | 614 to 618 | 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:637> | 637 to 637 | 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:642> | 642 to 646 | 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:655> | 655 to 655 | 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:658> | 658 to 658 | 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:663> | 663 to 668 | 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:690> | 690 to 693 | 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:712> | 712 to 720 | 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:733> | 733 to 733 | 1 | 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 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]'`)],
);