| name | prisma-database |
| description | Prisma schema design and database operations for IntelliFill. Use when modifying database schema, writing queries, or managing migrations. |
Prisma Database Development Skill
This skill provides comprehensive guidance for working with Prisma ORM and PostgreSQL in IntelliFill.
Table of Contents
- Schema Design
- Naming Conventions
- Relations
- Migrations
- Query Patterns
- Advanced Features
- Performance Optimization
- Testing with Prisma
Schema Design
IntelliFill uses PostgreSQL with Prisma ORM for type-safe database access.
Schema Location
quikadmin/prisma/
├── schema.prisma # Main schema definition
├── migrations/ # Migration history
│ └── YYYYMMDDHHMMSS_description/
│ └── migration.sql
└── seed.ts # Seed data script
Base Schema Template
// quikadmin/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
extensions = [pgvector(map: "vector")]
}
// Base model template with common fields
model User {
id String @id @default(uuid())
email String @unique
name String?
// Timestamps (always include)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Soft delete support
deletedAt DateTime? @map("deleted_at")
// Relations
documents Document[]
@@map("users") // Plural table name
}
model Document {
id String @id @default(uuid())
name String
description String?
// Foreign keys
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Metadata
status DocumentStatus @default(PENDING)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
// Indexes
@@index([userId])
@@index([status])
@@index([createdAt])
@@map("documents")
}
enum DocumentStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
Naming Conventions
IntelliFill follows strict naming conventions for consistency.
Model Naming
// Models: PascalCase, singular
model User { }
model Document { }
model TemplateMapping { }
// Table names: snake_case, plural (use @@map)
@@map("users")
@@map("documents")
@@map("template_mappings")
Field Naming
model Document {
// Fields: camelCase in Prisma
id String
userId String
createdAt DateTime
// Column names: snake_case in database (use @map)
userId String @map("user_id")
createdAt DateTime @map("created_at")
// Relations: camelCase, descriptive
user User
templateMappings TemplateMapping[]
}
Enum Naming
// Enums: PascalCase
enum DocumentStatus {
PENDING // Values: SCREAMING_SNAKE_CASE
PROCESSING
COMPLETED
FAILED
}
Relations
One-to-Many Relationship
// User has many Documents
model User {
id String @id @default(uuid())
documents Document[]
@@map("users")
}
model Document {
id String @id @default(uuid())
userId String @map("user_id")
// Relation field
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Index on foreign key
@@index([userId])
@@map("documents")
}
Many-to-Many Relationship
// Explicit join table (recommended)
model Document {
id String @id @default(uuid())
tags DocumentTag[]
@@map("documents")
}
model Tag {
id String @id @default(uuid())
name String @unique
documents DocumentTag[]
@@map("tags")
}
// Join table with additional fields
model DocumentTag {
documentId String @map("document_id")
tagId String @map("tag_id")
addedAt DateTime @default(now()) @map("added_at")
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([documentId, tagId])
@@index([tagId])
@@map("document_tags")
}
Self-Referencing Relationship
model Category {
id String @id @default(uuid())
name String
// Self-reference for hierarchy
parentId String? @map("parent_id")
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children Category[] @relation("CategoryHierarchy")
@@index([parentId])
@@map("categories")
}
One-to-One Relationship
model User {
id String @id @default(uuid())
profile Profile?
@@map("users")
}
model Profile {
id String @id @default(uuid())
bio String?
avatar String?
userId String @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
Migrations
Creating Migrations
# Create migration from schema changes
cd quikadmin
npx prisma migrate dev --name description_of_change
# Examples
npx prisma migrate dev --name add_document_status
npx prisma migrate dev --name add_user_profile
npx prisma migrate dev --name create_knowledge_base_tables
Migration Best Practices
- Descriptive names - Use clear, concise descriptions
- Small migrations - One logical change per migration
- Review SQL - Always check generated SQL before applying
- Backup data - Backup production data before migrations
- Test rollback - Ensure migrations can be rolled back
Manual Migration Editing
-- quikadmin/prisma/migrations/20240101000000_add_indexes/migration.sql
-- Add indexes for query performance
CREATE INDEX CONCURRENTLY IF NOT EXISTS "documents_user_id_status_idx"
ON "documents" ("user_id", "status");
-- Add full-text search index
CREATE INDEX IF NOT EXISTS "documents_name_search_idx"
ON "documents" USING gin(to_tsvector('english', name));
-- Add check constraint
ALTER TABLE "documents"
ADD CONSTRAINT "documents_name_length_check"
CHECK (length(name) >= 1 AND length(name) <= 255);
Migration Workflow
# 1. Modify schema.prisma
# 2. Create migration
npx prisma migrate dev --name my_change
# 3. Review generated SQL in migrations/*/migration.sql
# 4. Edit migration SQL if needed
# 5. Apply migration
npx prisma migrate deploy
# 6. Regenerate Prisma Client
npx prisma generate
Query Patterns
Basic CRUD Operations
import prisma from '@/utils/prisma';
// CREATE
const document = await prisma.document.create({
data: {
name: 'My Document',
userId: 'user-123',
status: 'PENDING',
},
});
// READ - Single
const document = await prisma.document.findUnique({
where: { id: 'doc-123' },
include: { user: true }, // Include relations
});
// READ - Multiple
const documents = await prisma.document.findMany({
where: {
userId: 'user-123',
status: 'COMPLETED',
},
orderBy: { createdAt: 'desc' },
take: 20,
skip: 0,
});
// UPDATE
const updated = await prisma.document.update({
where: { id: 'doc-123' },
data: { name: 'New Name' },
});
// DELETE
await prisma.document.delete({
where: { id: 'doc-123' },
});
Filtering and Search
// Multiple conditions (AND)
const documents = await prisma.document.findMany({
where: {
userId: 'user-123',
status: 'COMPLETED',
deletedAt: null,
},
});
// OR conditions
const documents = await prisma.document.findMany({
where: {
OR: [
{ status: 'COMPLETED' },
{ status: 'PROCESSING' },
],
},
});
// Complex nested conditions
const documents = await prisma.document.findMany({
where: {
AND: [
{ userId: 'user-123' },
{
OR: [
{ name: { contains: 'invoice', mode: 'insensitive' } },
{ description: { contains: 'invoice', mode: 'insensitive' } },
],
},
],
},
});
// String filters
const documents = await prisma.document.findMany({
where: {
name: {
contains: 'search', // LIKE '%search%'
startsWith: 'prefix', // LIKE 'prefix%'
endsWith: 'suffix', // LIKE '%suffix'
mode: 'insensitive', // Case-insensitive
},
},
});
// Date filters
const documents = await prisma.document.findMany({
where: {
createdAt: {
gte: new Date('2024-01-01'), // Greater than or equal
lte: new Date('2024-12-31'), // Less than or equal
},
},
});
// Array filters
const documents = await prisma.document.findMany({
where: {
tags: {
hasSome: ['urgent', 'important'], // Has any of these tags
hasEvery: ['approved', 'final'], // Has all of these tags
},
},
});
Pagination
// Offset-based pagination
async function getDocuments(page: number, limit: number) {
const skip = (page - 1) * limit;
const [documents, total] = await Promise.all([
prisma.document.findMany({
where: { userId: 'user-123' },
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.document.count({
where: { userId: 'user-123' },
}),
]);
return {
documents,
total,
page,
totalPages: Math.ceil(total / limit),
};
}
// Cursor-based pagination (better for large datasets)
async function getDocumentsCursor(cursor?: string, limit: number = 20) {
const documents = await prisma.document.findMany({
take: limit,
...(cursor && {
skip: 1, // Skip the cursor
cursor: { id: cursor },
}),
orderBy: { createdAt: 'desc' },
});
return {
documents,
nextCursor: documents[documents.length - 1]?.id,
};
}
Aggregations
// Count
const count = await prisma.document.count({
where: { status: 'COMPLETED' },
});
// Aggregate functions
const stats = await prisma.document.aggregate({
where: { userId: 'user-123' },
_count: { id: true },
_avg: { processingTime: true },
_sum: { pageCount: true },
_min: { createdAt: true },
_max: { createdAt: true },
});
// Group by
const statusCounts = await prisma.document.groupBy({
by: ['status'],
_count: { id: true },
where: { userId: 'user-123' },
});
Transactions
// Sequential operations in transaction
const result = await prisma.$transaction(async (tx) => {
// Create document
const document = await tx.document.create({
data: { name: 'New Doc', userId: 'user-123' },
});
// Create mapping
const mapping = await tx.templateMapping.create({
data: {
documentId: document.id,
templateId: 'template-123',
},
});
// Update user stats
await tx.user.update({
where: { id: 'user-123' },
data: { documentCount: { increment: 1 } },
});
return { document, mapping };
});
// Batch operations
await prisma.$transaction([
prisma.document.create({ data: {...} }),
prisma.document.update({ where: {...}, data: {...} }),
prisma.document.delete({ where: {...} }),
]);
Soft Delete Pattern
// Add deletedAt field to models
model Document {
deletedAt DateTime? @map("deleted_at")
}
// Soft delete
async function softDelete(id: string) {
return prisma.document.update({
where: { id },
data: { deletedAt: new Date() },
});
}
// Exclude soft-deleted in queries
const documents = await prisma.document.findMany({
where: {
userId: 'user-123',
deletedAt: null, // Only non-deleted
},
});
// Include soft-deleted
const allDocuments = await prisma.document.findMany({
where: { userId: 'user-123' },
// No deletedAt filter
});
// Restore soft-deleted
async function restore(id: string) {
return prisma.document.update({
where: { id },
data: { deletedAt: null },
});
}
Advanced Features
JSON Fields
model Document {
id String @id @default(uuid())
metadata Json? // JSON field
@@map("documents")
}
// Create with JSON
await prisma.document.create({
data: {
name: 'Doc',
metadata: {
category: 'invoice',
tags: ['urgent'],
customFields: { field1: 'value1' },
},
},
});
// Query JSON fields (PostgreSQL-specific)
const documents = await prisma.document.findMany({
where: {
metadata: {
path: ['category'],
equals: 'invoice',
},
},
});
Full-Text Search
model Document {
id String @id @default(uuid())
name String
description String?
// Add GIN index for full-text search in migration
@@map("documents")
}
-- In migration SQL
CREATE INDEX "documents_search_idx"
ON "documents"
USING gin(to_tsvector('english', name || ' ' || COALESCE(description, '')));
// Use raw SQL for full-text search
const documents = await prisma.$queryRaw`
SELECT * FROM documents
WHERE to_tsvector('english', name || ' ' || COALESCE(description, ''))
@@ plainto_tsquery('english', ${searchQuery})
ORDER BY ts_rank(
to_tsvector('english', name || ' ' || COALESCE(description, '')),
plainto_tsquery('english', ${searchQuery})
) DESC
LIMIT 20;
`;
pgvector for Embeddings
// Enable pgvector extension
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pgvector(map: "vector")]
}
model KnowledgeChunk {
id String @id @default(uuid())
content String
embedding Unsupported("vector(1536)")? // OpenAI embedding dimension
@@map("knowledge_chunks")
}
// Store embedding
await prisma.$executeRaw`
INSERT INTO knowledge_chunks (id, content, embedding)
VALUES (${id}, ${content}, ${embedding}::vector)
`;
// Similarity search
const results = await prisma.$queryRaw`
SELECT id, content, embedding <=> ${queryEmbedding}::vector AS distance
FROM knowledge_chunks
ORDER BY distance
LIMIT 10;
`;
Performance Optimization
Indexes
model Document {
id String @id @default(uuid())
userId String @map("user_id")
status DocumentStatus
name String
// Single-column indexes
@@index([userId])
@@index([status])
@@index([createdAt])
// Composite indexes
@@index([userId, status])
@@index([userId, createdAt])
// Unique constraint
@@unique([userId, name])
@@map("documents")
}
Select Specific Fields
// BAD: Fetches all fields
const documents = await prisma.document.findMany();
// GOOD: Select only needed fields
const documents = await prisma.document.findMany({
select: {
id: true,
name: true,
status: true,
},
});
Batch Operations
// BAD: N+1 queries
for (const id of documentIds) {
await prisma.document.update({
where: { id },
data: { status: 'COMPLETED' },
});
}
// GOOD: Single batch update
await prisma.document.updateMany({
where: { id: { in: documentIds } },
data: { status: 'COMPLETED' },
});
Connection Pooling
// quikadmin/src/utils/prisma.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
// Connection pool settings
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
// Graceful shutdown
async function shutdown() {
await prisma.$disconnect();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
export default prisma;
Testing with Prisma
Mock Prisma Client
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
export const prismaMock = mockDeep<PrismaClient>() as unknown as DeepMockProxy<PrismaClient>;
beforeEach(() => {
mockReset(prismaMock);
});
// In tests
prismaMock.document.findUnique.mockResolvedValue({
id: 'doc-1',
name: 'Test Doc',
});
Test Database Setup
// quikadmin/src/test/setup.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.TEST_DATABASE_URL,
},
},
});
beforeAll(async () => {
// Run migrations
await prisma.$executeRaw`
CREATE SCHEMA IF NOT EXISTS test;
`;
});
afterAll(async () => {
await prisma.$disconnect();
});
export default prisma;
Best Practices
- Use transactions - For multi-step operations
- Index foreign keys - Always add @@index on foreign keys
- Soft delete - Add deletedAt for audit trails
- Timestamps - Always include createdAt and updatedAt
- Cascade deletes - Use onDelete: Cascade for dependent data
- Select specific fields - Avoid fetching unnecessary data
- Batch operations - Use updateMany/createMany when possible
- Connection pooling - Configure appropriate pool size
- Migration naming - Use descriptive migration names
- Review generated SQL - Always check migration SQL