dorfteich/apps/api/prisma/schema.prisma
Claude Fable 5 8ae010218e
Some checks failed
CD / Build and push images (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m21s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
Obsidian vault import: endpoint, job orchestration, rollback (#117)
POST /ponds/:pondId/import/vault (pond-admin-gated; a vault import
creates a subtree, uploads files, and creates labels — administration,
not everyday editing) takes the ZIP plus a JSON options field
{parentPageId?, labelIds?, frontmatterMode}. The archive is parsed at
enqueue for fast 400s; the job (new kind import_vault, riding the
existing isImportKind worker routing) re-parses and runs the #116
transform, then: containers top-down → notes (asset placeholders →
uploaded pond files; non-images become page attachments) → tags to
labels (nested tags build a label hierarchy via LabelsService, so
locking and cache invalidation apply) plus the dialog labels.

All-or-nothing: any failure hard-deletes the created pages (children
first) and removes the stored files (quota restored), then surfaces as
import_vault_invalid_zip / import_vault_too_large / quota_exceeded /
conversion_failed — and makes the worker's retry policy safe.

Supporting changes:
- conversion_jobs gains a nullable options jsonb column; enqueue takes
  kind-specific options and a maxInputBytes override (the 25 MiB
  default protects the pandoc sidecar, which a vault never touches —
  vaults use the 64 MiB upload limit).
- insertPage accepts a pre-reserved slug (the batch reserves all slugs
  up front against pond ∪ batch).
- NEW: pages born with content seed their outgoing page_links rows
  (deriveContent now returns wikilinkSlugs) — imported pages would
  otherwise stay invisible to backlinks and the graph until their
  first collab save. Collab still rewrites the rows on every save, and
  the existing phantom resolution heals batch creation order.

import-vault.e2e.db.test.ts (4 tests, real worker drained): gating +
input rejection, the full fixture import (tree under a mount page,
collision suffixes, link rows incl. phantom, nested tag labels, extra
label everywhere, frontmatter stripped, image embedded + PDF attached),
complete quota rollback, and a clean re-import with fresh suffixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:13:11 +02:00

778 lines
29 KiB
Plaintext

// 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")
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
/// E-mail digest cadence (issue #95): hourly | daily | off.
digestFrequency String @default("hourly") @map("digest_frequency")
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[]
apiTokens ApiToken[]
ponds Pond[]
pages Page[]
attachments Attachment[]
conversionJobs ConversionJob[]
auditEntries AuditEntry[]
comments Comment[]
watches Watch[]
notifications Notification[]
@@map("users")
}
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
/// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which
/// also keeps emitting the established `audit: …` stdout log line. Content
/// activity (pages, files, exports) stays log-only by design.
model AuditEntry {
id String @id @default(uuid())
at DateTime @default(now())
/// Stable dot-namespaced action id, e.g. `grant.created`, `auth.login_failed`.
action String
/// Null for anonymous events (failed login for an unknown user) and after a
/// hard account deletion; pseudonymized accounts keep their id.
actorId String? @map("actor_id")
targetType String? @map("target_type")
targetId String? @map("target_id")
details Json?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
@@index([at])
@@index([actorId, at])
@@index([action, at])
@@map("audit_log")
}
/// Threaded page comments (issue #91, data-model.md §Comments). Threads are
/// one level deep: roots carry the optional document anchor and the resolve
/// state, replies reference the root via `parentId`. Purging a page cascades
/// its comments; trashing merely hides them (the list endpoint resolves the
/// page as a live page). Authors survive pseudonymization ("deleted user");
/// only a hard user delete nulls them.
model Comment {
id String @id @default(uuid())
pageId String @map("page_id")
parentId String? @map("parent_id")
authorId String? @map("author_id")
/// Markdown; rendered through the shared sanitizing pipeline on read.
body String
/// Opaque serialized document position (roots only).
anchor String?
resolvedAt DateTime? @map("resolved_at")
resolvedBy String? @map("resolved_by")
createdAt DateTime @default(now()) @map("created_at")
editedAt DateTime? @map("edited_at")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
parent Comment? @relation("thread", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("thread")
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([pageId, createdAt])
@@index([parentId])
@@map("comments")
}
enum WatchTargetType {
PAGE
POND
}
/// Explicit subscription (issue #93, data-model.md §watches): the target is
/// polymorphic (no FK) — page purge removes its watches via the trash
/// service, and the list endpoint filters targets the user can no longer
/// read, so stale rows are invisible and harmless.
model Watch {
id String @id @default(uuid())
userId String @map("user_id")
targetType WatchTargetType @map("target_type")
targetId String @map("target_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, targetType, targetId])
@@index([targetType, targetId])
@@map("watches")
}
/// In-app notification (issue #94, data-model.md §notifications). `payload`
/// carries the denormalized display data (page/pond names, actor names) so
/// the list renders without joins; permission is re-checked at generation
/// time, not at read time. `mailedAt` is the e-mail digest's bookkeeping
/// (issue #95) — independent of `readAt`.
model Notification {
id String @id @default(uuid())
userId String @map("user_id")
type String
payload Json
createdAt DateTime @default(now()) @map("created_at")
readAt DateTime? @map("read_at")
mailedAt DateTime? @map("mailed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, readAt, createdAt])
@@map("notifications")
}
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[]
labels Label[]
grants RoleGrant[]
conversionJobs ConversionJob[]
pondPlugins PondPlugin[]
@@index([ownerId])
@@map("ponds")
}
enum GrantSubjectType {
USER
AUTHENTICATED
PUBLIC
}
enum GrantRole {
POND_ADMIN
EDITOR
READER
}
enum GrantScopeType {
POND
LABEL
PAGE
}
enum GrantEffect {
ALLOW
DENY
}
/// The permission table (permissions.md, data-model.md §role_grants, issue #51).
/// One grant `(subject, role, scope, effect)` inside a pond. The shared
/// resolution algorithm (`@dorfteich/shared` permissions) decides access from
/// these; the API/collab enforce it. Structural rules: `POND_ADMIN` only at
/// `POND` scope with a `USER` subject (a CHECK constraint backs this up, added
/// in the migration); personal ponds allow only their owner as admin (enforced
/// in the service, needs the pond type). `subjectId` is set only for `USER`
/// subjects; `scopeId` is the label/page id for `LABEL`/`PAGE` scopes.
model RoleGrant {
id String @id @default(uuid())
pondId String @map("pond_id")
subjectType GrantSubjectType @map("subject_type")
subjectId String? @map("subject_id")
role GrantRole
scopeType GrantScopeType @map("scope_type")
scopeId String? @map("scope_id")
effect GrantEffect
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
@@unique([pondId, subjectType, subjectId, role, scopeType, scopeId])
@@index([pondId])
@@map("role_grants")
}
/// 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).
/// `parentId` nests pages into a tree (issue #106), mirroring the label
/// hierarchy (max 6 levels, enforced in the service; cycles rejected at write
/// time). Purely organizational: slugs stay flat and pond-unique, so moving a
/// page never changes its URL or breaks wikilinks. Trashed pages keep their
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
/// `SetNull` is only the FK backstop — purge promotes children explicitly.
model Page {
id String @id @default(uuid())
pondId String @map("pond_id")
parentId String? @map("parent_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])
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children Page[] @relation("PageHierarchy")
creator User @relation(fields: [createdBy], references: [id])
updates PageUpdate[]
contentCache PageContentCache?
attachments Attachment[]
versions PageVersion[]
pendingContributors PagePendingContributor[]
labels PageLabel[]
outgoingLinks PageLink[] @relation("outgoingLinks")
comments Comment[]
incomingLinks PageLink[] @relation("incomingLinks")
conversionJobs ConversionJob[]
@@unique([pondId, slug])
@@index([pondId])
@@index([parentId])
@@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?
/// Who created this version: the editor for `MANUAL`/`PRE_RESTORE`, null for
/// automatic snapshots (which have a contributor set instead of one author).
createdBy String? @map("created_by")
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")
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in
/// the migration (raw SQL — Prisma cannot index an Unsupported column).
searchVector Unsupported("tsvector")? @map("search_vector")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@map("page_content_cache")
}
/// A hierarchical label within a pond (data-model.md §labels, issue #43).
/// Labels organize pages and later scope permissions (M5): a grant on a label
/// applies to it and all its descendants, so the hierarchy must be sound now.
/// `parentId` builds the tree (max 6 levels, enforced in the service); cycles
/// are rejected at write time. Names are unique per (pond, parent) — the
/// unique index below covers nested labels; root-label uniqueness (parent_id
/// NULL, which Postgres treats as distinct) is enforced in the service under a
/// per-pond advisory lock. Deleting a label cascades to its whole subtree and
/// detaches page assignments; the service gates that behind `?force=true`.
model Label {
id String @id @default(uuid())
pondId String @map("pond_id")
parentId String? @map("parent_id")
name String
color String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id])
parent Label? @relation("LabelHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Label[] @relation("LabelHierarchy")
pages PageLabel[]
@@unique([pondId, parentId, name])
@@index([pondId])
@@map("labels")
}
/// Wikilink index maintained on every content change (data-model.md §page_links,
/// issue #47). One row per (source page, distinct target slug). `toPageId` is
/// the resolved target within the same pond, or null for a "phantom" link whose
/// target does not exist yet; creating/renaming a page to that slug resolves the
/// row. Backlinks query by `toPageId`. Collab (the content writer) maintains the
/// outgoing rows over raw SQL; it does not use Prisma.
model PageLink {
id String @id @default(uuid())
fromPageId String @map("from_page_id")
toPageId String? @map("to_page_id")
targetSlug String @map("target_slug")
fromPage Page @relation("outgoingLinks", fields: [fromPageId], references: [id], onDelete: Cascade)
toPage Page? @relation("incomingLinks", fields: [toPageId], references: [id], onDelete: SetNull)
@@unique([fromPageId, targetSlug])
@@index([toPageId])
@@index([targetSlug])
@@map("page_links")
}
/// Assignment of a label to a page (data-model.md §labels). Cascades on both
/// sides: purging a page or deleting a label removes the assignment.
model PageLabel {
pageId String @map("page_id")
labelId String @map("label_id")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@id([pageId, labelId])
@@index([labelId])
@@map("page_labels")
}
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
/// `<uploadsDir>/<pondId>/<id>` (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:<issuer>" 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")
}
enum ApiTokenScope {
READ
WRITE
}
/// Personal access tokens for the public API (issue #104). Only the SHA-256
/// hash of the secret is stored (auth-tokens pattern); a token acts AS its
/// user — the whole permission model applies — narrowed by `scope` and the
/// optional pond restriction. Revoking keeps the row so the settings UI can
/// show history; validation skips revoked/expired rows.
model ApiToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String @map("user_id")
name String
scope ApiTokenScope
/// Empty = every pond the user may access; else only these pond ids.
pondIds String[] @default([]) @map("pond_ids")
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")
lastUsedAt DateTime? @map("last_used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("api_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")
/// Wall-clock time of the last completed run (issue #86 admin panel).
lastDurationMs Int? @map("last_duration_ms")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("jobs")
}
enum ConversionJobStatus {
PENDING
RUNNING
SUCCEEDED
FAILED
}
/// One import/export conversion (ADR 0009, issue #62). Unlike the name-keyed
/// maintenance `Job` table, this is a per-request work queue: a row is
/// enqueued PENDING, a worker claims it (`FOR UPDATE SKIP LOCKED`, `lockedAt`
/// recovers a crashed run), calls the pandoc sidecar with a timeout, and
/// stores the output bytes or an `errorCode`. `input`/`result` are the raw
/// document bytes — kept small by the request size limit and pruned by a
/// later maintenance job (they are transient, not the durable copy an
/// Attachment is). The polling endpoint `GET /jobs/:id` is owner-scoped.
model ConversionJob {
id String @id @default(uuid())
ownerId String @map("owner_id")
/// Free-form label for the higher-level operation (e.g. 'export_docx',
/// 'import_docx') that later stories (#63/#65) set; #62 uses it only for logs.
kind String
sourceFormat String @map("source_format")
targetFormat String @map("target_format")
standalone Boolean @default(true)
input Bytes
status ConversionJobStatus @default(PENDING)
attempts Int @default(0)
result Bytes?
resultMimeType String? @map("result_mime_type")
errorCode String? @map("error_code")
lockedAt DateTime? @map("locked_at")
/// For a data-export job (#68): when its stored result stops being
/// downloadable and is purged (GDPR data minimization). Null for every
/// other job kind, whose result never expires.
expiresAt DateTime? @map("expires_at")
/// Kind-specific job options (issue #117): a vault import carries
/// `{parentPageId, labelIds, frontmatterMode}`. Null for other kinds.
options Json?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Import jobs (#63) carry the pond the document is imported into, the
/// original upload file name (title fallback), and the page they produced.
/// All null for a plain byte→byte conversion (export, #62/#65).
pondId String? @map("pond_id")
sourceName String? @map("source_name")
resultPageId String? @map("result_page_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@map("conversion_jobs")
}
/// Instance-level activation a Site Admin sets per installed plugin
/// (ADR 0008 lifecycle, issue #71). `optional` plugins are then toggled per
/// pond via PondPlugin; `required` plugins cannot be uninstalled.
enum PluginInstanceMode {
DISABLED
OPTIONAL
REQUIRED
}
/// An installed plugin package (ADR 0008, issue #71). The validated manifest is
/// stored verbatim so serving and admin views never re-read disk; the unpacked
/// bundle lives under `<PLUGINS_DIR>/<id>/<version>/`. Uninstall is a soft
/// delete (`removedAt` set, files removed) so existing plugin_block nodes can
/// still resolve the manifest fallback.
model Plugin {
id String @id
name String
version String
apiVersion String @map("api_version")
kind String
mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
manifest Json
installedAt DateTime @default(now()) @map("installed_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`.
removedAt DateTime? @map("removed_at")
pondPlugins PondPlugin[]
@@map("plugins")
}
/// Per-pond activation of an `optional` plugin, toggled by a Pond Admin
/// (issue #71 model; the toggle UI/endpoint is #72). A row's presence with
/// `enabled = true` means the plugin is active in that pond.
model PondPlugin {
pondId String @map("pond_id")
pluginId String @map("plugin_id")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
@@id([pondId, pluginId])
@@map("pond_plugins")
}