@raftlabs/raftstack 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,465 @@
1
+ ---
2
+ name: database
3
+ description: Use when designing database schemas, writing Drizzle ORM code, creating tables, choosing indexes, or when queries are slow and indexes might be missing
4
+ ---
5
+
6
+ # Database Development
7
+
8
+ ## Overview
9
+
10
+ Every foreign key needs an index. Every frequent filter needs an index. Use Drizzle ORM patterns correctly and think about query patterns before defining tables.
11
+
12
+ ## When to Use
13
+
14
+ - Designing new database schemas
15
+ - Adding tables with Drizzle ORM
16
+ - Optimizing slow queries
17
+ - Choosing between UUID vs serial IDs
18
+ - Deciding index strategy
19
+
20
+ ## The Iron Rules
21
+
22
+ ### 1. Every Foreign Key Gets an Index
23
+
24
+ Foreign keys don't automatically create indexes in PostgreSQL. You MUST add them.
25
+
26
+ ```typescript
27
+ // ❌ BAD: FK without index
28
+ export const orders = pgTable("orders", {
29
+ id: uuid("id").defaultRandom().primaryKey(),
30
+ userId: uuid("user_id").references(() => users.id), // No index!
31
+ });
32
+
33
+ // ✅ GOOD: FK with explicit index
34
+ export const orders = pgTable("orders", {
35
+ id: uuid("id").defaultRandom().primaryKey(),
36
+ userId: uuid("user_id").notNull().references(() => users.id),
37
+ }, (table) => ({
38
+ userIdIdx: index("orders_user_id_idx").on(table.userId),
39
+ }));
40
+ ```
41
+
42
+ ### 2. Index Frequently Filtered Columns
43
+
44
+ If you WHERE on it, index it.
45
+
46
+ ```typescript
47
+ // ✅ GOOD: Index on status and timestamps
48
+ export const orders = pgTable("orders", {
49
+ id: uuid("id").defaultRandom().primaryKey(),
50
+ userId: uuid("user_id").notNull().references(() => users.id),
51
+ status: orderStatusEnum("status").default("pending").notNull(),
52
+ createdAt: timestamp("created_at").defaultNow().notNull(),
53
+ }, (table) => ({
54
+ userIdIdx: index("orders_user_id_idx").on(table.userId),
55
+ statusIdx: index("orders_status_idx").on(table.status),
56
+ createdAtIdx: index("orders_created_at_idx").on(table.createdAt),
57
+ // Composite for common query: "user's orders by status"
58
+ userStatusIdx: index("orders_user_status_idx").on(table.userId, table.status),
59
+ }));
60
+ ```
61
+
62
+ ### 3. Use Partial Indexes for Filtered Subsets
63
+
64
+ When you frequently query a subset, use a partial index.
65
+
66
+ ```typescript
67
+ // ✅ GOOD: Partial index for active products only
68
+ export const products = pgTable("products", {
69
+ id: uuid("id").defaultRandom().primaryKey(),
70
+ name: varchar("name", { length: 255 }).notNull(),
71
+ isActive: boolean("is_active").default(true).notNull(),
72
+ isFeatured: boolean("is_featured").default(false).notNull(),
73
+ }, (table) => ({
74
+ // Only index active products - smaller, faster
75
+ activeIdx: index("products_active_idx")
76
+ .on(table.isActive)
77
+ .where(sql`${table.isActive} = true`),
78
+ // Featured products (active AND featured)
79
+ featuredIdx: index("products_featured_idx")
80
+ .on(table.isFeatured)
81
+ .where(sql`${table.isActive} = true AND ${table.isFeatured} = true`),
82
+ }));
83
+ ```
84
+
85
+ ### 4. JSONB with GIN Index for JSON Columns
86
+
87
+ Never store JSON as text. Use JSONB with GIN index for queries.
88
+
89
+ ```typescript
90
+ import { jsonb } from "drizzle-orm/pg-core";
91
+
92
+ // ❌ BAD: JSON as text
93
+ imageUrls: text("image_urls"), // Can't query efficiently
94
+
95
+ // ✅ GOOD: JSONB with GIN index
96
+ export const products = pgTable("products", {
97
+ id: uuid("id").defaultRandom().primaryKey(),
98
+ metadata: jsonb("metadata").$type<ProductMetadata>(),
99
+ tags: jsonb("tags").$type<string[]>().default([]),
100
+ }, (table) => ({
101
+ tagsGinIdx: index("products_tags_gin_idx")
102
+ .using("gin", table.tags),
103
+ }));
104
+ ```
105
+
106
+ ### 5. Auto-Update Timestamps
107
+
108
+ Use `$onUpdate` for automatic timestamp updates.
109
+
110
+ ```typescript
111
+ // ✅ GOOD: Auto-updating timestamps
112
+ export const users = pgTable("users", {
113
+ id: uuid("id").defaultRandom().primaryKey(),
114
+ email: varchar("email", { length: 255 }).notNull().unique(),
115
+ createdAt: timestamp("created_at").defaultNow().notNull(),
116
+ updatedAt: timestamp("updated_at")
117
+ .defaultNow()
118
+ .notNull()
119
+ .$onUpdate(() => new Date()),
120
+ });
121
+ ```
122
+
123
+ ### 6. Primary Key Strategy
124
+
125
+ | Strategy | Use When | Pros | Cons |
126
+ |----------|----------|------|------|
127
+ | **UUID** | Distributed systems, public IDs | No collisions, hide growth | Larger, slower indexes |
128
+ | **Serial/bigserial** | Single DB, internal IDs | Compact, fast | Exposes growth, sequence issues |
129
+ | **ULID/NanoID** | Need sortability + randomness | Sortable, compact | Custom generation |
130
+
131
+ ```typescript
132
+ // UUID (default recommendation)
133
+ id: uuid("id").defaultRandom().primaryKey(),
134
+
135
+ // Serial for internal/analytics tables
136
+ id: serial("id").primaryKey(),
137
+
138
+ // ULID for sortable + random
139
+ id: varchar("id", { length: 26 }).primaryKey().$default(() => ulid()),
140
+ ```
141
+
142
+ ## Migration Strategy
143
+
144
+ ### Generate vs Push
145
+
146
+ | Command | Use When | Output |
147
+ |---------|----------|--------|
148
+ | `drizzle-kit generate` | Production, team environments | SQL migration files + snapshots |
149
+ | `drizzle-kit migrate` | Apply versioned migrations | Executes generated SQL files |
150
+ | `drizzle-kit push` | Local dev, rapid prototyping | Direct schema push, no files |
151
+
152
+ ```bash
153
+ # Production workflow: versioned migrations
154
+ npx drizzle-kit generate # Creates migrations/0001_*.sql
155
+ npx drizzle-kit migrate # Applies to database
156
+
157
+ # Dev workflow: quick iteration
158
+ npx drizzle-kit push # Direct push, no migration files
159
+ ```
160
+
161
+ **Always use `generate` + `migrate` for:**
162
+ - Production databases
163
+ - Team collaboration
164
+ - Audit trail requirements
165
+ - Rollback capability
166
+
167
+ ### Migration Files Structure
168
+
169
+ ```
170
+ migrations/
171
+ ├── 0001_init.sql # First migration
172
+ ├── 0002_add_orders_table.sql # Sequential
173
+ ├── meta/
174
+ │ ├── 0001_snapshot.json # Schema snapshots
175
+ │ └── 0002_snapshot.json
176
+ ```
177
+
178
+ ## Relations & Nested Queries
179
+
180
+ ### Define Relations in Schema
181
+
182
+ ```typescript
183
+ import { relations } from 'drizzle-orm';
184
+
185
+ export const users = pgTable('users', {
186
+ id: uuid('id').defaultRandom().primaryKey(),
187
+ name: varchar('name', { length: 255 }).notNull(),
188
+ });
189
+
190
+ export const posts = pgTable('posts', {
191
+ id: uuid('id').defaultRandom().primaryKey(),
192
+ userId: uuid('user_id').notNull().references(() => users.id),
193
+ title: varchar('title', { length: 255 }).notNull(),
194
+ }, (table) => ({
195
+ userIdIdx: index('posts_user_id_idx').on(table.userId),
196
+ }));
197
+
198
+ // Define relations
199
+ export const usersRelations = relations(users, ({ many }) => ({
200
+ posts: many(posts),
201
+ }));
202
+
203
+ export const postsRelations = relations(posts, ({ one }) => ({
204
+ user: one(users, { fields: [posts.userId], references: [users.id] }),
205
+ }));
206
+ ```
207
+
208
+ ### Query with Relations (Avoids N+1)
209
+
210
+ ```typescript
211
+ // ✅ GOOD: Single query with join
212
+ const usersWithPosts = await db.query.users.findMany({
213
+ with: {
214
+ posts: true, // Eagerly loaded
215
+ },
216
+ });
217
+
218
+ // ✅ GOOD: Nested relations
219
+ const usersWithPostsAndComments = await db.query.users.findMany({
220
+ with: {
221
+ posts: {
222
+ with: {
223
+ comments: true,
224
+ },
225
+ },
226
+ },
227
+ });
228
+
229
+ // ✅ GOOD: Filtered nested queries
230
+ const activeUsersWithRecentPosts = await db.query.users.findMany({
231
+ where: eq(users.status, 'active'),
232
+ with: {
233
+ posts: {
234
+ where: gte(posts.createdAt, new Date('2024-01-01')),
235
+ limit: 10,
236
+ },
237
+ },
238
+ });
239
+
240
+ // ❌ BAD: N+1 query problem
241
+ const users = await db.select().from(users);
242
+ for (const user of users) {
243
+ user.posts = await db.select().from(posts).where(eq(posts.userId, user.id));
244
+ }
245
+ ```
246
+
247
+ ## Prepared Statements
248
+
249
+ Use prepared statements for repeated queries to cache query plans.
250
+
251
+ ```typescript
252
+ // ✅ GOOD: Prepared statement with placeholders
253
+ const getUserWithPosts = db.query.users.findFirst({
254
+ where: eq(users.id, sql.placeholder('userId')),
255
+ with: {
256
+ posts: {
257
+ where: eq(posts.status, sql.placeholder('status')),
258
+ },
259
+ },
260
+ }).prepare('get_user_posts'); // Name required for PostgreSQL
261
+
262
+ // Execute multiple times
263
+ const user1 = await getUserWithPosts.execute({ userId: '123', status: 'published' });
264
+ const user2 = await getUserWithPosts.execute({ userId: '456', status: 'published' });
265
+
266
+ // ✅ GOOD: Simple prepared query
267
+ const getOrdersByUser = db
268
+ .select()
269
+ .from(orders)
270
+ .where(eq(orders.userId, sql.placeholder('userId')))
271
+ .prepare('orders_by_user');
272
+
273
+ const orders = await getOrdersByUser.execute({ userId: '123' });
274
+ ```
275
+
276
+ **When to use:**
277
+ - High-traffic endpoints
278
+ - Repeated queries with different params
279
+ - Query optimization critical paths
280
+
281
+ ## Serverless Connection Pooling
282
+
283
+ ### Neon Serverless
284
+
285
+ ```typescript
286
+ import { Pool } from '@neondatabase/serverless';
287
+ import { drizzle } from 'drizzle-orm/neon-serverless';
288
+
289
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
290
+ const db = drizzle({ client: pool });
291
+
292
+ // For Node.js with WebSockets
293
+ import ws from 'ws';
294
+ import { neonConfig } from '@neondatabase/serverless';
295
+
296
+ neonConfig.webSocketConstructor = ws;
297
+ ```
298
+
299
+ ### Vercel Postgres
300
+
301
+ ```typescript
302
+ import { drizzle } from 'drizzle-orm/vercel-postgres';
303
+
304
+ const db = drizzle(); // Automatically uses POSTGRES_URL from env
305
+ ```
306
+
307
+ ### Edge Runtime (HTTP-based)
308
+
309
+ ```typescript
310
+ import { neon } from '@neondatabase/serverless';
311
+ import { drizzle } from 'drizzle-orm/neon-http';
312
+
313
+ const sql = neon(process.env.DATABASE_URL);
314
+ const db = drizzle({ client: sql });
315
+ ```
316
+
317
+ **Key considerations:**
318
+ - HTTP drivers for edge: no TCP connections
319
+ - Pool for serverless: handles connection lifecycle
320
+ - WebSocket for Node: better performance
321
+
322
+ ## Quick Reference: Index Types
323
+
324
+ | Index Type | Use For | Example |
325
+ |------------|---------|---------|
326
+ | **B-tree** (default) | Equality, range, ORDER BY | `WHERE status = 'active'`, `ORDER BY created_at` |
327
+ | **GIN** | Arrays, JSONB, full-text | `WHERE tags @> '["sale"]'` |
328
+ | **BRIN** | Large sorted tables (logs) | Time-series data ordered by timestamp |
329
+ | **Hash** | Only exact equality | `WHERE id = 'abc'` (rare use) |
330
+
331
+ ```typescript
332
+ // GIN for JSONB
333
+ index("idx").using("gin", table.tags)
334
+
335
+ // BRIN for time-series
336
+ index("idx").using("brin", table.createdAt)
337
+ ```
338
+
339
+ ## Schema Checklist
340
+
341
+ Before committing a schema:
342
+ - [ ] Every foreign key has an index
343
+ - [ ] Frequently filtered columns are indexed
344
+ - [ ] Composite indexes match common query patterns
345
+ - [ ] JSON columns use JSONB, not text
346
+ - [ ] `updatedAt` has `$onUpdate`
347
+ - [ ] Considered partial indexes for subsets
348
+ - [ ] Timestamps indexed if used in WHERE/ORDER BY
349
+ - [ ] Relations defined for nested queries
350
+ - [ ] Migration generated (not just pushed)
351
+
352
+ ## Testing Strategy
353
+
354
+ | What to Test | How |
355
+ |--------------|-----|
356
+ | Migrations | Apply to test DB, verify schema |
357
+ | Query performance | Use EXPLAIN ANALYZE |
358
+ | Index usage | Check query plans for index scans |
359
+ | Relations | Verify single query, not N+1 |
360
+ | Prepared statements | Benchmark vs regular queries |
361
+
362
+ ```typescript
363
+ // Test migration applies cleanly
364
+ import { migrate } from 'drizzle-orm/neon-serverless/migrator';
365
+
366
+ test('migrations apply successfully', async () => {
367
+ await migrate(db, { migrationsFolder: './migrations' });
368
+ // Verify tables exist
369
+ const result = await db.execute(sql`SELECT tablename FROM pg_tables WHERE schemaname='public'`);
370
+ expect(result.rows).toContainEqual({ tablename: 'users' });
371
+ });
372
+
373
+ // Test index usage with EXPLAIN
374
+ test('query uses index for user lookup', async () => {
375
+ const plan = await db.execute(sql`
376
+ EXPLAIN (FORMAT JSON)
377
+ SELECT * FROM orders WHERE user_id = '123'
378
+ `);
379
+ const planText = JSON.stringify(plan.rows);
380
+ expect(planText).toContain('Index Scan'); // Not Seq Scan
381
+ expect(planText).toContain('orders_user_id_idx');
382
+ });
383
+
384
+ // Test relations avoid N+1
385
+ test('users with posts executes single query', async () => {
386
+ const startTime = Date.now();
387
+ const users = await db.query.users.findMany({
388
+ with: { posts: true },
389
+ });
390
+ const duration = Date.now() - startTime;
391
+
392
+ expect(users).toHaveLength(100);
393
+ expect(users[0].posts).toBeDefined();
394
+ expect(duration).toBeLessThan(100); // Single query is fast
395
+ });
396
+ ```
397
+
398
+ ## Performance Validation Tools
399
+
400
+ ### EXPLAIN ANALYZE
401
+
402
+ ```sql
403
+ -- Check if index is used
404
+ EXPLAIN ANALYZE
405
+ SELECT * FROM orders
406
+ WHERE user_id = 'abc' AND status = 'pending';
407
+
408
+ -- Look for:
409
+ -- ✅ Index Scan using orders_user_status_idx
410
+ -- ❌ Seq Scan on orders (missing index!)
411
+ ```
412
+
413
+ ### Query Metrics
414
+
415
+ ```typescript
416
+ // Log slow queries in production
417
+ import { sql } from 'drizzle-orm';
418
+
419
+ const startTime = Date.now();
420
+ const result = await db.query.users.findMany({ with: { posts: true } });
421
+ const duration = Date.now() - startTime;
422
+
423
+ if (duration > 100) {
424
+ console.warn(`Slow query: ${duration}ms`, { query: 'users.findMany' });
425
+ }
426
+ ```
427
+
428
+ ## References
429
+
430
+ - [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview) - Relations, prepared statements, migrations
431
+ - [PostgreSQL Indexes](https://www.postgresql.org/docs/current/indexes.html) - Index types and usage
432
+ - [Drizzle Kit](https://orm.drizzle.team/docs/kit-overview) - Migration management
433
+
434
+ **Version Notes:**
435
+ - Drizzle ORM 0.30+: Improved relations API
436
+ - Drizzle Kit 0.20+: Enhanced migration generation
437
+
438
+ ## Red Flags - STOP and Add Indexes
439
+
440
+ | Thought | Reality |
441
+ |---------|---------|
442
+ | "I'll add indexes later if queries are slow" | Add them now. FK indexes are mandatory. |
443
+ | "Indexes slow down writes" | Reads outnumber writes 100:1. Index it. |
444
+ | "The table is small" | Tables grow. Index from day one. |
445
+ | "I don't know the query patterns yet" | FK indexes are always needed. Start there. |
446
+ | "JSON as text is fine for now" | JSONB costs nothing extra. Use it. |
447
+ | "I'll just push for now" | Use generate for prod. You need migration history. |
448
+ | "Relations are overhead" | They prevent N+1 and generate optimal queries. |
449
+ | "Prepared statements are premature" | They're free performance for repeated queries. |
450
+ | "I'll fetch users then their posts" | N+1 problem. Use `with` for single query. |
451
+
452
+ ## Common Mistakes
453
+
454
+ | Mistake | Fix |
455
+ |---------|-----|
456
+ | FK without index | Add `.on(table.foreignKeyColumn)` |
457
+ | JSON as text | Use `jsonb()` with GIN index |
458
+ | No composite index | Add for common multi-column filters |
459
+ | Missing $onUpdate | Add for `updatedAt` columns |
460
+ | Unique without index awareness | `unique()` creates index, but document intent |
461
+ | No partial index | Use `where()` for subset queries |
462
+ | Using push in production | Use `drizzle-kit generate` + `migrate` for versioning |
463
+ | N+1 queries with loops | Define relations, use `with` for nested data |
464
+ | No prepared statements | Use `.prepare()` for repeated queries |
465
+ | HTTP driver in Node serverless | Use Pool with WebSockets for better performance |