// Prisma schema — the single source of truth for the database structure. // The entity documentation lives in docs/architecture/data-model.md; keep // both in sync when the schema evolves. generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } /// Typed key-value configuration for the instance (registration mode, /// default quotas, legal pages, …). Values are validated with Zod before /// writing; see the InstanceSettings service (issue #19). model InstanceSetting { key String @id value Json updatedAt DateTime @updatedAt @@map("instance_settings") } enum UserStatus { PENDING_VERIFICATION ACTIVE DISABLED } /// Account profile. Login methods live in UserIdentity (OIDC-ready, /// ADR 0007); Site Admin is a user flag, all other roles are grants. model User { id String @id @default(uuid()) username String @unique email String @unique displayName String @map("display_name") locale String @default("en") isSiteAdmin Boolean @default(false) @map("is_site_admin") status UserStatus @default(PENDING_VERIFICATION) emailVerifiedAt DateTime? @map("email_verified_at") createdAt DateTime @default(now()) @map("created_at") lastLoginAt DateTime? @map("last_login_at") identities UserIdentity[] sessions Session[] authTokens AuthToken[] ponds Pond[] pages Page[] attachments Attachment[] @@map("users") } enum PondType { PERSONAL SHARED } /// Top-level content container (data-model.md §ponds). Personal ponds are /// created automatically on e-mail verification; `settings` stores only /// deviations from the defaults (pondSettingsSchema in @dorfteich/shared). /// `deletedAt`/`deletedBy` implement the pond-level trash (ADR 0013). model Pond { id String @id @default(uuid()) slug String @unique name String description String @default("") type PondType ownerId String @map("owner_id") settings Json @default("{}") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") owner User @relation(fields: [ownerId], references: [id]) usage PondUsage? pages Page[] attachments Attachment[] @@index([ownerId]) @@map("ponds") } /// A wiki page (data-model.md §pages). Carries a Yjs document from day one /// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is /// the merged state Y.Doc, decoded by the API to derive `PageContentCache` /// on every save (issue #23). `sortKey` uses fractional indexing so pages /// can be reordered without rewriting siblings (sidebar reorder is #26). model Page { id String @id @default(uuid()) pondId String @map("pond_id") title String slug String ydocState Bytes @map("ydoc_state") sortKey String @map("sort_key") createdBy String @map("created_by") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") pond Pond @relation(fields: [pondId], references: [id]) creator User @relation(fields: [createdBy], references: [id]) updates PageUpdate[] contentCache PageContentCache? attachments Attachment[] versions PageVersion[] pendingContributors PagePendingContributor[] @@unique([pondId, slug]) @@index([pondId]) @@map("pages") } /// Append log for incremental Yjs updates (data-model.md), compacted /// periodically. Unused by M2's whole-state REST saves; the collab /// server's persistence hooks (#35) are the first real writer. model PageUpdate { id String @id @default(uuid()) pageId String @map("page_id") seq Int update Bytes page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) @@unique([pageId, seq]) @@map("page_updates") } enum PageVersionTrigger { AUTO MANUAL PRE_RESTORE } /// Version snapshot of a page (ADR 0013, data-model.md). `ydocSnapshot` is a /// full, self-contained encoded Yjs state — restore never depends on the /// update log, so compaction (#40) cannot lose restorable history. /// `contributorIds` is the set of users who edited since the previous version /// (derived from the live session, #41). Created automatically at session end /// and on an active-editing interval by collab, and on demand (named) by the /// api; `PRE_RESTORE` snapshots are written before a restore (#42). model PageVersion { id String @id @default(uuid()) pageId String @map("page_id") ydocSnapshot Bytes @map("ydoc_snapshot") trigger PageVersionTrigger label String? contributorIds String[] @map("contributor_ids") createdAt DateTime @default(now()) @map("created_at") page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) @@index([pageId, createdAt]) @@map("page_versions") } /// Accumulator of users who have edited a page since its last version (#41). /// Collab flushes the current session's contributors here (deduplicated by the /// composite key); version creation on either side reads and clears it in the /// same transaction as writing the snapshot. Cascades on page purge (ADR 0013). model PagePendingContributor { pageId String @map("page_id") userId String @map("user_id") page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) @@id([pageId, userId]) @@map("page_pending_contributors") } /// Live-session registry the collab server keeps current: one row per page /// with an open collaboration session, refreshed by a heartbeat (issue #40). /// The compaction job reads it to skip pages that are being edited; a stale /// row (collab crashed without unloading) ages out via the heartbeat window, /// so no FK to `pages` is needed and a leftover row is harmless. Collab is the /// only writer, over raw SQL (it does not use Prisma). model CollabOpenSession { pageId String @id @map("page_id") heartbeatAt DateTime @map("heartbeat_at") @@index([heartbeatAt]) @@map("collab_open_sessions") } /// Derived plain representation refreshed on every state save (issue #23), /// built from the Yjs state via the shared editor schema. `outline` is the /// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb. model PageContentCache { pageId String @id @map("page_id") plainText String @map("plain_text") markdown String html String outline Json updatedAt DateTime @updatedAt @map("updated_at") page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) @@map("page_content_cache") } enum QuotaSubjectType { USER POND } /// Per-user/per-pond quota values (ADR 0011). Resolution: pond override → /// user override → instance default (QuotaService). `value` is BigInt so /// storage limits beyond 2 GiB fit. model QuotaOverride { id String @id @default(uuid()) subjectType QuotaSubjectType @map("subject_type") subjectId String @map("subject_id") quotaKey String @map("quota_key") value BigInt updatedAt DateTime @updatedAt @map("updated_at") @@unique([subjectType, subjectId, quotaKey]) @@map("quota_overrides") } /// Cached usage counters per pond, updated transactionally with the /// guarded writes (uploads land in M2 #27, membership in M5); reconciled /// nightly by a maintenance job (operations.md). model PondUsage { pondId String @id @map("pond_id") storageBytesUsed BigInt @default(0) @map("storage_bytes_used") editorCount Int @default(0) @map("editor_count") readerCount Int @default(0) @map("reader_count") updatedAt DateTime @updatedAt @map("updated_at") pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade) @@map("pond_usage") } /// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at /// `//` (FileStorageService); this row carries the /// metadata needed to serve and account for it. `pageId` starts unset — /// images are uploaded before the page referencing them is known /// (paste-then-insert, issue #28) — and is set on every page state save to /// whichever page's document currently embeds the file (issue #31, /// `PagesService.saveState`); the trash-purge job uses that link to delete /// a purged page's files. Not touched when an image is later removed from /// its page's content — an orphan-file sweep to reclaim those is a /// separate future maintenance job (operations.md), not this one. /// `deletedAt` stays unused for now — purge hard-deletes attachments /// directly rather than soft-deleting them first — reserved for that same /// future orphan-sweep job. model Attachment { id String @id @default(uuid()) pondId String @map("pond_id") pageId String? @map("page_id") fileName String @map("file_name") mimeType String @map("mime_type") sizeBytes Int @map("size_bytes") storagePath String @map("storage_path") uploadedBy String @map("uploaded_by") createdAt DateTime @default(now()) @map("created_at") deletedAt DateTime? @map("deleted_at") pond Pond @relation(fields: [pondId], references: [id]) page Page? @relation(fields: [pageId], references: [id]) uploader User @relation(fields: [uploadedBy], references: [id]) @@index([pondId]) @@index([pageId]) @@map("attachments") } /// One row per login method. `provider` is "password" today and /// "oidc:" later; `credential` holds the Argon2id hash for /// password identities. model UserIdentity { id String @id @default(uuid()) userId String @map("user_id") provider String subject String credential String? createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, subject]) @@index([userId]) @@map("user_identities") } /// Server-side browser sessions (ADR 0007). `id` is the SHA-256 hash of /// the opaque cookie token — the raw token is never stored. model Session { id String @id userId String @map("user_id") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") lastSeenAt DateTime @default(now()) @map("last_seen_at") userAgent String? @map("user_agent") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([expiresAt]) @@map("sessions") } enum AuthTokenPurpose { EMAIL_VERIFICATION PASSWORD_RESET } /// Single-use, expiring tokens for e-mail flows. Stored hashed; consuming /// sets `consumedAt` so replays are detectable. model AuthToken { id String @id @default(uuid()) tokenHash String @unique @map("token_hash") userId String @map("user_id") purpose AuthTokenPurpose expiresAt DateTime @map("expires_at") consumedAt DateTime? @map("consumed_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, purpose]) @@map("auth_tokens") } /// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes /// scope and subject, e.g. "login:ip:203.0.113.7". model RateLimit { key String @id windowStart DateTime @map("window_start") count Int @default(0) @@map("rate_limits") } enum MailStatus { PENDING SENT FAILED } /// Outbox for reliable e-mail delivery with retry (issue #12). model MailOutbox { id String @id @default(uuid()) toAddress String @map("to_address") subject String textBody String @map("text_body") htmlBody String @map("html_body") status MailStatus @default(PENDING) attempts Int @default(0) nextAttemptAt DateTime @default(now()) @map("next_attempt_at") lastError String? @map("last_error") createdAt DateTime @default(now()) @map("created_at") sentAt DateTime? @map("sent_at") @@index([status, nextAttemptAt]) @@map("mail_outbox") } enum JobStatus { IDLE RUNNING FAILED } /// Generic maintenance-job bookkeeping (data-model.md, operations.md; /// issue #31). One row per named job; `SchedulerService` is the only /// writer. `status`/`lockedAt` double as the run-mutex: claiming a due job /// is a single atomic `UPDATE ... WHERE status != 'RUNNING'`, which is safe /// under concurrent processes without needing a session-scoped advisory /// lock (Prisma doesn't guarantee one connection across separate calls). /// `lastRunAt` is what makes the schedule survive an api restart — cadence /// is computed from it, not from an in-memory timer start time. The mail /// outbox worker (#12) predates this table and still runs its own loop; /// folding it in is left for whenever that file is next touched, not this /// issue's job to do. model Job { name String @id cadenceSeconds Int @map("cadence_seconds") status JobStatus @default(IDLE) lastRunAt DateTime? @map("last_run_at") lockedAt DateTime? @map("locked_at") lastError String? @map("last_error") updatedAt DateTime @updatedAt @map("updated_at") @@map("jobs") }