import { sql, type SQL } from 'drizzle-orm' import { bigint, boolean, bytea, customType, index, integer, jsonb, pgEnum, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core' // == CUSTOM TYPES ===================== // -> Typed as a string: an ltree path comes back from the driver as its dotted text form, and every // caller treats it as one const ltree = customType<{ data: string }>({ dataType() { return 'ltree' } }) const tsvector = customType({ dataType() { return 'tsvector' } }) // == TABLES =========================== // API KEYS ---------------------------- export const apiKeys = pgTable('apiKeys', { id: uuid().primaryKey().defaultRandom(), name: varchar({ length: 255 }).notNull(), // -> Only the tail of the token, to tell keys apart in the admin list. The token itself is a // signed JWT shown once at creation and never stored: it is a bearer credential, and // verification needs the public key plus this row's state, not the token. keyShort: varchar({ length: 8 }).notNull(), // -> IDs of the groups whose permissions the key carries. Resolved on every request, so editing a // group immediately affects the keys pointing at it. groups: jsonb().notNull().default([]), expiration: timestamp().notNull().defaultNow(), isRevoked: boolean().notNull().default(false), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }) // APPROVAL RULES ---------------------- /** * Which pages accept edit suggestions, who may submit them, and who reviews them. * * Per site, and matched the way group page rules are: a mode plus a pattern. A page no rule matches * accepts no suggestions at all, so this table being empty means the feature is off. */ export const approvalRules = pgTable( 'approvalRules', { id: uuid().primaryKey().defaultRandom(), name: varchar({ length: 255 }).notNull().default(''), // -> A rule can be turned off without losing what it says, which is how an administrator suspends // suggestions on a section without having to write the rule again afterwards. isEnabled: boolean().notNull().default(true), // -> One of START / EXACT / END / REGEX / TAG / TAGALL, the same set group page rules use. A // varchar rather than an enum so that adding a mode does not need a migration; the API schema // is what rejects an unknown one. match: varchar({ length: 16 }).notNull().default('START'), path: varchar({ length: 2048 }).notNull().default(''), // -> Group IDs. Resolved on use rather than joined, so deleting a group takes effect at once, the // way `apiKeys.groups` works. submitterGroups: jsonb().notNull().default([]), reviewerGroups: jsonb().notNull().default([]), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow(), siteId: uuid() .notNull() .references(() => sites.id) }, (table) => [index('approvalRules_siteId_idx').on(table.siteId)] ) // ASSETS ------------------------------ export const assetKindEnum = pgEnum('assetKind', ['document', 'image', 'other']) export const assets = pgTable( 'assets', { id: uuid().primaryKey().defaultRandom(), fileName: varchar({ length: 255 }).notNull(), fileExt: varchar({ length: 255 }).notNull(), isSystem: boolean().notNull().default(false), kind: assetKindEnum().notNull().default('other'), mimeType: varchar({ length: 255 }).notNull().default('application/octet-stream'), fileSize: bigint({ mode: 'number' }), // in bytes meta: jsonb().notNull().default({}), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow(), data: bytea(), preview: bytea(), storageInfo: jsonb(), authorId: uuid() .notNull() .references(() => users.id), siteId: uuid() .notNull() .references(() => sites.id) }, (table) => [index('assets_siteId_idx').on(table.siteId)] ) // AUTHENTICATION ---------------------- export const authentication = pgTable('authentication', { id: uuid().primaryKey().defaultRandom(), module: varchar({ length: 255 }).notNull(), isEnabled: boolean().notNull().default(false), displayName: varchar({ length: 255 }).notNull().default(''), config: jsonb().notNull().default({}), registration: boolean().notNull().default(false), allowedEmailRegex: varchar({ length: 255 }).notNull().default(''), autoEnrollGroups: uuid().array().default([]) }) // BLOCKS ------------------------------ export const blocks = pgTable( 'blocks', { id: uuid().primaryKey().defaultRandom(), block: varchar({ length: 255 }).notNull(), name: varchar({ length: 255 }).notNull(), description: varchar({ length: 255 }).notNull(), icon: varchar({ length: 255 }).notNull(), isEnabled: boolean().notNull().default(false), isCustom: boolean().notNull().default(false), config: jsonb().notNull().default({}), siteId: uuid() .notNull() .references(() => sites.id) }, (table) => [index('blocks_siteId_idx').on(table.siteId)] ) // GROUPS ------------------------------ export const groups = pgTable('groups', { id: uuid().primaryKey().defaultRandom(), name: varchar({ length: 255 }).notNull(), permissions: jsonb().notNull(), rules: jsonb().notNull(), redirectOnLogin: varchar({ length: 255 }).notNull().default(''), redirectOnFirstLogin: varchar({ length: 255 }).notNull().default(''), redirectOnLogout: varchar({ length: 255 }).notNull().default(''), isSystem: boolean().notNull().default(false), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }) // HOOKS ------------------------------- export const hookStateEnum = pgEnum('hookState', ['pending', 'success', 'error']) export const hooks = pgTable('hooks', { id: uuid().primaryKey().defaultRandom(), name: varchar({ length: 255 }).notNull(), // -> Event keys such as `page:create`, matched against what the server emits events: text() .array() .notNull() .default(sql`ARRAY[]::text[]`), url: text().notNull(), includeMetadata: boolean().notNull().default(true), includeContent: boolean().notNull().default(false), acceptUntrusted: boolean().notNull().default(false), // -> Sent verbatim as the Authorization header, so it holds whatever secret the remote expects authHeader: text(), // -> Outcome of the most recent delivery, which is what the admin list shows state: hookStateEnum().notNull().default('pending'), lastErrorMessage: text(), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }) // ICONS ------------------------------- // -> An Iconify icon set the wiki draws icons from, e.g. `mdi`. Adding one makes its icons // searchable; individual icons are only stored once something references them. export const iconSets = pgTable('iconSets', { // -> The Iconify prefix, which is what content references: `:` prefix: varchar({ length: 64 }).primaryKey(), name: varchar({ length: 255 }).notNull(), isEnabled: boolean().notNull().default(true), // -> Iconify collection metadata (author, license, total, palette, samples, ...) as published by // the upstream API, refreshed on demand rather than being authored here info: jsonb().notNull().default({}), refreshedAt: timestamp(), createdAt: timestamp().notNull().defaultNow() }) // -> The permanent home of every icon the wiki has ever served. Fetched from the Iconify API on first // use, then never fetched again: the disk cache is derived from these rows and may be empty. export const icons = pgTable( 'icons', { prefix: varchar({ length: 64 }) .notNull() .references(() => iconSets.prefix), name: varchar({ length: 255 }).notNull(), // -> The SVG markup inside the `` element, with `currentColor` left as-is body: text().notNull(), // -> Resolved Iconify icon properties: the viewBox is `left top width height`, and the transform // flags apply on top of it. Aliases are resolved before storing, so a row is self-contained. width: integer().notNull().default(16), height: integer().notNull().default(16), left: integer().notNull().default(0), top: integer().notNull().default(0), rotate: integer().notNull().default(0), hFlip: boolean().notNull().default(false), vFlip: boolean().notNull().default(false), createdAt: timestamp().notNull().defaultNow() }, (table) => [primaryKey({ columns: [table.prefix, table.name] })] ) // JOB HISTORY ------------------------- export const jobHistoryStateEnum = pgEnum('jobHistoryState', [ 'active', 'completed', 'failed', 'interrupted' ]) export const jobHistory = pgTable('jobHistory', { id: uuid().primaryKey().defaultRandom(), task: varchar({ length: 255 }).notNull(), state: jobHistoryStateEnum().notNull(), useWorker: boolean().notNull().default(false), wasScheduled: boolean().notNull().default(false), payload: jsonb(), attempt: integer().notNull().default(1), maxRetries: integer().notNull().default(0), lastErrorMessage: text(), executedBy: varchar({ length: 255 }), createdAt: timestamp().notNull(), startedAt: timestamp().notNull().defaultNow(), completedAt: timestamp() }) // JOB SCHEDULE ------------------------ export const jobSchedule = pgTable('jobSchedule', { id: uuid().primaryKey().defaultRandom(), task: varchar({ length: 255 }).notNull(), cron: varchar({ length: 255 }).notNull(), type: varchar({ length: 255 }).notNull().default('system'), payload: jsonb(), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }) // JOB LOCK ---------------------------- export const jobLock = pgTable('jobLock', { key: varchar({ length: 255 }).primaryKey(), lastCheckedBy: varchar({ length: 255 }), lastCheckedAt: timestamp().notNull().defaultNow() }) // JOBS -------------------------------- export const jobs = pgTable('jobs', { id: uuid().primaryKey().defaultRandom(), task: varchar({ length: 255 }).notNull(), useWorker: boolean().notNull().default(false), payload: jsonb(), retries: integer().notNull().default(0), maxRetries: integer().notNull().default(0), waitUntil: timestamp(), isScheduled: boolean().notNull().default(false), createdBy: varchar({ length: 255 }), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }) // LOCALES ----------------------------- export const locales = pgTable( 'locales', { code: varchar({ length: 255 }).primaryKey(), name: varchar({ length: 255 }).notNull(), nativeName: varchar({ length: 255 }).notNull(), language: varchar({ length: 8 }).notNull(), // Unicode language subtag region: varchar({ length: 3 }).notNull(), // Unicode region subtag script: varchar({ length: 4 }).notNull(), // Unicode script subtag isRTL: boolean().notNull().default(false), strings: jsonb().notNull().default([]), completeness: integer().notNull().default(0), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow() }, (table) => [index('locales_language_idx').on(table.language)] ) // NAVIGATION -------------------------- export const navigation = pgTable( 'navigation', { id: uuid().primaryKey().defaultRandom(), items: jsonb().notNull().default([]), siteId: uuid() .notNull() .references(() => sites.id) }, (table) => [index('navigation_siteId_idx').on(table.siteId)] ) // PAGES ------------------------------ export const pagePublishStateEnum = pgEnum('pagePublishState', ['draft', 'published', 'scheduled']) export const pages = pgTable( 'pages', { id: uuid().primaryKey().defaultRandom(), // -> A BCP-47 code, matched only ever for equality. Not `ltree`: a hyphenated code is a single // label to it, so `'pt-BR'::ltree <@ 'pt'` is false and the type buys no locale-family // matching -- see the note on `pageHistory.locale`. locale: varchar({ length: 255 }).notNull(), path: varchar({ length: 255 }).notNull(), hash: varchar({ length: 255 }).notNull(), alias: varchar({ length: 255 }), title: varchar({ length: 255 }).notNull(), description: varchar({ length: 255 }), icon: varchar({ length: 255 }), publishState: pagePublishStateEnum('publishState').notNull().default('draft'), publishStartDate: timestamp(), publishEndDate: timestamp(), config: jsonb().notNull().default({}), relations: jsonb().notNull().default([]), content: text(), render: text(), searchContent: text(), ts: tsvector('ts'), tags: text() .array() .notNull() .default(sql`ARRAY[]::text[]`), toc: jsonb(), editor: varchar({ length: 255 }).notNull(), contentType: varchar({ length: 255 }).notNull(), isBrowsable: boolean().notNull().default(true), isSearchable: boolean().notNull().default(true), // -> The generated expression references its own table, so the return type must be annotated // explicitly to break the circular inference (TS7022/TS7024). isSearchableComputed: boolean('isSearchableComputed').generatedAlwaysAs( (): SQL => sql`${pages.publishState} != 'draft' AND ${pages.isSearchable}` ), password: varchar({ length: 255 }), ratingScore: integer().notNull().default(0), ratingCount: timestamp().notNull().defaultNow(), scripts: jsonb().notNull().default({}), historyData: jsonb().notNull().default({}), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow(), authorId: uuid() .notNull() .references(() => users.id), creatorId: uuid() .notNull() .references(() => users.id), ownerId: uuid() .notNull() .references(() => users.id), siteId: uuid() .notNull() .references(() => sites.id) }, (table) => [ index('pages_authorId_idx').on(table.authorId), index('pages_creatorId_idx').on(table.creatorId), index('pages_ownerId_idx').on(table.ownerId), index('pages_siteId_idx').on(table.siteId), index('pages_ts_idx').using('gin', table.ts), index('pages_tags_idx').using('gin', table.tags), index('pages_isSearchableComputed_idx').on(table.isSearchableComputed) ] ) // PAGE HISTORY ------------------------ /** * One row per change to a page: what it looked like afterwards, who made it, and what kind of change * it was. * * Every row is a complete version rather than a delta, which is what makes the three things this * exists for straightforward: comparing any two versions, putting a page back to one of them, and * recovering a page that was deleted. The deletion itself is recorded the same way, carrying the page * as it stood when it went — that row is the whole of what a recovery needs. * * The render is deliberately not kept. It is derived from the content by a pipeline that lives in the * frontend, and storing a second copy of every page's HTML for every version is a great deal of space * for something a restore can regenerate. */ export const pageHistory = pgTable( 'pageHistory', { id: uuid().primaryKey().defaultRandom(), // -> Not a foreign key: the history of a deleted page is exactly what recovering it needs, so it // has to outlive the row it points at pageId: uuid().notNull(), /** * `created`, `updated`, `moved` or `deleted`. A varchar rather than an enum so that naming another * kind of change later does not need a migration. */ action: varchar({ length: 16 }).notNull().default('updated'), /** Which fields this change touched, so a history list can summarise it without diffing. */ changedFields: text() .array() .notNull() .default(sql`ARRAY[]::text[]`), /* Columns rather than part of `meta` below: a history list shows these for every row, a page that has moved needs the path it had at the time rather than the one it has now, and looking a history up by where the page was — the only way in once the page itself is gone — means matching on the locale and the path together. A locale code is BCP-47 with hyphens (`pt-BR`), and every comparison anywhere is an equality one. `locales.code`, which these values come from, is a varchar too. */ locale: varchar({ length: 255 }).notNull(), path: varchar({ length: 255 }).notNull(), title: varchar({ length: 255 }).notNull(), content: text(), /** * The rest of the page as it stood: description, icon, tags, publish state and dates, relations, * scripts, config, editor and content type. Kept whole rather than as columns of its own so that a * field added to a page does not have to be added here too. */ meta: jsonb().notNull().default({}), /** * Why the change was made, in the author's words, as the editor's reason-for-change prompt * collected it. Null when the site does not ask for one, or asks and is not answered. */ reason: varchar({ length: 255 }), versionDate: timestamp().notNull().defaultNow(), // -> Null once the account is gone, rather than holding the account hostage: a history row is a // record of what happened to the page, and requiring its author to exist for ever would mean // that editing a page once made an account undeletable — even after the page itself was gone. authorId: uuid().references(() => users.id, { onDelete: 'set null' }), siteId: uuid() .notNull() .references(() => sites.id) }, (table) => [ index('pageHistory_pageId_idx').on(table.pageId, table.versionDate), // -> "What happened to the page at this path, in this locale", which is how a deleted page is // found again: there is no page row left to look its ID up from. Leading with `siteId` means // this also serves the plain per-site queries. index('pageHistory_siteId_idx').on(table.siteId, table.locale, table.path, table.versionDate), index('pageHistory_authorId_idx').on(table.authorId) ] ) // PAGE EDIT SUBMISSIONS --------------- /** * An edit suggested by somebody who may read a page but not change it, waiting to be reviewed. * * Both the resulting source and a patch are kept, because they answer different questions. The patch * is what a reviewer merges — it is computed against the page as it stood at submission time, so two * people suggesting edits to different parts of a page can both be accepted. The source is what the * author resumes from and what a review screen shows, and it cannot be reconstructed from the patch * alone once the page has moved on. */ export const pageEditSubmissions = pgTable( 'pageEditSubmissions', { id: uuid().primaryKey().defaultRandom(), content: text().notNull(), /** Unified diff, from the page content this was based on to `content`. */ patch: text().notNull(), /** SHA-256 of that base content, so a reviewer can tell the page has changed underneath. */ baseHash: varchar({ length: 64 }).notNull(), // -> A guest has no account to attribute the suggestion to, so it says who sent it. Null for a // logged in author, whose name is on `authorId` instead. guestName: varchar({ length: 255 }), guestEmail: varchar({ length: 255 }), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow(), pageId: uuid() .notNull() .references(() => pages.id, { onDelete: 'cascade' }), siteId: uuid() .notNull() .references(() => sites.id), authorId: uuid().references(() => users.id) }, (table) => [ index('pageEditSubmissions_pageId_idx').on(table.pageId), index('pageEditSubmissions_siteId_idx').on(table.siteId), index('pageEditSubmissions_authorId_idx').on(table.authorId), // -> One open suggestion per person per page: coming back to the button continues that one rather // than starting a second. Guests are excluded because they are all the same nobody. uniqueIndex('pageEditSubmissions_page_author_idx') .on(table.pageId, table.authorId) .where(sql`"authorId" IS NOT NULL`) ] ) // PAGE WATCHING ----------------------- /** * A page somebody asked to be told about, one row per person per page. * * A row IS the watch: there is no `isEnabled` to turn off, because unwatching a page is not a state a * page keeps — it is the absence of interest, and the row goes. Which is also why the whole table can * be read as "everyone to notify about this page" when notifications are built on top of it. * * `siteId` is carried alongside `pageId` rather than reached through the page, since every query here * is scoped to one site: the watch list belongs to an inbox, and an inbox belongs to a site. */ export const pageWatching = pgTable( 'pageWatching', { id: uuid().primaryKey().defaultRandom(), createdAt: timestamp().notNull().defaultNow(), pageId: uuid() .notNull() .references(() => pages.id, { onDelete: 'cascade' }), siteId: uuid() .notNull() .references(() => sites.id), userId: uuid() .notNull() .references(() => users.id, { onDelete: 'cascade' }) }, (table) => [ // -> Covers the site scoping too, being the leading column: this is the inbox's own query index('pageWatching_user_site_idx').on(table.userId, table.siteId), // -> Watching a page twice is watching it once, so the second attempt is a no-op rather than a row uniqueIndex('pageWatching_page_user_idx').on(table.pageId, table.userId) ] ) // PAGE RENDER QUEUE ------------------- /** * A page waiting for the server to render it, one row per page. * * The markdown pipeline lives in the frontend, so rendering a page here means driving a headless * browser — too heavy to hold a request open for, and ruinous to do several times at once. A row is a * request for a render, and the `renderPages` task drains the table one page at a time through a * single browser (`models/rendering.ts`). * * A row IS the request, so asking twice for the same page updates the row instead of adding a second: * what gets rendered is the content as it stands when the browser reaches it, and rendering it twice * would produce the same HTML. `createdAt` keeps its place in the queue across those repeats. * * The two permissions travel with the row because a render is sanitized against what the person who * asked for it may embed, and by the time the job runs there is no session left to ask. */ export const pageRenderQueue = pgTable( 'pageRenderQueue', { id: uuid().primaryKey().defaultRandom(), /** `write:scripts` — whether this render may keep `