import type { ColumnType, Insertable, Selectable, Updateable } from 'kysely'; /** * Timestamps are stored as ISO-8601 strings rather than native date types. * * SQLite and D1 have no date type at all, so something has to be chosen; ISO-8601 is the encoding * whose lexicographic order already matches chronological order, which is what lets every `order by` * and every range filter in the codebase treat these as ordinary indexed text. */ export type Timestamp = ColumnType; /** * Booleans are stored as integers (0/1) because SQLite and D1 have no boolean type. The `select` * side is typed as `number` deliberately: repositories convert explicitly via `toBool`/`fromBool` * so the conversion stays greppable instead of hiding in a plugin. */ export type SqlBool = ColumnType; /** * JSON payloads are TEXT, serialised and parsed explicitly at the repository rather than by a * plugin — `toSqlValue` throws on a plain object reaching the driver, so the conversion stays * greppable. Queries never read into these; see `content_item_values` and `content_item_text` for * why the answer is a derived index rather than `json_extract`. */ export type JsonText = ColumnType; export interface UsersTable { id: string; email: string; name: string; avatar_url: string | null; /** * The user's role, site-wide. * * Flat on purpose. An earlier plan scoped role assignments to departments that owned content; * departments turned out to be classification, which taxonomies already do. See `guards.ts`. */ role: 'admin' | 'editor' | 'contributor' | 'viewer'; is_active: SqlBool; created_at: Timestamp; updated_at: Timestamp; } export interface UserCredentialsTable { user_id: string; /** PBKDF2-SHA256, encoded as `pbkdf2$$$`. */ password_hash: string; created_at: Timestamp; updated_at: Timestamp; } export interface OauthAccountsTable { provider: 'google' | 'github' | 'microsoft'; provider_user_id: string; user_id: string; created_at: Timestamp; } export interface TotpSecretsTable { user_id: string; /** Base32-encoded shared secret. */ secret: string; /** Null until the user completes enrolment by confirming a code. */ verified_at: string | null; /** * The highest time step already accepted. * * A code is valid for its whole period plus the drift window, so without this one observed over * a shoulder works again for up to ninety seconds. Refusing anything at or below the last spent * step makes each code single-use. */ last_used_step: number | null; created_at: Timestamp; } /** * One failed sign-in attempt. * * Rows rather than a counter: a counter needs a window start and a reset rule, and two concurrent * requests reading-modifying-writing it lose attempts — on exactly the workload where concurrency * *is* the attack. */ export interface LoginAttemptsTable { id: string; /** Kind-scoped, e.g. `email:someone@example.edu` or `ip:203.0.113.4`. */ identifier: string; created_at: Timestamp; } /** * A single-use token for setting a password. * * `id` is the SHA-256 of the token, as with `sessions` — the raw value only ever exists in the * link. `created_by` is null for a token nobody but the account holder asked for, which is the * shape an email-delivered reset will take. */ export interface PasswordResetTokensTable { id: string; user_id: string; expires_at: Timestamp; created_by: string | null; used_at: Timestamp | null; created_at: Timestamp; } /** * A half-finished sign-in: the password was right, the second factor is outstanding. * * A row rather than a signed cookie, because it has to be revocable and single-use — it represents * most of the way in, and a self-contained token would stay valid however the account changed * underneath it. */ export interface LoginChallengesTable { id: string; user_id: string; expires_at: Timestamp; created_at: Timestamp; } /** A single-use recovery code, hashed at rest. */ export interface TotpRecoveryCodesTable { id: string; user_id: string; used_at: Timestamp | null; created_at: Timestamp; } /** * A short-lived link that shows unpublished content on a site of another origin. * * A row rather than a signed token, following `login_challenges`: revocable, short-lived, and not * carrying its own validity however the account changes underneath it. `release_id` set means the * staged version inside that release rather than the item's own content — one mechanism for both, * because two nearly-identical ones drift until one stops checking something. */ export interface PreviewTokensTable { /** SHA-256 of the token. The raw value exists only in the link. */ id: string; content_item_id: string; release_id: string | null; created_by: string | null; expires_at: Timestamp; created_at: Timestamp; /** * The editor's unsaved form state, for the split-view preview pane. * * A rendering input, not a version — see `0015_preview_draft` for why that distinction is * load-bearing and what must never be built on top of these. `draft_updated_at` is the flag for * "a snapshot exists"; the other four are read only when it is set. */ title: string | null; slug: string | null; /** JSON. Validated with `requireComplete: false`, so richtext is sanitised but nothing is required. */ data: string | null; /** JSON. */ seo: string | null; draft_updated_at: Timestamp | null; } /** * What an API key is allowed to do. * * One scope today, and that is not a placeholder — it is everything the delivery API needs. A write * scope invented before anything writes would be a permission nobody has checked, which is worse * than an absent one because it reads as enforced. */ /** * What a key may do. * * `search:write` is the first scope that is not a read, and it is deliberately narrow: it admits * appending a row to `search_queries` and nothing else. It exists because a search log cannot be * built from the delivery response — that response is cached for a day, so the second search for a * term never reaches an origin and a request-counting log would report the most popular searches as * the rarest. */ export type ApiKeyScope = 'content:read' | 'search:write'; /** * A non-human principal. * * `id` **is** the SHA-256 of the token, as with `sessions` and `password_reset_tokens` — so * verification is one indexed lookup and a database dump holds no usable credentials. The raw value * exists once, in the response that created it. * * Deliberately not a row in `users`. A key cannot own content, cannot author a revision, and must * never satisfy a check written as "an editor did this"; giving it a user row would make all three * true by accident. */ export interface ApiKeysTable { id: string; label: string; /** The first characters of the raw token, in the clear, so a key is recognisable in a list. */ token_prefix: string; /** JSON array of `ApiKeyScope`. */ scopes: JsonText; /** Null means it never expires — the safe default, since silent expiry takes a site down. */ expires_at: string | null; /** Revoked rather than deleted, so audit entries naming this key still resolve. */ revoked_at: string | null; /** Written coarsely; see `touchApiKey` for why it is not exact. */ last_used_at: string | null; created_by: string | null; created_at: Timestamp; updated_at: Timestamp; } /** * One entry in the append-only audit log. * * `actor_email` and `subject_label` are copied in at write time rather than joined at read time, * because a log records what was true *then*: an entry stays readable after the person and the * thing it describes are both gone. `subject_id` has no foreign key for the same reason — a * cascade would delete the evidence along with the subject. */ export interface AuditLogTable { id: string; actor_id: string | null; actor_email: string | null; action: string; subject_type: string; subject_id: string | null; subject_label: string | null; /** JSON text, or null. */ detail: string | null; created_at: Timestamp; } export interface SessionsTable { /** SHA-256 of the session token. The raw token is only ever in the cookie. */ id: string; user_id: string; expires_at: string; created_at: Timestamp; } /** * How a content type's instances are addressed. * * - `page` nests under a parent; path is the materialised chain of slugs (`/admissions/apply`) * - `collection` flat and type-prefixed (`/events/spring-open-house`) * - `singleton` exactly one item ever exists; no create/delete, just edit. Not routable on its own. * - `block` never addressed at all. Instances live inside another item's `data`, placed into a * `block` field, and have no row in `content_items`. * * A block type is a user-defined schema with fields that content conforms to — which is exactly * what a content type is, so it reuses the same table, the same field builder, the same validation, * and the same API rather than growing a parallel set of all four. `kind` already answers "how does * this type's content get addressed", and "it does not" is a coherent fourth answer. * * The cost is that every read of `content_types` meant for *content* has to exclude blocks, so * `listContentTypes` excludes them by default and callers opt in — the safe behaviour is the one * you get by not thinking about it. * * Re-exported from `content/contentTypeKind.ts` rather than declared here, so the list of kinds is * written once and a Zod enum, a runtime guard and this type cannot drift apart. Imported as well as * re-exported, because `ContentTypesTable` below needs the name in local scope. */ import type { ContentTypeKind } from '../content/contentTypeKind.js'; export type { ContentTypeKind }; export interface ContentTypesTable { id: string; /** Stable machine name used in API routes and code. Immutable after creation. */ api_id: string; name: string; name_plural: string; description: string | null; kind: ContentTypeKind; icon: string | null; /** * URL prefix for `collection` types (e.g. `events`). Null for `page` and `singleton`. * Kept separate from `api_id` so the public URL can be renamed without breaking the API. */ url_prefix: string | null; /** * Where a `singleton` renders on the public site, for preview. Null for every other kind. * * A singleton's `path` is the synthetic `/__singleton/{api_id}`, so it cannot say where it is * shown — a homepage built from blocks lives at `/`, and only the site knows that. Null means * "this singleton has no page", which is the right answer for a settings record and the reason * the default is off rather than on. Read through `previewPathFor`, never directly, so the * preview pane and the mint endpoint cannot disagree about which address to frame. */ preview_path: string | null; /** * Whether a `collection`'s items have pages of their own. 1 for every other kind. * * Off is what a staff directory wants: the people are real content items, listed on a page the * site builds, and none of them is a URL. Read through `typeHasItemPages`, never directly — the * delivery resolver, the listing filters and the preview link all gate on it, and a call site * reading the column itself is one that will forget the kind check. */ item_pages: number; /** * Whether this type is left out of the admin sidebar. 0 for every type by default. * * For content that is real content and is never reached from the sidebar — a directory's people, * a course subject reached through the page that lists it — where an entry nobody clicks pushes * the ones people use daily below the fold. * * **The sidebar only.** A hidden type keeps its list screen, its create screen, and its place in * "All content" and in search. A flag that also filtered listings would be a delete that does not * delete. Read through `isNavigable`, so the two questions — does this type exist, and is it in * the sidebar — cannot be conflated at a call site. */ hide_from_nav: number; /** * Whether items of this type have search-and-social settings at all. 0 for every type by default. * * For content that is real content and is never a page anybody shares — a directory's people, a * catalogue's courses. The panel is not harmful when it does not apply, it is permanently empty, * and a screen full of controls that never apply teaches an editor to stop reading the screen. * * **Hides and stops delivering; never deletes.** Any `seo` already stored on an item stays exactly * where it is and comes back if this is turned off again — a content-type setting must not be a * content deletion, the same rule that keeps a conditionally hidden field's value. What it does do * is omit `seo` from the delivery payload, so a consumer does not render fallbacks for a type * nobody maintains them on. */ no_seo: number; /** * Whether a save on this type appends a revision. 0 for every type by default. * * For content that is replaced wholesale rather than edited — a course re-imported each year — * where the history is noise nobody reads and a row per save per item is a table that grows for * nothing. * * **Stops new snapshots and keeps old ones.** The existing rows are the only record of what an * item used to say, and erasing them would be a delete with no confirmation, from a screen that is * not about content. A type switched on after a year of edits still has its year of history. */ no_revisions: number; /** * How an item or block instance of this type is summarised in one line, as a template. * * `{{ api_id }}` tokens filled from the item's `data` — `{{ headline }} · {{ link }}` — rendered by * `renderSummary` and always as **text**. Null means "use the item's own title", which is right for * most content types; a block instance has no title, so its type's name is the floor instead. * * This replaced `title_field`, which named a single field, was offered by the settings screen as * "which field labels an item in admin lists", and was read by no list at all. See * `0027_summary_template` for why it was widened rather than merely wired up. */ summary_template: string | null; /** * Which columns this type's admin list shows, as a JSON array of keys. * * A key is either a field's `api_id` or a built-in name (`title`, `path`, `status`, `updated`, * `created`). Null means the five built-ins every list showed before this was configurable, which * is what keeps the migration from changing every screen somebody was used to. * * Read through `resolveListColumns`, never parsed at a call site: a key naming a field that has * since been deleted is dropped rather than rendering an empty column, and that rule has to hold * everywhere. */ list_columns: JsonText | null; /** One of `ITEM_SORTS`. Null means `path`, the order every list had before this. */ list_sort: string | null; /** * The field `field_asc` / `field_desc` order by. * * Null for every other order. A field named here and later deleted drops the sort back to `path` * rather than erroring — the same rule a query field's `dateFieldApiId` follows, and for the same * reason: a live screen must not break for a configuration change made weeks earlier elsewhere. */ list_sort_field: string | null; /** Order in the admin sidebar, where each content type is its own entry. Ties break by name. */ position: number; /** * Social-card image used by items of this type that have not set their own. * * Most items never need a bespoke card, so the useful default lives at the type level rather * than being copied onto every item at creation — changing it here updates every item that has * not overridden it, which copying would not. */ default_og_image_id: string | null; created_at: Timestamp; updated_at: Timestamp; } /** * The v1 field set from the scope doc, plus `link`. Which of these can be authored is recorded in * exactly one place — `DEFERRED_FIELD_TYPES` — and it is currently empty. * * `link` is not a `relation` with extra options: a relation names a content item and cannot express * an external address, a file, or "open in a new tab", which between them are most of what a button * is. It stores whichever of the three a link actually is, discriminated by `kind`. */ export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'select' | 'media' | 'taxonomy' | 'relation' | 'link' /** * A reference to a reusable text snippet, by its `api_id`. * * The structured half of the same feature `{{ tuition }}` tokens provide. A token is right when * the value goes *inside a sentence*; this is right when the value **is** the field — a chart's * data point, a figure in a stat block — where the consumer wants `4500` rather than the sentence * "$4,500" it would have to parse back. * * Stores the `api_id` rather than the row's uuid, which is the one place this deviates from * `relation` and `media`. The token syntax already makes `api_id` the public name of a snippet and * the delivery map has to be keyed by it regardless, so storing a uuid here would be a second * spelling of one fact. It is safe because `api_id` is immutable. */ | 'snippet' /** * A third-party page framed in an `