opencode-agent-kit
Version:
Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)
1,224 lines (1,004 loc) • 36.3 kB
Markdown
---
name: postgres-prisma-optimization
description: >-
PostgreSQL + Prisma ORM optimization: schema design patterns (relations, enums, composite keys, indexes),
migration workflow (dev/deploy/resolve), N+1 query prevention (include, select, raw queries),
PostgreSQL indexing strategy (B-tree, GIN, partial, composite), query performance (EXPLAIN ANALYZE,
connection pooling with PgBouncer), data modeling patterns (soft delete, auditing, versioning, polymorphic),
migration safety (reversible migrations, data migration), Prisma transaction patterns (interactive, nested write).
version: 1.0.0
author: opencode-agent-kit
agent: database-specialist
---
# Postgres Prisma Optimization Skill
Skill khusus untuk agent `` — Database Specialist. Berisi pattern, strategi, workflow, dan best practices untuk
optimasi PostgreSQL dengan Prisma ORM: schema design, indexing, query performance, migration safety, dan data modeling.
---
## 1. Prisma Schema Design Patterns
### 1.1 Relations — One-to-One
```prisma
model User {
id String .Uuid
email String
profile Profile?
}
model Profile {
id String .Uuid
fullName String
bio String?
avatarUrl String?
userId String .Uuid
user User
@
}
```
**Key points:**
- Field `userId` harus `` agar one-to-one enforced di database.
- Gunakan `` untuk snake_case di database, camelCase di Prisma.
- Selalu tentukan `onDelete` (Cascade, Restrict, SetNull, NoAction).
- `@` untuk nama tabel snake_case di PostgreSQL.
### 1.2 Relations — One-to-Many
```prisma
model User {
id String .Uuid
posts Post[]
}
model Post {
id String .Uuid
title String
content String?
authorId String .Uuid
author User
@
@
}
```
**Key points:**
- Foreign key di sisi "many" (Post).
- Index foreign key secara eksplisit dengan `@` untuk JOIN performance.
- Naming convention: `authorId` → ``.
### 1.3 Relations — Many-to-Many (Junction Table)
```prisma
model Post {
id String .Uuid
title String
tags TagOnPost[]
}
model Tag {
id String .Uuid
name String
posts TagOnPost[]
}
model TagOnPost {
postId String .Uuid
tagId String .Uuid
post Post
tag Tag
@
@
@
}
```
**Key points:**
- Junction table dengan `@` — composite primary key.
- Index pada foreign key kedua (`tagId`) untuk reverse lookup.
- Nama tabel junction: plural + `_on_` atau `_` connector.
### 1.4 Enums
```prisma
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
```
Gunakan Prisma enum (bukan native PostgreSQL enum) agar kompatibel dengan migration engine.
Untuk PostgreSQL native enum, gunakan `` attribute di Prisma 5+.
### 1.5 Composite Keys
```prisma
model AuditLog {
id String .Uuid
entityId String .Uuid
entityType String
createdAt DateTime .Timestamptz(3)
// Composite unique constraint
@
// Composite index for common query pattern
@
@
}
```
### 1.6 Custom Indexes
```prisma
model Order {
id String .Uuid
status String?
userId String .Uuid
totalAmount Decimal .Decimal(12, 2)
createdAt DateTime .Timestamptz(3)
// Composite index with order
@
// Partial index via raw SQL (not expressible in Prisma schema)
@
@
}
```
**Catatan:** Partial index `where` clause didukung Prisma sejak versi 5.10+. Untuk versi lebih lama, buat via migration manual.
---
## 2. Migration Workflow
### 2.1 Development — `prisma migrate dev`
```bash
# Membuat migration baru berdasarkan perubahan schema
npx prisma migrate dev --name add_user_profile
# Reset database dan apply semua migration (destructive!)
npx prisma migrate reset
# Apply migration tanpa prompt
npx prisma migrate dev --name add_user_profile --skip-generate
# Hanya generate Prisma Client tanpa migration
npx prisma generate
```
**Best practice:**
1. Ubah `schema.prisma` terlebih dahulu.
2. Jalankan `prisma migrate dev --name <description>`.
3. Review file SQL migration yang dihasilkan di `prisma/migrations/<timestamp>_<name>/migration.sql`.
4. Jika ada data transformation, tambahkan raw SQL di migration file sebelum commit.
5. Generate Prisma Client: `prisma generate`.
6. Test query baru di aplikasi.
### 2.2 Production — `prisma migrate deploy`
```bash
# Apply semua migration yang belum dijalankan (read-only operation)
npx prisma migrate deploy
# Verifikasi status migration
npx prisma migrate status
```
**Best practice:**
- `migrate deploy` adalah operasi idempotent — aman dijalankan multiple times.
- Jangan pernah jalankan `migrate dev` di production.
- Selalu backup database sebelum deploy migration.
- Monitor log migration untuk error.
### 2.3 Resolving Migration Conflicts
Ketika migration conflict terjadi (baseline berbeda):
```bash
# 1. Resolve secara manual
# Edit migration.sql yang conflict untuk menyelaraskan state
npx prisma migrate resolve --applied "20250101000000_add_user_profile"
# 2. Baseline ulang (untuk development)
npx prisma migrate dev --create-only # Buat migration kosong
npx prisma migrate --help # Lihat opsi resolve
# 3. Jika sudah terlanjur apply di production
# Gunakan --rolled-back untuk menandai migration yang gagal
npx prisma migrate resolve --rolled-back "20250101000000_bad_migration"
```
**Strategi conflict resolution:**
1. Identifikasi migration mana yang berbeda baseline.
2. Gunakan `migrate resolve --applied` untuk menandai migration sebagai applied (tanpa menjalankannya).
3. Gunakan `migrate resolve --rolled-back` untuk menandai migration sebagai rolled back.
4. Buat migration korektif baru.
### 2.4 Migration SQL Review Checklist
Setiap migration harus di-review:
```sql
-- ❌ BURUK — Menghapus kolom dalam 1 step (breaking change)
ALTER TABLE "posts" DROP COLUMN "old_column";
-- ✅ BAIK — Additive change (backward compatible)
ALTER TABLE "posts" ADD COLUMN "new_column" TEXT;
-- ✅ BAIK — Buat index terpisah
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_posts_author_id" ON "posts" ("author_id");
-- ❌ BURUK — Default value mengunci tabel
ALTER TABLE "posts" ADD COLUMN "views" INTEGER NOT NULL DEFAULT 0;
-- ✅ BAIK — Default tanpa lock (PostgreSQL 11+)
ALTER TABLE "posts" ADD COLUMN "views" INTEGER;
ALTER TABLE "posts" ALTER COLUMN "views" SET DEFAULT 0;
```
### 2.5 Migration Rollback Strategy
Prisma tidak memiliki `migrate down` built-in. Strategi:
1. **Siapkan rollback SQL** di dokumentasi migration.
2. **Simpan rollback script** sebagai file terpisah: `prisma/migrations/<name>/rollback.sql`.
3. **Gunakan pendekatan reversible:** Setiap migration additive harus memiliki migration "undo" terpisah.
4. **Data migration** harus memiliki script rollback data juga.
```sql
-- Contoh rollback.sql untuk migration tertentu
-- Step 1: Kembalikan data jika ada
UPDATE posts SET old_column = new_column WHERE new_column IS NOT NULL;
-- Step 2: Drop kolom baru
ALTER TABLE "posts" DROP COLUMN "new_column";
```
---
## 3. N+1 Query Prevention
### 3.1 The N+1 Problem
```typescript
// ❌ N+1 — Setiap iterasi query user memicu 1 query tambahan
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
// 1 query untuk users + N query untuk posts = N+1
}
```
### 3.2 Eager Loading dengan `include`
```typescript
// ✅ Eager loading — 1 query dengan JOIN
const usersWithPosts = await prisma.user.findMany({
include: {
posts: true, // One-to-many
profile: true, // One-to-one
tags: { include: { tag: true } }, // Nested include (many-to-many via junction)
},
});
// Filtered include
const filteredPosts = await prisma.user.findMany({
include: {
posts: {
where: { status: 'PUBLISHED' },
orderBy: { createdAt: 'desc' },
take: 10, // Batasi jumlah related records
},
},
});
```
### 3.3 Projection dengan `select`
```typescript
// ✅ Select — Hanya fetching field yang diperlukan
const usersPartial = await prisma.user.findMany({
select: {
id: true,
email: true,
posts: {
select: {
id: true,
title: true,
},
where: { status: 'PUBLISHED' },
},
},
});
```
**Perbandingan `include` vs `select`:**
| Aspect | `include` | `select` |
|---------------|------------------------------------|--------------------------------------|
| Penggunaan | Seluruh field dari relation | Field tertentu saja |
| Performance | Lebih berat (semua field) | Lebih ringan (subset field) |
| Use case | Detail view, full entity | List view, API response partial |
| Nested query | Mendukung | Mendukung |
### 3.4 Batch Loading dengan Prisma
```typescript
// ✅ Batch loading — Hindari loop query
const userIds = ['id-1', 'id-2', 'id-3'];
const usersInBatch = await prisma.user.findMany({
where: { id: { in: userIds } },
include: { posts: true },
});
// Group by user (manual)
const userMap = new Map(usersInBatch.map(u => [u.id, u]));
```
### 3.5 Raw Query untuk Complex Aggregation
```typescript
// ✅ Raw query — Ketika Prisma query tidak cukup optimal
const result = await prisma.$queryRaw<Array<{
id: string;
email: string;
post_count: bigint;
}>>`
SELECT
u.id,
u.email,
COUNT(p.id)::BIGINT as post_count
FROM "users" u
LEFT JOIN "posts" p ON p.author_id = u.id
WHERE u.id = ANY(${userIds}::UUID[])
GROUP BY u.id, u.email
ORDER BY post_count DESC
`;
// ✅ Gunakan prisma.$queryRawUnsafe untuk dynamic query (hati-hati SQL injection)
```
### 3.6 Monitoring Query Count
```typescript
// ✅ Log query count di development
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error'],
});
// Atau middleware untuk count query
let queryCount = 0;
prisma.$use(async (params, next) => {
queryCount++;
console.log(`Query #${queryCount}: ${params.model}.${params.action}`);
return next(params);
});
```
---
## 4. PostgreSQL Indexing Strategy
### 4.1 Index Type Reference
| Index Type | Use Case | Contoh PostgreSQL DDL |
|------------|----------|----------------------|
| **B-tree** (default) | Equality & range, ORDER BY, <, <=, >, >=, BETWEEN, LIKE (prefix) | `CREATE INDEX ...` |
| **GIN** | Full-text search, array contains, JSONB queries, trigram/pattern matching | `CREATE INDEX ... USING GIN` |
| **GiST** | Full-text search, geometric data, range types | `CREATE INDEX ... USING GiST` |
| **BRIN** | Large tables with naturally ordered data (log, time-series) | `CREATE INDEX ... USING BRIN` |
| **Hash** | Simple equality (jarang digunakan, B-tree lebih baik) | `CREATE INDEX ... USING HASH` |
### 4.2 B-tree Index
```prisma
// Prisma — B-tree (default)
model User {
email String // Unique index = B-tree
status String?
@
}
```
```sql
-- SQL equivalent
CREATE INDEX IF NOT EXISTS "idx_user_status" ON "users" ("status");
```
**Best practice B-tree:**
- Komposit index untuk multi-column query: `@`.
- ORDER BY field di akhir composite index.
- Unique constraint otomatis membuat B-tree index.
### 4.3 GIN Index
GIN untuk full-text search dan JSONB:
```sql
-- Full-text search GIN
CREATE INDEX IF NOT EXISTS "idx_posts_content_gin"
ON "posts" USING GIN (to_tsvector('english', "content"));
-- JSONB GIN index
CREATE INDEX IF NOT EXISTS "idx_products_metadata_gin"
ON "products" USING GIN ("metadata");
-- Array contains GIN
CREATE INDEX IF NOT EXISTS "idx_tags_array_gin"
ON "posts" USING GIN ("tags");
```
Di Prisma, GIN index harus dibuat via migration manual karena Prisma belum support `@` dengan `using`:
```prisma
// schema.prisma — placeholder
model Post {
id String .Uuid
title String
content String
}
```
```sql
-- migration.sql — tambahkan secara manual
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_posts_content_search"
ON "posts" USING GIN (to_tsvector('english', "content"));
```
### 4.4 Partial Index
Partial index untuk subset data yang sering di-query:
```prisma
// Prisma 5.10+ support partial index di schema
model Order {
status String?
@
}
```
```sql
-- SQL equivalent (semua versi)
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_order_active"
ON "orders" ("status")
WHERE status = 'ACTIVE';
```
**Use case partial index:**
- Filter `WHERE status = 'ACTIVE'` pada tabel dengan mayoritas data inactive.
- Filter `WHERE deleted_at IS NULL` untuk soft delete query.
- Filter `WHERE is_verified = true` pada user verification.
### 4.5 Composite Index
```prisma
model Transaction {
userId String .Uuid
status String?
createdAt DateTime .Timestamptz(3)
amount Decimal .Decimal(12, 2)
// Query: WHERE user_id = ? AND status = ? ORDER BY created_at DESC
@
}
```
**Composite index rules:**
- **Leftmost prefix rule:** Index `[A, B, C]` bisa digunakan untuk `A`, `A+B`, `A+B+C`, tapi **tidak** untuk `B` saja.
- **Order matters:** Letakkan kolom dengan high cardinality (banyak unique values) di depan.
- **Order BY:** Kolom ORDER BY harus di posisi paling kanan dari index.
- **Range condition:** Kolom dengan range operator (`<`, `>`, `BETWEEN`) harus di posisi paling kanan.
### 4.6 Indexing Decision Matrix
| Query Pattern | Index Type | Example |
|---------------|------------|---------|
| `WHERE id = ?` | B-tree (PK) | `` |
| `WHERE email = ?` | B-tree unique | `` |
| `WHERE user_id = ?` | B-tree | `@` |
| `WHERE user_id = ? AND status = ?` | B-tree composite | `@` |
| `WHERE content ILIKE '%keyword%'` | GIN (trigram) | `USING GIN (content gin_trgm_ops)` |
| `WHERE metadata @> '{"key":"val"}'` | GIN JSONB | `USING GIN (metadata)` |
| `WHERE status = 'ACTIVE'` | Partial B-tree | `WHERE status = 'ACTIVE'` |
| `WHERE created_at BETWEEN ? AND ?` | B-tree (range) | `@` |
| `WHERE deleted_at IS NULL` | Partial B-tree | `WHERE deleted_at IS NULL` |
| Full-text search | GIN | `USING GIN (to_tsvector(...))` |
### 4.7 Anti-Patterns Indexing
- ❌ **Over-indexing:** Setiap index tambahan memperlambat INSERT/UPDATE/DELETE.
- ❌ **Index kolom boolean atau low cardinality** — selectivity terlalu rendah.
- ❌ **Duplicate index** — misal: `@` + `@` on same column.
- ❌ **Index pada kolom yang jarang di-query** — storage waste.
- ❌ **Composite index dengan urutan kolom salah** (low cardinality di depan).
---
## 5. Query Performance
### 5.1 EXPLAIN ANALYZE
```sql
-- Analisis query plan
EXPLAIN ANALYZE
SELECT u.id, u.email, COUNT(p.id) as post_count
FROM "users" u
LEFT JOIN "posts" p ON p.author_id = u.id
WHERE u.status = 'ACTIVE'
GROUP BY u.id, u.email
ORDER BY post_count DESC
LIMIT 20;
```
**Apa yang harus diperhatikan dari output EXPLAIN ANALYZE:**
| Indicator | Arti | Tindakan |
|-----------|------|----------|
| `Seq Scan` | Sequential scan pada large table | Tambah index |
| `Est. rows` vs `Actual rows` | Estimasi cardinality salah | Update `ANALYZE` atau adjust stats |
| `Sort Method: external merge` | Sort overflow ke disk | Tambah index sorting |
| `Nested Loop` dengan banyak row | Join strategi salah | Tambah index atau ubah join |
| `Rows Removed by Filter` tinggi | Filter tidak efisien | Index partial atau composite |
| `Shared Hit Blocks` rendah | Data tidak di cache | Tingkatkan `shared_buffers` |
### 5.2 Prisma Query Profiling
```typescript
// Profiling dengan middleware
const prisma = new PrismaClient({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
],
});
prisma.$on('query', (e) => {
console.log('Query:', e.query);
console.log('Duration:', e.duration, 'ms');
console.log('Params:', e.params);
});
// Query timing
async function queryWithTiming<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
const result = await fn();
const duration = performance.now() - start;
console.log(`[${label}] ${duration.toFixed(2)}ms`);
return result;
}
```
### 5.3 Slow Query Detection
```sql
-- Cari slow queries dari pg_stat_statements
SELECT
query,
calls,
total_exec_time / calls AS avg_time_ms,
rows,
shared_blks_hit,
shared_blks_read
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_%'
ORDER BY total_exec_time DESC
LIMIT 20;
```
**Aktifkan pg_stat_statements:**
```sql
-- Di postgresql.conf atau ALTER SYSTEM
shared_preload_libraries = 'pg_stat_statements';
pg_stat_statements.track = all;
-- Atau di session
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
```
### 5.4 Connection Pooling dengan PgBouncer
```typescript
// Prisma + PgBouncer configuration
const prisma = new PrismaClient({
datasources: {
db: {
// Gunakan PgBouncer transaction mode port (6432)
url: process.env.DATABASE_URL, // postgresql://user:pass@host:6432/db?pgbouncer=true
},
},
});
```
**PgBouncer URL:**
```
# Transaction mode (recommended untuk Prisma)
postgresql://user:pass@pgbouncer-host:6432/dbname?pgbouncer=true
```
**PgBouncer Key Config:**
```ini
[databases]
dbname = host=postgres-host port=5432 dbname=dbname
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 500
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 5.0
server_idle_timeout = 600
query_timeout = 30
```
**Prisma Connection Pool Best Practice:**
| Environment | Pool Size | Connection Limit |
|-------------|-----------|------------------|
| Development | 5-10 | 10 |
| Staging | 10-20 | 20 |
| Production (low traffic) | 20-50 | 50 |
| Production (high traffic) | 50-100+ | 100+ |
```typescript
// Prisma connection management
const prisma = new PrismaClient({
// connection_limit via datasource URL
datasources: {
db: {
url: process.env.DATABASE_URL + '&connection_limit=20',
},
},
});
// Graceful shutdown
process.on('SIGTERM', async () => {
await prisma.$disconnect();
});
```
### 5.5 Query Performance Checklist
- [ ] Gunakan `EXPLAIN ANALYZE` untuk memverifikasi index usage.
- [ ] Hindari N+1 dengan `include` atau `select`.
- [ ] Batasi result dengan `take` + pagination.
- [ ] Gunakan `select` untuk hanya mengambil field yang diperlukan.
- [ ] Index foreign key untuk JOIN performance.
- [ ] Monitor slow query dengan `pg_stat_statements`.
- [ ] Gunakan connection pooling di production.
- [ ] Set `connection_limit` sesuai dengan kapasitas database.
- [ ] Gunakan `.Uuid` untuk UUID fields (binary storage, lebih cepat dari text UUID).
---
## 6. Data Modeling Patterns
### 6.1 Soft Delete
```prisma
model User {
id String .Uuid
email String
deletedAt DateTime? .Timestamptz(3)
@
@
}
```
**Query pattern:**
```typescript
// Semua query harus include filter deleted_at IS NULL
const activeUsers = await prisma.user.findMany({
where: { deletedAt: null },
});
// Soft delete
await prisma.user.update({
where: { id: userId },
data: { deletedAt: new Date() },
});
// Hard delete (permanent)
await prisma.user.delete({ where: { id: userId } });
```
**Partial index untuk performance:**
```sql
-- Buat partial index yang mencakup hanya non-deleted records
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_user_active"
ON "users" ("email")
WHERE deleted_at IS NULL;
```
### 6.2 Auditing
```prisma
model AuditLog {
id String .Uuid
entityId String .Uuid
entityType String
action String // CREATE, UPDATE, DELETE
oldValue Json? .JsonB
newValue Json? .JsonB
changes Json? .JsonB // Only changed fields
actorId String? .Uuid
actorEmail String?
ipAddress String?
createdAt DateTime .Timestamptz(3)
@
@
@
@
}
```
**Implementasi di Prisma middleware:**
```typescript
prisma.$use(async (params, next) => {
const result = await next(params);
if (['create', 'update', 'delete'].includes(params.action)) {
await prisma.auditLog.create({
data: {
entityId: result?.id,
entityType: params.model!,
action: params.action.toUpperCase(),
actorId: currentUserId,
changes: params.args.data,
},
});
}
return result;
});
```
### 6.3 Versioning (Optimistic Locking)
```prisma
model Document {
id String .Uuid
title String
content String?
version Int
updatedAt DateTime .Timestamptz(3)
@
}
```
**Optimistic locking pattern:**
```typescript
// Update with version check
async function updateDocument(id: string, data: Partial<Document>, expectedVersion: number) {
const result = await prisma.document.updateMany({
where: {
id,
version: expectedVersion, // Only update if version matches
},
data: {
...data,
version: { increment: 1 },
},
});
if (result.count === 0) {
throw new Error('Document was modified by another user. Please refresh and try again.');
}
return prisma.document.findUnique({ where: { id } });
}
```
### 6.4 Polymorphic Relations
Prisma tidak mendukung polymorphic relation secara native. Gunakan pattern berikut:
**Option 1: Junction per entity type**
```prisma
model Comment {
id String .Uuid
content String
authorId String .Uuid
author User
// Polymorphic: commentable
postId String? .Uuid
documentId String? .Uuid
post Post?
document Document?
@
@
@
}
```
**Option 2: Entity type + Entity ID (manual union)**
```prisma
model Comment {
id String .Uuid
content String
entityType String // 'POST', 'DOCUMENT', 'IMAGE'
entityId String // UUID of the parent entity
authorId String .Uuid
author User
@
@
}
```
**Option 3: Template table with JSONB**
```prisma
model Activity {
id String .Uuid
action String
targetType String
targetId String .Uuid
metadata Json? .JsonB // Flexible, store any target-specific data
@
@
@
}
```
### 6.5 Data Modeling Checklist
- [ ] Normalization sampai 3NF kecuali justified denormalization.
- [ ] Soft delete pattern untuk data yang perlu retention.
- [ ] Audit trail untuk data critical (financial, user data, settings).
- [ ] Versioning untuk concurrent update protection.
- [ ] Polymorphic pattern untuk entity yang memiliki relasi ke multiple types.
- [ ] JSONB untuk data semi-structured (metadata, settings, config).
- [ ] Index pada foreign key dan query pattern utama.
- [ ] `` untuk tracking modification time.
- [ ] Default timestamps (``) untuk created_at.
---
## 7. Migration Safety
### 7.1 Safe Migration Principles
| Principle | Description | Example |
|-----------|-------------|---------|
| **Additive first** | Tambah kolom/tabel baru sebelum drop yang lama | `ADD COLUMN` sebelum `DROP COLUMN` |
| **Expand-Contract** | Phase 1: add new column & backfill → Phase 2: deploy code → Phase 3: drop old column | 3-step migration |
| **Backward compatible** | Schema baru tidak boleh break query yang sudah ada | Default value, nullable new column |
| **Data integrity** | Setiap data transformation harus preserve integrity | Test rollback |
| **Performance aware** | Migration tidak boleh menyebabkan downtime | `CREATE INDEX CONCURRENTLY` |
### 7.2 Expand-Contract Pattern
```sql
-- Phase 1: Add new column (additive)
ALTER TABLE "users" ADD COLUMN "display_name" TEXT;
UPDATE "users" SET "display_name" = "username"; -- Backfill
-- Deploy code that writes to both username and display_name
-- Phase 2 (later migration): Drop old column
ALTER TABLE "users" DROP COLUMN "username";
-- Deploy code that uses only display_name
```
### 7.3 Data Migration
```sql
-- Batch data migration dengan chunking
DO $$
DECLARE
batch_size CONSTANT INT := 1000;
offset_val INT := 0;
affected_rows INT;
BEGIN
LOOP
UPDATE "posts"
SET "search_vector" = to_tsvector('english', "title" || ' ' || COALESCE("content", ''))
WHERE "id" IN (
SELECT "id" FROM "posts"
WHERE "search_vector" IS NULL
ORDER BY "id"
LIMIT batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS affected_rows = ROW_COUNT;
COMMIT; -- Commit setiap batch
EXIT WHEN affected_rows = 0;
offset_val := offset_val + batch_size;
RAISE NOTICE 'Processed % rows', offset_val;
END LOOP;
END;
$$;
```
**Prisma + raw untuk data migration:**
```typescript
async function migrateData(batchSize = 1000) {
let processed = 0;
while (true) {
const result = await prisma.$executeRaw`
UPDATE "posts"
SET "search_vector" = to_tsvector('english', "title" || ' ' || COALESCE("content", ''))
WHERE "id" IN (
SELECT "id" FROM "posts"
WHERE "search_vector" IS NULL
ORDER BY "id"
LIMIT ${batchSize}
FOR UPDATE SKIP LOCKED
)
`;
if (result === 0) break;
processed += result;
console.log(`Processed ${processed} rows`);
}
}
```
### 7.4 Reversible Migrations
Setiap migration harus memiliki rollback script:
```sql
-- Forward migration (migration.sql)
ALTER TABLE "users" ADD COLUMN "display_name" TEXT;
UPDATE "users" SET "display_name" = "username" WHERE "display_name" IS NULL;
-- Rollback script (rollback.sql — simpan di folder migration)
ALTER TABLE "users" DROP COLUMN "display_name";
```
**Rollback template:**
```markdown
## Migration: add_display_name
### Forward
- ADD COLUMN display_name TEXT
- Backfill display_name from username
### Rollback
- DROP COLUMN display_name
### Data Loss
- Ya: Semua data display_name akan hilang
### Downtime
- Tidak (additive)
```
### 7.5 CREATE INDEX CONCURRENTLY
```sql
-- Selalu gunakan CONCURRENTLY untuk production
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_orders_user_id"
ON "orders" ("user_id");
-- Jangan lakukan ini di production (lock table):
-- CREATE INDEX "idx_orders_user_id" ON "orders" ("user_id");
```
**Catatan:**
- `CONCURRENTLY` tidak bisa dijalankan dalam transaction block.
- Pisahkan `CREATE INDEX CONCURRENTLY` di statement terpisah (di luar transaction).
- Gagal menjalankan CONCURRENTLY akan meninggalkan "invalid" index — monitor dan drop jika perlu.
### 7.6 Migration Safety Checklist
- [ ] Migration additive? (ADD COLUMN, CREATE TABLE, CREATE INDEX)
- [ ] Backward compatible? (Old query masih bekerja)
- [ ] Rollback script sudah disiapkan?
- [ ] Data transformation sudah di-test?
- [ ] Data loss implications sudah di-dokumentasi?
- [ ] Production large table? Gunakan CONCURRENTLY.
- [ ] Migration bisa di-run dalam waktu singkat? (batch jika perlu)
- [ ] Sudah di-test di staging environment?
- [ ] Monitor rencana (alerting, metric) sudah siap?
- [ ] Backup database sudah dilakukan?
---
## 8. Prisma Transaction Patterns
### 8.1 Interactive Transaction
```typescript
// ✅ Interactive transaction — multi-step atomic operation
const result = await prisma.$transaction(async (tx) => {
// Step 1: Create order
const order = await tx.order.create({
data: {
userId: userId,
totalAmount: total,
status: 'PENDING',
},
});
// Step 2: Update inventory (reduce stock)
for (const item of items) {
const inventory = await tx.inventory.update({
where: { productId: item.productId },
data: { stock: { decrement: item.quantity } },
});
if (inventory.stock < 0) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
}
// Step 3: Create order items
await tx.orderItem.createMany({
data: items.map(item => ({
orderId: order.id,
productId: item.productId,
quantity: item.quantity,
price: item.price,
})),
});
return order;
}, {
timeout: 10000, // Max 10 detik
maxWait: 5000, // Max 5 detik untuk mendapatkan connection
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, // Serializable isolation
});
```
**Isolation levels:**
```typescript
enum TransactionIsolationLevel {
ReadUncommitted = 'ReadUncommitted',
ReadCommitted = 'ReadCommitted', // Default PostgreSQL
RepeatableRead = 'RepeatableRead',
Serializable = 'Serializable', // Strictest, use for financial transactions
}
```
### 8.2 Nested Write (Batch Write)
```typescript
// ✅ Nested write — create related records in 1 query
const user = await prisma.user.create({
data: {
email: 'user@example.com',
profile: {
create: {
fullName: 'John Doe',
bio: 'Software Engineer',
},
},
posts: {
create: [
{ title: 'Post 1', content: 'Content 1' },
{ title: 'Post 2', content: 'Content 2' },
],
},
},
include: {
profile: true,
posts: true,
},
});
```
### 8.3 Batch Operations
```typescript
// ✅ Bulk create (atomic, 1 query)
await prisma.post.createMany({
data: [
{ title: 'Post A', authorId: userId },
{ title: 'Post B', authorId: userId },
],
skipDuplicates: true, // Skip if unique constraint violation
});
// ✅ Bulk update
await prisma.post.updateMany({
where: { authorId: userId },
data: { status: 'PUBLISHED' },
});
// ✅ Bulk delete
await prisma.post.deleteMany({
where: { authorId: userId, status: 'ARCHIVED' },
});
```
### 8.4 Retry Logic (Deadlock / Serialization)
```typescript
// ✅ Retry transaction on serialization failure
async function withRetry<T>(
fn: (tx: Prisma.TransactionClient) => Promise<T>,
maxRetries = 3,
isolationLevel = Prisma.TransactionIsolationLevel.Serializable,
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await prisma.$transaction(fn, {
isolationLevel,
timeout: 10000,
});
} catch (error) {
// P2034: Transaction failed due to write conflict
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') {
if (attempt === maxRetries) throw error;
console.warn(`Transaction conflict, retrying (${attempt}/${maxRetries})...`);
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}
```
### 8.5 Transaction Best Practices
| Pattern | Use Case | Notes |
|---------|----------|-------|
| `$transaction([...])` | Multiple independent queries | Batch of reads, batch of writes independent |
| `$transaction(async (tx) => {})` | Related multi-step operation | Interactive, reads depend on writes |
| `createMany` | Bulk insert | 1 query, faster than loop |
| `updateMany` | Bulk update | 1 query, atomic |
| Retry logic | Serializable isolation | Handle P2034 error |
| Connection pool | Production | Set `maxWait` realistic |
### 8.6 Pitfalls to Avoid
```typescript
// ❌ BURUK — Sequential create inside transaction (N+1 writes)
await prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.orderItem.create({ data: item }); // N queries!
}
});
// ✅ BAIK — Batch create
await prisma.$transaction(async (tx) => {
await tx.orderItem.createMany({ data: items }); // 1 query!
});
// ❌ BURUK — No timeout
await prisma.$transaction(async (tx) => {
// Long operation — bisa hold connection forever
});
// ✅ BAIK — With timeout
await prisma.$transaction(async (tx) => {
// Safe operation with timeout
}, { timeout: 5000 });
```
---
## 9. Error Reference (Prisma Known Error Codes)
| Code | Error | Cause | Fix |
|------|-------|-------|-----|
| P2002 | Unique constraint violation | Duplicate value | Handle with try-catch, return 409 |
| P2025 | Record not found | `update`/`delete` on non-existent record | Check `result === null` or use `updateMany` |
| P2014 | Relation violation | Foreign key constraint failed | Ensure referenced record exists |
| P2003 | Foreign key constraint | Invalid reference ID | Validate ID before write |
| P2034 | Transaction conflict | Write conflict on serializable isolation | Implement retry logic |
| P1001 | Connection refused | Database not accessible | Check DB host/port/credentials |
| P1000 | Authentication failed | Wrong credentials | Verify DATABASE_URL |
| P1017 | Server closed connection | Pool exhaustion or idle timeout | Increase pool size, set idle timeout |
---
## 10. Quick Reference — Command Cheatsheet
```bash
# Prisma CLI
npx prisma migrate dev --name <description> # Dev migration
npx prisma migrate deploy # Prod migration
npx prisma migrate status # Check status
npx prisma migrate resolve # Resolve conflict
npx prisma generate # Generate client
npx prisma db push # Push schema directly (dev only)
npx prisma db pull # Introspect DB to schema
npx prisma studio # GUI data browser
# Database
psql -U user -d dbname -c "EXPLAIN ANALYZE <query>"
CREATE INDEX CONCURRENTLY IF NOT EXISTS ...
pgbouncer -d /etc/pgbouncer/pgbouncer.ini
# Monitoring
SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC;
SELECT * FROM pg_stat_activity;
SELECT * FROM pg_locks WHERE NOT granted;
```