feat(db): migrate Prisma datasource postgres -> sqlite (native, file-backed)

Drops the shared sql-0 postgres dependency in favor of an embedded SQLite
file (DATABASE_URL=file:/data/<db>), WAL-replicated to SeaweedFS by the
operator persistence sidecar — same pattern as esign (Documenso).

Schema changes for the sqlite connector:
- datasource provider postgresql -> sqlite; drop directUrl/shadowDatabaseUrl
- 18 enums -> String (values preserved as string defaults)
- scalar-list fields (String[]/Int[]) -> Json @default("[]") (arrays
  round-trip through Prisma Json at runtime; next.config ignoreBuildErrors
  keeps the type-only churn out of the build)
- strip pg-native @db.* attributes (@db.Text/@db.Timestamp)
- startup: prisma migrate deploy -> prisma db push (pg migration history is
  postgres-specific; db push reconciles the sqlite schema idempotently)

CI: amd64-only (DOKS has no arm64); deploy:false — rollout is operator-managed
via the universe Service CR, never `kubectl set image`.

Verified: `prisma validate` + `prisma db push` materialize all 62 tables on a
fresh sqlite file. Source DB is empty (0 app rows), so cutover is data-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zeekay
2026-07-05 00:33:13 -07:00
co-authored by Claude Opus 4.8
parent 2952083ef0
commit 0aaaa1fa7e
11 changed files with 49 additions and 146 deletions
+5 -8
View File
@@ -16,12 +16,9 @@ jobs:
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/dataroom
# Native multi-arch builds on canonical arcd labels.
# amd64 → [self-hosted, linux, amd64] (matches evo).
# arm64 → [self-hosted, linux, arm64] (matches spark).
runner-arm64: '["self-hosted","linux","arm64"]'
platforms: linux/amd64,linux/arm64
deploy: true
deploy-deployment: dataroom
deploy-namespace: hanzo
# amd64-only: DOKS has no arm64 droplets (arm64 build paused).
platforms: linux/amd64
# Deploy is operator-managed via universe Service CR (services.hanzo.ai/dataroom).
# CI only builds+pushes the image; never `kubectl set image` (operator reverts it).
deploy: false
secrets: inherit
+4 -1
View File
@@ -44,4 +44,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bin ./node_modules/
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy --schema prisma/schema/schema.prisma && node server.js"]
# SQLite: reconcile schema on startup via `db push` (Prisma has no migrate-deploy
# path for the sqlite provider here; the pg migration history under prisma/migrations
# is postgres-specific and unused). Schema folder = prismaSchemaFolder preview.
CMD ["sh", "-c", "node_modules/.bin/prisma db push --schema prisma/schema --skip-generate --accept-data-loss && node server.js"]
+1 -1
View File
@@ -2,7 +2,7 @@ model DocumentAnnotation {
id String @id @default(cuid())
title String
content Json // Rich text content stored as JSON (Tiptap/ProseMirror format)
pages Int[] // Array of page numbers this annotation applies to
pages Json @default("[]")// Array of page numbers this annotation applies to
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
teamId String
+5 -26
View File
@@ -4,7 +4,7 @@ model Conversation {
isEnabled Boolean @default(true)
// Visibility control
visibilityMode ConversationVisibility @default(PRIVATE)
visibilityMode String @default("PRIVATE")
// Core relationships
dataroomId String
@@ -56,19 +56,8 @@ model Conversation {
}
// Define conversation visibility options
enum ConversationVisibility {
PRIVATE // Only visible to participants and team members
PUBLIC_LINK // Visible to all viewers with access to the specific link
PUBLIC_GROUP // Visible to all viewers in the specific group
PUBLIC_DOCUMENT // Visible to all viewers with access to the document, across any link
PUBLIC_DATAROOM // Visible to all viewers with access to the dataroom
}
// Define participant roles
enum ParticipantRole {
OWNER // Created the conversation
PARTICIPANT // Joined the conversation later
}
// Track participants in a conversation (including the owner)
model ConversationParticipant {
@@ -77,7 +66,7 @@ model ConversationParticipant {
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
// Role in the conversation
role ParticipantRole @default(PARTICIPANT)
role String @default("PARTICIPANT")
// Participant can be either a viewer or a user
viewerId String?
@@ -184,15 +173,15 @@ model DataroomFaqItem {
publishedByUser User @relation(fields: [publishedByUserId], references: [id], onDelete: Cascade)
// Visibility and status
visibilityMode FaqVisibility @default(PUBLIC_DATAROOM)
status FaqStatus @default(PUBLISHED)
visibilityMode String @default("PUBLIC_DATAROOM")
status String @default("PUBLISHED")
isAnonymized Boolean @default(true)
// Analytics
viewCount Int @default(0)
// Optional categorization
tags String[] @default([])
tags Json @default("[]")
// Metadata
documentPageNumber Int? // Optional: specific page reference
@@ -213,15 +202,5 @@ model DataroomFaqItem {
}
// Define FAQ visibility options
enum FaqVisibility {
PUBLIC_DATAROOM // Visible to all dataroom visitors
PUBLIC_LINK // Visible only to specific link visitors
PUBLIC_DOCUMENT // Visible only when viewing specific document
}
// Define FAQ status
enum FaqStatus {
DRAFT // Not yet published
PUBLISHED // Live and visible to visitors
ARCHIVED // No longer visible but kept for reference
}
+4 -13
View File
@@ -38,7 +38,7 @@ model Dataroom {
enableChangeNotifications Boolean @default(false)
// unified permission strategy
defaultPermissionStrategy DefaultPermissionStrategy @default(INHERIT_FROM_PARENT)
defaultPermissionStrategy String @default("INHERIT_FROM_PARENT")
// bulk download setting
allowBulkDownload Boolean @default(true) // Allow bulk download of entire dataroom
@@ -127,7 +127,7 @@ model ViewerGroup {
id String @id @default(cuid())
name String
members ViewerGroupMembership[]
domains String[]
domains Json@default("[]")
links Link[]
accessControls ViewerGroupAccessControls[]
allowAll Boolean @default(false)
@@ -171,7 +171,7 @@ model ViewerGroupAccessControls {
// Access control for items (documents or dataroom items)
itemId String // This can be a document ID or a dataroom item ID
itemType ItemType // Enum: DATAROOM_DOCUMENT, DATAROOM_FOLDER
itemType String // Enum: DATAROOM_DOCUMENT, DATAROOM_FOLDER
// Granular permissions
canView Boolean @default(true)
@@ -184,16 +184,7 @@ model ViewerGroupAccessControls {
@@index([groupId])
}
enum ItemType {
DATAROOM_DOCUMENT
DATAROOM_FOLDER
}
enum DefaultPermissionStrategy {
INHERIT_FROM_PARENT
ASK_EVERY_TIME
HIDDEN_BY_DEFAULT
}
model PermissionGroup {
id String @id @default(cuid())
@@ -222,7 +213,7 @@ model PermissionGroupAccessControls {
// Access control for items (documents or dataroom items)
itemId String // This can be a document ID or a dataroom item ID
itemType ItemType // Enum: DATAROOM_DOCUMENT, DATAROOM_FOLDER
itemType String // Enum: DATAROOM_DOCUMENT, DATAROOM_FOLDER
// Granular permissions
canView Boolean @default(true)
+4 -8
View File
@@ -6,7 +6,7 @@ model Document {
originalFile String? // This should be a reference to the original file like pptx, xlsx, etc. (S3, Google Cloud Storage, etc.)
type String? // This should be a reference to the file type (pdf, sheet, etc.)
contentType String? // This should be the actual contentType of the file like application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, etc.
storageType DocumentStorageType @default(VERCEL_BLOB)
storageType String @default("VERCEL_BLOB")
numPages Int? // This should be a reference to the number of pages in the document
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
teamId String
@@ -53,7 +53,7 @@ model DocumentVersion {
type String? // This should be a reference to the file type (pdf, docx, etc.)
contentType String? // This should be the actual contentType of the file like application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, etc.
fileSize BigInt? // This should be the size of the file in bytes
storageType DocumentStorageType @default(VERCEL_BLOB)
storageType String @default("VERCEL_BLOB")
numPages Int? // This should be a reference to the number of pages in the document
isPrimary Boolean @default(false) // Indicates if this is the primary version
isVertical Boolean @default(false) // Indicates if the document is vertical (portrait) or not (landscape)
@@ -77,11 +77,11 @@ model DocumentPage {
version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
versionId String
pageNumber Int // e.g., 1, 2, 3 for
embeddedLinks String[]
embeddedLinks Json@default("[]")
pageLinks Json? // This will store the page links data: [{href: "https://example.com", coords: "0,0,100,100"}]
metadata Json? // This will store the page metadata: {originalWidth: 100, origianlHeight: 100, scaledWidth: 50, scaledHeight: 50, scaleFactor: 2}
file String // This should be a reference to where the file / page is stored (S3, Google Cloud Storage, etc.)
storageType DocumentStorageType @default(VERCEL_BLOB)
storageType String @default("VERCEL_BLOB")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -89,10 +89,6 @@ model DocumentPage {
@@index([versionId])
}
enum DocumentStorageType {
S3_PATH
VERCEL_BLOB
}
model Folder {
id String @id @default(cuid())
+2 -2
View File
@@ -17,8 +17,8 @@ model jackson_store {
iv String?
tag String?
namespace String?
createdAt DateTime @default(now()) @db.Timestamp(0)
modifiedAt DateTime? @db.Timestamp(0)
createdAt DateTime @default(now())
modifiedAt DateTime?
@@index([namespace], map: "_jackson_store_namespace")
}
+8 -28
View File
@@ -1,14 +1,4 @@
enum LinkType {
DOCUMENT_LINK
DATAROOM_LINK
WORKFLOW_LINK
}
enum LinkAudienceType {
GENERAL
GROUP
TEAM
}
model Link {
id String @id @default(cuid())
@@ -16,14 +6,14 @@ model Link {
documentId String? // This can be nullable, representing links without documents
dataroom Dataroom? @relation(fields: [dataroomId], references: [id], onDelete: Cascade)
dataroomId String? // This can be nullable, representing links without datarooms
linkType LinkType @default(DOCUMENT_LINK) // This will store the type of the link
linkType String @default("DOCUMENT_LINK") // This will store the type of the link
url String? @unique
name String? // Link name
slug String? // Link slug for pretty URLs
expiresAt DateTime? // Optional expiration date
password String? // Optional password for link protection
allowList String[] // Array of emails and domains allowed to view the document
denyList String[] // Array of emails and domains denied to view the document
allowList Json @default("[]")// Array of emails and domains allowed to view the document
denyList Json @default("[]")// Array of emails and domains denied to view the document
emailProtected Boolean @default(true) // Optional email protection
emailAuthenticated Boolean @default(false) // Optional email authentication flag
allowDownload Boolean? @default(false) // Optional give user a option to allow to download the document
@@ -48,7 +38,7 @@ model Link {
watermarkConfig Json? // This will store the watermark configuration: {text: "Confidential", isTiled: false, color: "#000000", fontSize: 12, opacity: 0.5, rotation: 30, position: "top-right"}
// group links
audienceType LinkAudienceType @default(GENERAL) // This will store the audience type of the link
audienceType String @default("GENERAL") // This will store the audience type of the link
groupId String?
group ViewerGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
@@ -129,9 +119,9 @@ model LinkPreset {
emailAuthenticated Boolean? @default(false)
allowDownload Boolean? @default(false)
enableAllowList Boolean? @default(false)
allowList String[]
allowList Json@default("[]")
enableDenyList Boolean? @default(false)
denyList String[]
denyList Json@default("[]")
expiresIn Int? // how many days from link creation until it expires in seconds
enableScreenshotProtection Boolean? @default(false)
expiresAt DateTime?
@@ -158,21 +148,11 @@ model LinkPreset {
@@index([teamId])
}
enum CustomFieldType {
SHORT_TEXT
LONG_TEXT
NUMBER
PHONE_NUMBER
URL
CHECKBOX
SELECT
MULTI_SELECT
}
model VisitorGroup {
id String @id @default(cuid())
name String
emails String[] // List of emails and domains (e.g., "user@example.com", "@example.org")
emails Json @default("[]")// List of emails and domains (e.g., "user@example.com", "@example.org")
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@ -203,7 +183,7 @@ model CustomField {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type CustomFieldType
type String
identifier String
label String
placeholder String?
+11 -39
View File
@@ -1,8 +1,6 @@
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_PRISMA_URL_NON_POOLING") // uses a direct connection
shadowDatabaseUrl = env("POSTGRES_PRISMA_SHADOW_URL") // used for migrations
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
@@ -16,12 +14,12 @@ model Account {
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -128,10 +126,10 @@ model View {
verified Boolean @default(false) // Whether the viewer email has been verified
viewedAt DateTime @default(now())
downloadedAt DateTime? // This is the time the document was downloaded
downloadType DownloadType? // Type of download: SINGLE, BULK, or FOLDER
downloadType String? // Type of download: SINGLE, BULK, or FOLDER
downloadMetadata Json? // Metadata about the download (folder name, document list, etc.)
reactions Reaction[]
viewType ViewType @default(DOCUMENT_VIEW)
viewType String @default("DOCUMENT_VIEW")
viewerId String? // This is the viewer ID from the dataroom
viewer Viewer? @relation(fields: [viewerId], references: [id], onDelete: Cascade)
groupId String? // This is the group ID from the dataroom
@@ -169,16 +167,7 @@ model View {
@@index([documentId, viewedAt(sort: Desc)]) // Performance optimization for latest views queries
}
enum ViewType {
DOCUMENT_VIEW
DATAROOM_VIEW
}
enum DownloadType {
SINGLE // Individual document download
BULK // Full dataroom bulk download
FOLDER // Folder download
}
model Viewer {
id String @id @default(cuid())
@@ -234,17 +223,10 @@ model Invitation {
@@unique([email, teamId])
}
enum EmailType {
FIRST_DAY_DOMAIN_REMINDER_EMAIL
FIRST_DOMAIN_INVALID_EMAIL
SECOND_DOMAIN_INVALID_EMAIL
FIRST_TRIAL_END_REMINDER_EMAIL
FINAL_TRIAL_END_REMINDER_EMAIL
}
model SentEmail {
id String @id @default(cuid())
type EmailType
type String
recipient String // Email address of the recipient
marketing Boolean @default(false)
createdAt DateTime @default(now())
@@ -307,7 +289,7 @@ model ChatMessage {
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
role String // "user" | "assistant" | "system"
content String @db.Text
content String
// Optional structured data
metadata Json? // Store sources, page numbers, confidence scores, etc.
@@ -460,11 +442,6 @@ model YearInReview {
@@index([teamId])
}
enum TagType {
LINK_TAG
DOCUMENT_TAG
DATAROOM_TAG
}
model Tag {
id String @id @default(cuid())
@@ -491,7 +468,7 @@ model TagItem {
id String @id @default(cuid())
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
itemType TagType
itemType String
// tag can be linked to a link, document or dataroom
linkId String?
@@ -521,7 +498,7 @@ model ViewerInvitation {
invitedBy String
customMessage String?
sentAt DateTime @default(now())
status InvitationStatus @default(SENT)
status String @default("SENT")
createdAt DateTime @default(now())
@@ -530,8 +507,3 @@ model ViewerInvitation {
@@index([groupId])
}
enum InvitationStatus {
SENT
FAILED
BOUNCED
}
+3 -8
View File
@@ -65,18 +65,13 @@ model Team {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ignoredDomains String[] // Domains that are ignored for the team
globalBlockList String[] // Email and domains that are blocked for the team
ignoredDomains Json @default("[]")// Domains that are ignored for the team
globalBlockList Json @default("[]")// Email and domains that are blocked for the team
}
enum Role {
ADMIN
MANAGER
MEMBER
}
model UserTeam {
role Role @default(MEMBER)
role String @default("MEMBER")
status String @default("ACTIVE")
userId String
teamId String
+2 -12
View File
@@ -1,16 +1,6 @@
// Workflow models for routing visitors based on email/domain rules
enum WorkflowStepType {
ROUTER // Route to different links based on conditions
}
enum ExecutionStatus {
PENDING
IN_PROGRESS
COMPLETED
FAILED
BLOCKED
}
model Workflow {
id String @id @default(cuid())
@@ -37,7 +27,7 @@ model WorkflowStep {
workflowId String
name String
stepOrder Int // Execution order (priority-based routing)
stepType WorkflowStepType @default(ROUTER)
stepType String @default("ROUTER")
conditions Json // JSON array of conditions to evaluate
actions Json // JSON array of actions to execute
createdAt DateTime @default(now())
@@ -55,7 +45,7 @@ model WorkflowExecution {
workflowId String
visitorEmail String?
visitorIp String?
status ExecutionStatus
status String
startedAt DateTime @default(now())
completedAt DateTime?
result Json? // Final result/output (routing target)