All checks were successful
CD / Build and push images (push) Successful in 4m9s
CI / Lint, typecheck, test (push) Successful in 2m50s
CI / Auth e2e pack (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s
Import/export conversions run asynchronously against an internal pandoc-server sidecar with limits and graceful failure (ADR 0009). This is the plumbing; the import (#63) and export (#65) features enqueue jobs onto it. Sidecar & config: - pandoc/core:3.6 in HTTP server mode added to the Compose stack, internal network only, with a wget healthcheck on /version; the api depends on it healthy and reaches it via the new PANDOC_URL env (default http://pandoc:3030). - readyz gains a warning-level `converter` check: an unreachable sidecar degrades import/export but never flips the instance to unready (new `warn` status on ReadinessCheck). Conversion flow (apps/api/src/import-export/): - ConversionJob table (per-request work queue, distinct from the name-keyed maintenance Job table): owner, formats, input/result bytes, status, attempts, lockedAt. Migration + owner cascade. - PandocConverter (abstract) + PandocServerConverter: POST / with {text,from,to,standalone}; binary input formats (docx/odt/…) are base64-encoded in `text`; 60 s AbortController timeout; input/output size caps. Failures map to distinct localized codes — converter_unavailable / converter_timeout (retryable) and conversion_failed (final). - ConversionWorker: claims one job at a time with `FOR UPDATE SKIP LOCKED` (safe against overlapping sweeps and a second process), recovers a stale RUNNING lock, retries transient failures up to 3 attempts then fails. A 2 s sweep plus wake-on-enqueue means a queued job survives an API restart. - ConversionJobService.enqueue (size-limited) + owner-scoped GET /jobs/:id (poll) and GET /jobs/:id/result (stream the output); a foreign/unknown id is 404. ConversionJobView in @dorfteich/shared. Tests: - conversion-job.e2e.db.test.ts (fake converter injected via a new createTestApp override hook): enqueue→convert→poll→result; foreign/unknown job 404; a persisted PENDING job picked up by a fresh app's worker (restart survival); sidecar-down fails after 3 retries while the API stays healthy. - pandoc.converter.test.ts: success, non-200→conversion_failed, refused→ converter_unavailable, and a delay-injecting server→converter_timeout. - Verified locally against a real pandoc/core:3.6 container: markdown→html, markdown→docx (valid PK/OOXML bytes), and a docx→markdown round-trip. Local: typecheck, lint, i18n:check, build all green; api 193 tests (9 new), shared 121, web 50. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
559 lines
20 KiB
Plaintext
559 lines
20 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")
|
|
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[]
|
|
conversionJobs ConversionJob[]
|
|
|
|
@@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[]
|
|
labels Label[]
|
|
grants RoleGrant[]
|
|
|
|
@@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).
|
|
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[]
|
|
labels PageLabel[]
|
|
outgoingLinks PageLink[] @relation("outgoingLinks")
|
|
incomingLinks PageLink[] @relation("incomingLinks")
|
|
|
|
@@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?
|
|
/// 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")
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
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")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([status, createdAt])
|
|
@@map("conversion_jobs")
|
|
}
|