All checks were successful
CD / Build and push images (push) Successful in 3m19s
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Uploading a Word/OpenOffice document to POST /ponds/:id/import enqueues a conversion job (the #62 queue) that produces a new page in the pond; the client polls GET /jobs/:id for the created resultPageId. Pipeline (ImportService, ADR 0009): pandoc-server is stateless and hands back a document's media no other way, so we convert in two passes — docx/odt → html with embed-resources inlines every image as a data: URI, then html → gfm produces clean structural Markdown with those data URIs still inline. Embedded images are stored as pond files (with quota accounting) and their references rewritten to file ids on the Markdown text before parsing (the editor parser only admits png/jpeg/gif/webp data URIs); an image whose bytes the upload pipeline rejects is dropped, not fatal. The title comes from a leading top-level heading (removed from the body) else the file name. The page is created from the resulting Yjs state. The shared conversion worker routes import-kind jobs to the pipeline via a token (breaking a module cycle), so import inherits the queue's locking, retry, and restart-survival. Media stored during a failed attempt is rolled back; a pond that runs out of storage fails the job with quota_exceeded. - schema: ConversionJob gains pond_id / source_name / result_page_id (migration 20260710041215_import_pages_conversion); ConversionJobView gains resultPageId. - PagesService.createWithState / yjs-content docToState build a page from a prepared document; FilesService.linkAttachmentsToPage links import media. - fixtures/import/: representative .docx/.odt corpus (headings, lists, nested lists, tables, images, links, bold/italic) with expected-Markdown snapshots; scripts/gen-import-fixtures.mjs regenerates them. - tests: import.service.db.test.ts drives the full pipeline with a fake converter (CI); import.fixtures.test.ts runs the real two-pass conversion over the corpus and a 50-page timing check against a reachable sidecar. - i18n: import_unsupported_format (de+en). Limits documented (25 MiB input, 60 s per pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
570 lines
21 KiB
Plaintext
570 lines
21 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[]
|
|
conversionJobs ConversionJob[]
|
|
|
|
@@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")
|
|
conversionJobs ConversionJob[]
|
|
|
|
@@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")
|
|
|
|
/// 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")
|
|
}
|