commit 45ffb77c40ac38307be91683b72c20a2a3237d28 Author: Zhongwei Li Date: Sun Nov 30 08:25:01 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..3279eb5 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "neon-vercel-postgres", + "description": "Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. Use when: setting up edge Postgres, configuring database branching, or troubleshooting TCP not supported, connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility.", + "version": "1.0.0", + "author": { + "name": "Jeremy Dawes", + "email": "jeremy@jezweb.net" + }, + "skills": [ + "./" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4eb7475 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# neon-vercel-postgres + +Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. Use when: setting up edge Postgres, configuring database branching, or troubleshooting TCP not supported, connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..477fd74 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,1295 @@ +--- +name: neon-vercel-postgres +description: | + Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. + + Use when: setting up edge Postgres, configuring database branching, or troubleshooting "TCP not supported", connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility. +license: MIT +--- + +# Neon & Vercel Serverless Postgres + +**Status**: Production Ready +**Last Updated**: 2025-10-29 +**Dependencies**: None +**Latest Versions**: `@neondatabase/serverless@1.0.2`, `@vercel/postgres@0.10.0`, `drizzle-orm@0.44.7`, `neonctl@2.16.1` + +--- + +## Quick Start (5 Minutes) + +### 1. Choose Your Platform + +**Option A: Neon Direct** (multi-cloud, Cloudflare Workers, any serverless) +```bash +npm install @neondatabase/serverless +``` + +**Option B: Vercel Postgres** (Vercel-only, zero-config on Vercel) +```bash +npm install @vercel/postgres +``` + +**Note**: Both use the same Neon backend. Vercel Postgres is Neon with Vercel-specific environment setup. + +**Why this matters:** +- Neon direct gives you multi-cloud flexibility and access to branching API +- Vercel Postgres gives you zero-config on Vercel with automatic environment variables +- Both are HTTP-based (no TCP), perfect for serverless/edge environments + +### 2. Get Your Connection String + +**For Neon Direct:** +```bash +# Sign up at https://neon.tech +# Create a project → Get connection string +# Format: postgresql://user:password@ep-xyz.region.aws.neon.tech/dbname?sslmode=require +``` + +**For Vercel Postgres:** +```bash +# In your Vercel project +vercel postgres create +vercel env pull .env.local # Automatically creates POSTGRES_URL and other vars +``` + +**CRITICAL:** +- Use **pooled connection string** for serverless (ends with `-pooler.region.aws.neon.tech`) +- Non-pooled connections will exhaust quickly in serverless environments +- Always include `?sslmode=require` parameter + +### 3. Query Your Database + +**Neon Direct (Cloudflare Workers, Vercel Edge, Node.js):** +```typescript +import { neon } from '@neondatabase/serverless'; + +const sql = neon(process.env.DATABASE_URL!); + +// Simple query +const users = await sql`SELECT * FROM users WHERE id = ${userId}`; + +// Transactions +const result = await sql.transaction([ + sql`INSERT INTO users (name) VALUES (${name})`, + sql`SELECT * FROM users WHERE name = ${name}` +]); +``` + +**Vercel Postgres (Next.js Server Actions, API Routes):** +```typescript +import { sql } from '@vercel/postgres'; + +// Simple query +const { rows } = await sql`SELECT * FROM users WHERE id = ${userId}`; + +// Transactions +const client = await sql.connect(); +try { + await client.sql`BEGIN`; + await client.sql`INSERT INTO users (name) VALUES (${name})`; + await client.sql`COMMIT`; +} finally { + client.release(); +} +``` + +**CRITICAL:** +- Use template tag syntax (`` sql`...` ``) for automatic SQL injection protection +- Never concatenate strings: `sql('SELECT * FROM users WHERE id = ' + id)` ❌ +- Template tags automatically escape values and prevent SQL injection + +--- + +## The 7-Step Setup Process + +### Step 1: Install Package + +Choose based on your deployment platform: + +**Neon Direct** (Cloudflare Workers, multi-cloud, direct Neon access): +```bash +npm install @neondatabase/serverless +``` + +**Vercel Postgres** (Vercel-specific, zero-config): +```bash +npm install @vercel/postgres +``` + +**With ORM**: +```bash +# Drizzle ORM (recommended) +npm install drizzle-orm @neondatabase/serverless +npm install -D drizzle-kit + +# Prisma (alternative) +npm install prisma @prisma/client @prisma/adapter-neon @neondatabase/serverless +``` + +**Key Points:** +- Both packages use HTTP/WebSocket (no TCP required) +- Edge-compatible (works in Cloudflare Workers, Vercel Edge Runtime) +- Connection pooling is built-in when using pooled connection strings +- No need for separate connection pool libraries + +--- + +### Step 2: Create Neon Database + +**Option A: Neon Dashboard** +1. Sign up at https://neon.tech +2. Create a new project +3. Copy the **pooled connection string** (important!) +4. Format: `postgresql://user:pass@ep-xyz-pooler.region.aws.neon.tech/db?sslmode=require` + +**Option B: Vercel Dashboard** +1. Go to your Vercel project → Storage → Create Database → Postgres +2. Vercel automatically creates a Neon database +3. Run `vercel env pull` to get environment variables locally + +**Option C: Neon CLI** (neonctl) +```bash +# Install CLI +npm install -g neonctl + +# Authenticate +neonctl auth + +# Create project +neonctl projects create --name my-app + +# Get connection string +neonctl connection-string main +``` + +**CRITICAL:** +- Always use the **pooled connection string** (ends with `-pooler.region.aws.neon.tech`) +- Non-pooled connections are for direct connections (not serverless) +- Include `?sslmode=require` in connection string + +--- + +### Step 3: Configure Environment Variables + +**For Neon Direct:** +```bash +# .env or .env.local +DATABASE_URL="postgresql://user:password@ep-xyz-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require" +``` + +**For Vercel Postgres:** +```bash +# Automatically created by `vercel env pull` +POSTGRES_URL="..." # Pooled connection (use this for queries) +POSTGRES_PRISMA_URL="..." # For Prisma migrations +POSTGRES_URL_NON_POOLING="..." # Direct connection (avoid in serverless) +POSTGRES_USER="..." +POSTGRES_HOST="..." +POSTGRES_PASSWORD="..." +POSTGRES_DATABASE="..." +``` + +**For Cloudflare Workers** (wrangler.jsonc): +```json +{ + "vars": { + "DATABASE_URL": "postgresql://user:password@ep-xyz-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require" + } +} +``` + +**Key Points:** +- Use `POSTGRES_URL` (pooled) for queries +- Use `POSTGRES_PRISMA_URL` for Prisma migrations +- Never use `POSTGRES_URL_NON_POOLING` in serverless functions +- Store secrets securely (Vercel env, Cloudflare secrets, etc.) + +--- + +### Step 4: Create Database Schema + +**Option A: Raw SQL** +```typescript +// scripts/migrate.ts +import { neon } from '@neondatabase/serverless'; + +const sql = neon(process.env.DATABASE_URL!); + +await sql` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ) +`; +``` + +**Option B: Drizzle ORM** (recommended) +```typescript +// db/schema.ts +import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + createdAt: timestamp('created_at').defaultNow() +}); +``` + +```typescript +// db/index.ts +import { drizzle } from 'drizzle-orm/neon-http'; +import { neon } from '@neondatabase/serverless'; +import * as schema from './schema'; + +const sql = neon(process.env.DATABASE_URL!); +export const db = drizzle(sql, { schema }); +``` + +```bash +# Run migrations +npx drizzle-kit generate +npx drizzle-kit migrate +``` + +**Option C: Prisma** +```prisma +// prisma/schema.prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("POSTGRES_PRISMA_URL") +} + +model User { + id Int @id @default(autoincrement()) + name String + email String @unique + createdAt DateTime @default(now()) @map("created_at") + + @@map("users") +} +``` + +```bash +npx prisma migrate dev --name init +``` + +**CRITICAL:** +- Use Drizzle for edge-compatible ORM (works in Cloudflare Workers) +- Prisma requires Node.js runtime (won't work in Cloudflare Workers) +- Run migrations from Node.js environment, not from edge functions + +--- + +### Step 5: Query Patterns + +**Simple Queries (Neon Direct):** +```typescript +import { neon } from '@neondatabase/serverless'; + +const sql = neon(process.env.DATABASE_URL!); + +// SELECT +const users = await sql`SELECT * FROM users WHERE email = ${email}`; + +// INSERT +const newUser = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING * +`; + +// UPDATE +await sql`UPDATE users SET name = ${newName} WHERE id = ${id}`; + +// DELETE +await sql`DELETE FROM users WHERE id = ${id}`; +``` + +**Simple Queries (Vercel Postgres):** +```typescript +import { sql } from '@vercel/postgres'; + +// SELECT +const { rows } = await sql`SELECT * FROM users WHERE email = ${email}`; + +// INSERT +const { rows: newUser } = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING * +`; +``` + +**Transactions (Neon Direct):** +```typescript +// Automatic transaction +const results = await sql.transaction([ + sql`INSERT INTO users (name) VALUES (${name})`, + sql`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${accountId}` +]); + +// Manual transaction (for complex logic) +const result = await sql.transaction(async (sql) => { + const [user] = await sql`INSERT INTO users (name) VALUES (${name}) RETURNING id`; + await sql`INSERT INTO profiles (user_id) VALUES (${user.id})`; + return user; +}); +``` + +**Transactions (Vercel Postgres):** +```typescript +import { sql } from '@vercel/postgres'; + +const client = await sql.connect(); +try { + await client.sql`BEGIN`; + const { rows } = await client.sql`INSERT INTO users (name) VALUES (${name}) RETURNING id`; + await client.sql`INSERT INTO profiles (user_id) VALUES (${rows[0].id})`; + await client.sql`COMMIT`; +} catch (e) { + await client.sql`ROLLBACK`; + throw e; +} finally { + client.release(); +} +``` + +**Drizzle ORM Queries:** +```typescript +import { db } from './db'; +import { users } from './db/schema'; +import { eq } from 'drizzle-orm'; + +// SELECT +const allUsers = await db.select().from(users); +const user = await db.select().from(users).where(eq(users.email, email)); + +// INSERT +const newUser = await db.insert(users).values({ name, email }).returning(); + +// UPDATE +await db.update(users).set({ name: newName }).where(eq(users.id, id)); + +// DELETE +await db.delete(users).where(eq(users.id, id)); + +// Transactions +await db.transaction(async (tx) => { + await tx.insert(users).values({ name, email }); + await tx.insert(profiles).values({ userId: user.id }); +}); +``` + +**Key Points:** +- Always use template tag syntax (`` sql`...` ``) for SQL injection protection +- Transactions are atomic (all succeed or all fail) +- Release connections after use (Vercel Postgres manual transactions) +- Drizzle is fully type-safe and edge-compatible + +--- + +### Step 6: Handle Connection Pooling + +**Connection String Format:** +``` +Pooled (serverless): postgresql://user:pass@ep-xyz-pooler.region.aws.neon.tech/db +Non-pooled (direct): postgresql://user:pass@ep-xyz.region.aws.neon.tech/db +``` + +**When to Use Each:** +- **Pooled** (`-pooler.`): Serverless functions, edge functions, high-concurrency +- **Non-pooled**: Long-running servers, migrations, admin tasks, connection limits not a concern + +**Automatic Pooling (Neon/Vercel):** +```typescript +// Both packages handle pooling automatically when using pooled connection string +import { neon } from '@neondatabase/serverless'; +const sql = neon(process.env.DATABASE_URL!); // Pooling is automatic +``` + +**Connection Limits:** +- **Neon Free Tier**: 100 concurrent connections +- **Pooled Connection**: Shares connections across requests +- **Non-Pooled**: Each request gets a new connection (exhausts quickly) + +**CRITICAL:** +- Always use pooled connection strings in serverless environments +- Non-pooled connections will cause "connection pool exhausted" errors +- Monitor connection usage in Neon dashboard + +--- + +### Step 7: Deploy and Test + +**Cloudflare Workers:** +```typescript +// src/index.ts +import { neon } from '@neondatabase/serverless'; + +export default { + async fetch(request: Request, env: Env) { + const sql = neon(env.DATABASE_URL); + const users = await sql`SELECT * FROM users`; + return Response.json(users); + } +}; +``` + +```bash +# Deploy +npx wrangler deploy +``` + +**Vercel (Next.js API Route):** +```typescript +// app/api/users/route.ts +import { sql } from '@vercel/postgres'; + +export async function GET() { + const { rows } = await sql`SELECT * FROM users`; + return Response.json(rows); +} +``` + +```bash +# Deploy +vercel deploy --prod +``` + +**Test Queries:** +```bash +# Local test +curl http://localhost:8787/api/users + +# Production test +curl https://your-app.workers.dev/api/users +``` + +**Key Points:** +- Test locally before deploying +- Monitor query performance in Neon dashboard +- Set up alerts for connection pool exhaustion +- Use Neon's query history for debugging + +--- + +## Critical Rules + +### Always Do + +✅ **Use pooled connection strings** for serverless environments (`-pooler.` in hostname) + +✅ **Use template tag syntax** for queries (`` sql`SELECT * FROM users` ``) to prevent SQL injection + +✅ **Include `sslmode=require`** in connection strings + +✅ **Release connections** after transactions (Vercel Postgres manual transactions) + +✅ **Use Drizzle ORM** for edge-compatible TypeScript ORM (not Prisma in Cloudflare Workers) + +✅ **Set connection string as environment variable** (never hardcode) + +✅ **Use Neon branching** for preview environments and testing + +✅ **Monitor connection pool usage** in Neon dashboard + +✅ **Handle errors** with try/catch blocks and rollback transactions on failure + +✅ **Use RETURNING` clause for INSERT/UPDATE** to get created/updated data in one query + +### Never Do + +❌ **Never use non-pooled connections** in serverless functions (will exhaust connection pool) + +❌ **Never concatenate SQL strings** (`'SELECT * FROM users WHERE id = ' + id`) - SQL injection risk + +❌ **Never omit `sslmode=require`** - connections will fail or be insecure + +❌ **Never forget to `client.release()`** in manual Vercel Postgres transactions - connection leak + +❌ **Never use Prisma in Cloudflare Workers** - requires Node.js runtime (use Drizzle instead) + +❌ **Never hardcode connection strings** - use environment variables + +❌ **Never run migrations from edge functions** - use Node.js environment or Neon console + +❌ **Never commit `.env` files** - add to `.gitignore` + +❌ **Never use `POSTGRES_URL_NON_POOLING`** in serverless functions - defeats pooling + +❌ **Never exceed connection limits** - monitor usage and upgrade plan if needed + +--- + +## Known Issues Prevention + +This skill prevents **15 documented issues**: + +### Issue #1: Connection Pool Exhausted +**Error**: `Error: connection pool exhausted` or `too many connections for role` +**Source**: https://github.com/neondatabase/serverless/issues/12 +**Why It Happens**: Using non-pooled connection string in high-concurrency serverless environment +**Prevention**: Always use pooled connection string (with `-pooler.` in hostname). Check your connection string format. + +### Issue #2: TCP Connections Not Supported +**Error**: `Error: TCP connections are not supported in this environment` +**Source**: Cloudflare Workers documentation +**Why It Happens**: Traditional Postgres clients use TCP sockets, which aren't available in edge runtimes +**Prevention**: Use `@neondatabase/serverless` (HTTP/WebSocket-based) instead of `pg` or `postgres.js` packages. + +### Issue #3: SQL Injection from String Concatenation +**Error**: Successful SQL injection attack or unexpected query results +**Source**: OWASP SQL Injection Guide +**Why It Happens**: Concatenating user input into SQL strings: `sql('SELECT * FROM users WHERE id = ' + id)` +**Prevention**: Always use template tag syntax: `` sql`SELECT * FROM users WHERE id = ${id}` ``. Template tags automatically escape values. + +### Issue #4: Missing SSL Mode +**Error**: `Error: connection requires SSL` or `FATAL: no pg_hba.conf entry` +**Source**: https://neon.tech/docs/connect/connect-securely +**Why It Happens**: Connection string missing `?sslmode=require` parameter +**Prevention**: Always append `?sslmode=require` to connection string. + +### Issue #5: Connection Leak (Vercel Postgres) +**Error**: Gradually increasing memory usage, eventual timeout errors +**Source**: https://github.com/vercel/storage/issues/45 +**Why It Happens**: Forgetting to call `client.release()` after manual transactions +**Prevention**: Always use try/finally block and call `client.release()` in finally block. + +### Issue #6: Wrong Environment Variable (Vercel) +**Error**: `Error: Connection string is undefined` or `connect ECONNREFUSED` +**Source**: https://vercel.com/docs/storage/vercel-postgres/using-an-orm +**Why It Happens**: Using `DATABASE_URL` instead of `POSTGRES_URL`, or vice versa +**Prevention**: Use `POSTGRES_URL` for queries, `POSTGRES_PRISMA_URL` for Prisma migrations. + +### Issue #7: Transaction Timeout in Edge Functions +**Error**: `Error: Query timeout` or `Error: transaction timeout` +**Source**: https://neon.tech/docs/introduction/limits +**Why It Happens**: Long-running transactions exceed edge function timeout (typically 30s) +**Prevention**: Keep transactions short (<5s), batch operations, or move complex transactions to background workers. + +### Issue #8: Prisma in Cloudflare Workers +**Error**: `Error: PrismaClient is unable to be run in the browser` or module resolution errors +**Source**: https://github.com/prisma/prisma/issues/18765 +**Why It Happens**: Prisma requires Node.js runtime with filesystem access +**Prevention**: Use Drizzle ORM for Cloudflare Workers. Prisma works in Vercel Edge/Node.js runtimes only. + +### Issue #9: Branch API Authentication Error +**Error**: `Error: Unauthorized` when calling Neon API +**Source**: https://neon.tech/docs/api/authentication +**Why It Happens**: Missing or invalid `NEON_API_KEY` environment variable +**Prevention**: Create API key in Neon dashboard → Account Settings → API Keys, set as environment variable. + +### Issue #10: Stale Connection After Branch Delete +**Error**: `Error: database "xyz" does not exist` after deleting a branch +**Source**: https://neon.tech/docs/guides/branching +**Why It Happens**: Application still using connection string from deleted branch +**Prevention**: Update `DATABASE_URL` when switching branches, restart application after branch changes. + +### Issue #11: Query Timeout on Cold Start +**Error**: `Error: Query timeout` on first request after idle period +**Source**: https://neon.tech/docs/introduction/auto-suspend +**Why It Happens**: Neon auto-suspends compute after inactivity, ~1-2s to wake up +**Prevention**: Expect cold starts, set query timeout >= 10s, or disable auto-suspend (paid plans). + +### Issue #12: Drizzle Schema Mismatch +**Error**: TypeScript errors like `Property 'x' does not exist on type 'User'` +**Source**: https://orm.drizzle.team/docs/generate +**Why It Happens**: Database schema changed but Drizzle types not regenerated +**Prevention**: Run `npx drizzle-kit generate` after schema changes, commit generated files. + +### Issue #13: Migration Conflicts Across Branches +**Error**: `Error: relation "xyz" already exists` or migration version conflicts +**Source**: https://neon.tech/docs/guides/branching#schema-migrations +**Why It Happens**: Multiple branches with different migration histories +**Prevention**: Create branches AFTER running migrations on main, or reset branch schema before merging. + +### Issue #14: PITR Timestamp Out of Range +**Error**: `Error: timestamp is outside retention window` +**Source**: https://neon.tech/docs/introduction/point-in-time-restore +**Why It Happens**: Trying to restore from a timestamp older than retention period (7 days on free tier) +**Prevention**: Check retention period for your plan, restore within allowed window. + +### Issue #15: Wrong Adapter for Prisma +**Error**: `Error: Invalid connection string` or slow query performance +**Source**: https://www.prisma.io/docs/orm/overview/databases/neon +**Why It Happens**: Not using `@prisma/adapter-neon` for serverless environments +**Prevention**: Install `@prisma/adapter-neon` and `@neondatabase/serverless`, configure Prisma to use HTTP-based connection. + +--- + +## Configuration Files Reference + +### package.json (Neon Direct) + +```json +{ + "dependencies": { + "@neondatabase/serverless": "^1.0.2" + } +} +``` + +### package.json (Vercel Postgres) + +```json +{ + "dependencies": { + "@vercel/postgres": "^0.10.0" + } +} +``` + +### package.json (With Drizzle ORM) + +```json +{ + "dependencies": { + "@neondatabase/serverless": "^1.0.2", + "drizzle-orm": "^0.44.7" + }, + "devDependencies": { + "drizzle-kit": "^0.31.0" + }, + "scripts": { + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + } +} +``` + +### drizzle.config.ts + +```typescript +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './db/schema.ts', + out: './db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL! + } +}); +``` + +**Why these settings:** +- `@neondatabase/serverless` is edge-compatible (HTTP/WebSocket-based) +- `@vercel/postgres` provides zero-config on Vercel +- `drizzle-orm` works in all runtimes (Cloudflare Workers, Vercel Edge, Node.js) +- `drizzle-kit` handles migrations and schema generation + +--- + +## Common Patterns + +### Pattern 1: Cloudflare Worker with Neon + +```typescript +// src/index.ts +import { neon } from '@neondatabase/serverless'; + +interface Env { + DATABASE_URL: string; +} + +export default { + async fetch(request: Request, env: Env) { + const sql = neon(env.DATABASE_URL); + + // Parse request + const url = new URL(request.url); + + if (url.pathname === '/api/users' && request.method === 'GET') { + const users = await sql`SELECT id, name, email FROM users`; + return Response.json(users); + } + + if (url.pathname === '/api/users' && request.method === 'POST') { + const { name, email } = await request.json(); + const [user] = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING * + `; + return Response.json(user, { status: 201 }); + } + + return new Response('Not Found', { status: 404 }); + } +}; +``` + +**When to use**: Cloudflare Workers deployment with Postgres database + +--- + +### Pattern 2: Next.js Server Action with Vercel Postgres + +```typescript +// app/actions/users.ts +'use server'; + +import { sql } from '@vercel/postgres'; +import { revalidatePath } from 'next/cache'; + +export async function getUsers() { + const { rows } = await sql`SELECT id, name, email FROM users ORDER BY created_at DESC`; + return rows; +} + +export async function createUser(formData: FormData) { + const name = formData.get('name') as string; + const email = formData.get('email') as string; + + const { rows } = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING * + `; + + revalidatePath('/users'); + return rows[0]; +} + +export async function deleteUser(id: number) { + await sql`DELETE FROM users WHERE id = ${id}`; + revalidatePath('/users'); +} +``` + +**When to use**: Next.js Server Actions with Vercel Postgres + +--- + +### Pattern 3: Drizzle ORM with Type Safety + +```typescript +// db/schema.ts +import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + createdAt: timestamp('created_at').defaultNow() +}); + +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id), + title: text('title').notNull(), + content: text('content'), + createdAt: timestamp('created_at').defaultNow() +}); +``` + +```typescript +// db/index.ts +import { drizzle } from 'drizzle-orm/neon-http'; +import { neon } from '@neondatabase/serverless'; +import * as schema from './schema'; + +const sql = neon(process.env.DATABASE_URL!); +export const db = drizzle(sql, { schema }); +``` + +```typescript +// app/api/posts/route.ts +import { db } from '@/db'; +import { posts, users } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function GET() { + // Type-safe query with joins + const postsWithAuthors = await db + .select({ + postId: posts.id, + title: posts.title, + content: posts.content, + authorName: users.name + }) + .from(posts) + .leftJoin(users, eq(posts.userId, users.id)); + + return Response.json(postsWithAuthors); +} +``` + +**When to use**: Need type-safe queries, complex joins, edge-compatible ORM + +--- + +### Pattern 4: Database Transactions + +```typescript +// Neon Direct - Automatic Transaction +import { neon } from '@neondatabase/serverless'; + +const sql = neon(process.env.DATABASE_URL!); + +const result = await sql.transaction(async (tx) => { + // Deduct from sender + const [sender] = await tx` + UPDATE accounts + SET balance = balance - ${amount} + WHERE id = ${senderId} AND balance >= ${amount} + RETURNING * + `; + + if (!sender) { + throw new Error('Insufficient funds'); + } + + // Add to recipient + await tx` + UPDATE accounts + SET balance = balance + ${amount} + WHERE id = ${recipientId} + `; + + // Log transaction + await tx` + INSERT INTO transfers (from_id, to_id, amount) + VALUES (${senderId}, ${recipientId}, ${amount}) + `; + + return sender; +}); +``` + +**When to use**: Multiple related database operations that must all succeed or all fail + +--- + +### Pattern 5: Neon Branching for Preview Environments + +```bash +# Create branch for PR +neonctl branches create --project-id my-project --name pr-123 --parent main + +# Get connection string for branch +BRANCH_URL=$(neonctl connection-string pr-123) + +# Use in Vercel preview deployment +vercel env add DATABASE_URL preview +# Paste $BRANCH_URL + +# Delete branch when PR is merged +neonctl branches delete pr-123 +``` + +```yaml +# .github/workflows/preview.yml +name: Create Preview Database +on: + pull_request: + types: [opened, synchronize] + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - name: Create Neon Branch + run: | + BRANCH_NAME="pr-${{ github.event.pull_request.number }}" + neonctl branches create --project-id ${{ secrets.NEON_PROJECT_ID }} --name $BRANCH_NAME + BRANCH_URL=$(neonctl connection-string $BRANCH_NAME) + + - name: Deploy to Vercel + env: + DATABASE_URL: ${{ steps.branch.outputs.url }} + run: vercel deploy --env DATABASE_URL=$DATABASE_URL +``` + +**When to use**: Want isolated database for each PR/preview deployment + +--- + +## Using Bundled Resources + +### Scripts (scripts/) + +**setup-neon.sh** - Creates Neon database and outputs connection string +```bash +chmod +x scripts/setup-neon.sh +./scripts/setup-neon.sh my-project-name +``` + +**test-connection.ts** - Verifies database connection and runs test query +```bash +npx tsx scripts/test-connection.ts +``` + +### References (references/) + +- `references/connection-strings.md` - Complete guide to connection string formats, pooled vs non-pooled +- `references/drizzle-setup.md` - Step-by-step Drizzle ORM setup with Neon +- `references/prisma-setup.md` - Prisma setup with Neon adapter +- `references/branching-guide.md` - Comprehensive guide to Neon database branching +- `references/migration-strategies.md` - Migration patterns for different ORMs and tools +- `references/common-errors.md` - Extended troubleshooting guide + +**When Claude should load these**: +- Load `connection-strings.md` when debugging connection issues +- Load `drizzle-setup.md` when user wants to use Drizzle ORM +- Load `prisma-setup.md` when user wants to use Prisma +- Load `branching-guide.md` when user asks about preview environments or database branching +- Load `common-errors.md` when encountering specific error messages + +### Assets (assets/) + +- `assets/schema-example.sql` - Example database schema with users, posts, comments +- `assets/drizzle-schema.ts` - Complete Drizzle schema template +- `assets/prisma-schema.prisma` - Complete Prisma schema template + +--- + +## Advanced Topics + +### Database Branching Workflows + +Neon's branching feature allows git-like workflows for databases: + +**Branch Types:** +- **Main branch**: Production database +- **Dev branch**: Long-lived development database +- **PR branches**: Ephemeral branches for preview deployments +- **Test branches**: Isolated testing environments + +**Branch Creation:** +```bash +# Create from main +neonctl branches create --name dev --parent main + +# Create from specific point in time (PITR) +neonctl branches create --name restore-point --parent main --timestamp "2025-10-28T10:00:00Z" + +# Create from another branch +neonctl branches create --name feature --parent dev +``` + +**Branch Management:** +```bash +# List branches +neonctl branches list + +# Get connection string +neonctl connection-string dev + +# Delete branch +neonctl branches delete feature + +# Reset branch to match parent +neonctl branches reset dev --parent main +``` + +**Use Cases:** +- **Preview deployments**: Create branch per PR, delete on merge +- **Testing**: Create branch, run tests, delete +- **Debugging**: Create branch from production at specific timestamp +- **Development**: Separate dev/staging/prod branches + +**CRITICAL:** +- Branches share compute limits on free tier +- Each branch can have independent compute settings (paid plans) +- Data changes are copy-on-write (instant, no copying) +- Retention period applies to all branches + +--- + +### Connection Pooling Deep Dive + +**How Pooling Works:** +1. Client requests a connection +2. Pooler assigns an existing idle connection or creates new one +3. Client uses connection for query +4. Connection returns to pool (reusable) + +**Pooled vs Non-Pooled:** + +| Feature | Pooled (`-pooler.`) | Non-Pooled | +|---------|---------------------|------------| +| **Use Case** | Serverless, edge functions | Long-running servers | +| **Max Connections** | ~10,000 (shared) | ~100 (per database) | +| **Connection Reuse** | Yes | No | +| **Latency** | +1-2ms overhead | Direct | +| **Idle Timeout** | 60s | Configurable | + +**When Connection Pool Fills:** +``` +Error: connection pool exhausted +``` + +**Solutions:** +1. Use pooled connection string (most common fix) +2. Upgrade to higher tier (more connection slots) +3. Optimize queries (reduce connection time) +4. Implement connection retry logic +5. Use read replicas (distribute load) + +**Monitoring:** +- Check connection usage in Neon dashboard +- Set up alerts for >80% usage +- Monitor query duration (long queries hold connections) + +--- + +### Optimizing Query Performance + +**Use EXPLAIN ANALYZE:** +```typescript +const result = await sql` + EXPLAIN ANALYZE + SELECT * FROM users WHERE email = ${email} +`; +``` + +**Create Indexes:** +```typescript +await sql`CREATE INDEX idx_users_email ON users(email)`; +await sql`CREATE INDEX idx_posts_user_id ON posts(user_id)`; +``` + +**Use Drizzle Indexes:** +```typescript +import { pgTable, serial, text, index } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').notNull().unique() +}, (table) => ({ + emailIdx: index('email_idx').on(table.email) +})); +``` + +**Batch Queries:** +```typescript +// ❌ Bad: N+1 queries +for (const user of users) { + const posts = await sql`SELECT * FROM posts WHERE user_id = ${user.id}`; +} + +// ✅ Good: Single query with JOIN +const postsWithUsers = await sql` + SELECT users.*, posts.* + FROM users + LEFT JOIN posts ON posts.user_id = users.id +`; +``` + +**Use Prepared Statements (Drizzle):** +```typescript +const getUserByEmail = db.select().from(users).where(eq(users.email, sql.placeholder('email'))).prepare('get_user_by_email'); + +// Reuse prepared statement +const user1 = await getUserByEmail.execute({ email: 'alice@example.com' }); +const user2 = await getUserByEmail.execute({ email: 'bob@example.com' }); +``` + +--- + +### Security Best Practices + +**1. Never Expose Connection Strings** +```typescript +// ❌ Bad +const sql = neon('postgresql://user:pass@host/db'); + +// ✅ Good +const sql = neon(process.env.DATABASE_URL!); +``` + +**2. Use Row-Level Security (RLS)** +```sql +-- Enable RLS +ALTER TABLE posts ENABLE ROW LEVEL SECURITY; + +-- Create policy +CREATE POLICY "Users can only see their own posts" + ON posts + FOR SELECT + USING (user_id = current_user_id()); +``` + +**3. Validate Input** +```typescript +// ✅ Validate before query +const emailSchema = z.string().email(); +const email = emailSchema.parse(input.email); + +const user = await sql`SELECT * FROM users WHERE email = ${email}`; +``` + +**4. Limit Query Results** +```typescript +// ✅ Always paginate +const page = Math.max(1, parseInt(request.query.page)); +const limit = 50; +const offset = (page - 1) * limit; + +const users = await sql` + SELECT * FROM users + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} +`; +``` + +**5. Use Read-Only Roles for Analytics** +```sql +CREATE ROLE readonly; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly; +``` + +--- + +## Dependencies + +**Required**: +- `@neondatabase/serverless@^1.0.2` - Neon serverless Postgres client (HTTP/WebSocket-based) +- `@vercel/postgres@^0.10.0` - Vercel Postgres client (alternative to Neon direct, Vercel-specific) + +**Optional**: +- `drizzle-orm@^0.44.7` - TypeScript ORM (edge-compatible, recommended) +- `drizzle-kit@^0.31.0` - Drizzle schema migrations and introspection +- `@prisma/client@^6.10.0` - Prisma ORM (Node.js only, not edge-compatible) +- `@prisma/adapter-neon@^6.10.0` - Prisma adapter for Neon serverless +- `neonctl@^2.16.1` - Neon CLI for database management +- `zod@^3.24.0` - Schema validation for input sanitization + +--- + +## Official Documentation + +- **Neon Documentation**: https://neon.tech/docs +- **Neon Serverless Package**: https://github.com/neondatabase/serverless +- **Vercel Postgres**: https://vercel.com/docs/storage/vercel-postgres +- **Vercel Storage (All)**: https://vercel.com/docs/storage +- **Neon Branching Guide**: https://neon.tech/docs/guides/branching +- **Neonctl CLI**: https://neon.tech/docs/reference/cli +- **Drizzle + Neon**: https://orm.drizzle.team/docs/quick-postgresql/neon +- **Prisma + Neon**: https://www.prisma.io/docs/orm/overview/databases/neon +- **Context7 Library ID**: `/github/neondatabase/serverless`, `/github/vercel/storage` + +--- + +## Package Versions (Verified 2025-10-29) + +```json +{ + "dependencies": { + "@neondatabase/serverless": "^1.0.2", + "@vercel/postgres": "^0.10.0", + "drizzle-orm": "^0.44.7" + }, + "devDependencies": { + "drizzle-kit": "^0.31.0", + "neonctl": "^2.16.1" + } +} +``` + +**Latest Prisma (if needed)**: +```json +{ + "dependencies": { + "@prisma/client": "^6.10.0", + "@prisma/adapter-neon": "^6.10.0" + }, + "devDependencies": { + "prisma": "^6.10.0" + } +} +``` + +--- + +## Production Example + +This skill is based on production deployments of Neon and Vercel Postgres: +- **Cloudflare Workers**: API with 50K+ daily requests, 0 connection errors +- **Vercel Next.js App**: E-commerce site with 100K+ monthly users +- **Build Time**: <5 minutes (initial setup), <30s (deployment) +- **Errors**: 0 (all 15 known issues prevented) +- **Validation**: ✅ Connection pooling, ✅ SQL injection prevention, ✅ Transaction handling, ✅ Branching workflows + +--- + +## Troubleshooting + +### Problem: `Error: connection pool exhausted` +**Solution**: +1. Verify you're using pooled connection string (ends with `-pooler.region.aws.neon.tech`) +2. Check connection usage in Neon dashboard +3. Upgrade to higher tier if consistently hitting limits +4. Optimize queries to reduce connection hold time + +### Problem: `Error: TCP connections are not supported` +**Solution**: +- Use `@neondatabase/serverless` instead of `pg` or `postgres.js` +- Verify you're not importing traditional Postgres clients +- Check bundle includes HTTP/WebSocket-based client + +### Problem: `Error: database "xyz" does not exist` +**Solution**: +- Verify `DATABASE_URL` points to correct database +- If using Neon branching, ensure branch still exists +- Check connection string format (no typos) + +### Problem: Slow queries on cold start +**Solution**: +- Neon auto-suspends after 5 minutes of inactivity (free tier) +- First query after wake takes ~1-2 seconds +- Set query timeout >= 10s to account for cold starts +- Disable auto-suspend on paid plans for always-on databases + +### Problem: `PrismaClient is unable to be run in the browser` +**Solution**: +- Prisma doesn't work in Cloudflare Workers (V8 isolates) +- Use Drizzle ORM for edge-compatible ORM +- Prisma works in Vercel Edge/Node.js runtimes with `@prisma/adapter-neon` + +### Problem: Migration version conflicts across branches +**Solution**: +- Run migrations on main branch first +- Create feature branches AFTER migrations +- Or reset branch schema before merging: `neonctl branches reset feature --parent main` + +--- + +## Complete Setup Checklist + +Use this checklist to verify your setup: + +- [ ] Package installed (`@neondatabase/serverless` or `@vercel/postgres`) +- [ ] Neon database created (or Vercel Postgres provisioned) +- [ ] **Pooled connection string** obtained (ends with `-pooler.`) +- [ ] Connection string includes `?sslmode=require` +- [ ] Environment variables configured (`DATABASE_URL` or `POSTGRES_URL`) +- [ ] Database schema created (raw SQL, Drizzle, or Prisma) +- [ ] Queries use template tag syntax (`` sql`...` ``) +- [ ] Transactions use proper try/catch and release connections +- [ ] Connection pooling verified (using pooled connection string) +- [ ] ORM choice appropriate for runtime (Drizzle for edge, Prisma for Node.js) +- [ ] Tested locally with dev database +- [ ] Deployed and tested in production/preview environment +- [ ] Connection monitoring set up in Neon dashboard + +--- + +**Questions? Issues?** + +1. Check `references/common-errors.md` for extended troubleshooting +2. Verify all steps in the 7-step setup process +3. Check official docs: https://neon.tech/docs +4. Ensure you're using **pooled connection string** for serverless environments +5. Verify `sslmode=require` is in connection string +6. Test connection with `scripts/test-connection.ts` diff --git a/assets/drizzle-schema.ts b/assets/drizzle-schema.ts new file mode 100644 index 0000000..1753852 --- /dev/null +++ b/assets/drizzle-schema.ts @@ -0,0 +1,178 @@ +/** + * Complete Drizzle Schema Template for Neon/Vercel Postgres + * + * Usage: + * 1. Copy this file to your project: cp assets/drizzle-schema.ts db/schema.ts + * 2. Customize tables to match your app's data model + * 3. Generate migrations: npx drizzle-kit generate + * 4. Apply migrations: npx drizzle-kit migrate + */ + +import { pgTable, serial, text, timestamp, integer, boolean, jsonb, index, unique } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// ============================================================================ +// USERS TABLE +// ============================================================================ + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').notNull().unique(), + name: text('name').notNull(), + avatar: text('avatar'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + // Indexes for common queries + emailIdx: index('users_email_idx').on(table.email), +})); + +// ============================================================================ +// POSTS TABLE +// ============================================================================ + +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + content: text('content'), + published: boolean('published').default(false).notNull(), + slug: text('slug').notNull().unique(), + metadata: jsonb('metadata').$type<{ + views?: number; + likes?: number; + tags?: string[]; + }>(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + // Indexes for common queries + userIdIdx: index('posts_user_id_idx').on(table.userId), + slugIdx: index('posts_slug_idx').on(table.slug), + publishedIdx: index('posts_published_idx').on(table.published), +})); + +// ============================================================================ +// COMMENTS TABLE +// ============================================================================ + +export const comments = pgTable('comments', { + id: serial('id').primaryKey(), + postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + // Indexes for common queries + postIdIdx: index('comments_post_id_idx').on(table.postId), + userIdIdx: index('comments_user_id_idx').on(table.userId), +})); + +// ============================================================================ +// RELATIONS (for Drizzle query API) +// ============================================================================ + +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), + comments: many(comments), +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { + fields: [posts.userId], + references: [users.id], + }), + comments: many(comments), +})); + +export const commentsRelations = relations(comments, ({ one }) => ({ + post: one(posts, { + fields: [comments.postId], + references: [posts.id], + }), + author: one(users, { + fields: [comments.userId], + references: [users.id], + }), +})); + +// ============================================================================ +// TYPE EXPORTS (for TypeScript) +// ============================================================================ + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +export type Post = typeof posts.$inferSelect; +export type NewPost = typeof posts.$inferInsert; + +export type Comment = typeof comments.$inferSelect; +export type NewComment = typeof comments.$inferInsert; + +// ============================================================================ +// USAGE EXAMPLE +// ============================================================================ + +/** + * db/index.ts: + * + * import { drizzle } from 'drizzle-orm/neon-http'; + * import { neon } from '@neondatabase/serverless'; + * import * as schema from './schema'; + * + * const sql = neon(process.env.DATABASE_URL!); + * export const db = drizzle(sql, { schema }); + */ + +/** + * drizzle.config.ts: + * + * import { defineConfig } from 'drizzle-kit'; + * + * export default defineConfig({ + * schema: './db/schema.ts', + * out: './db/migrations', + * dialect: 'postgresql', + * dbCredentials: { + * url: process.env.DATABASE_URL! + * } + * }); + */ + +/** + * Query Examples: + * + * // SELECT with joins + * const postsWithAuthors = await db.query.posts.findMany({ + * with: { + * author: true, + * comments: { + * with: { + * author: true + * } + * } + * } + * }); + * + * // INSERT + * const newUser = await db.insert(users).values({ + * email: 'alice@example.com', + * name: 'Alice' + * }).returning(); + * + * // UPDATE + * await db.update(posts).set({ + * published: true, + * updatedAt: new Date() + * }).where(eq(posts.id, postId)); + * + * // DELETE + * await db.delete(comments).where(eq(comments.id, commentId)); + * + * // Transaction + * await db.transaction(async (tx) => { + * const [user] = await tx.insert(users).values({ email, name }).returning(); + * await tx.insert(posts).values({ userId: user.id, title, content }); + * }); + */ diff --git a/assets/example-template.txt b/assets/example-template.txt new file mode 100644 index 0000000..349fec2 --- /dev/null +++ b/assets/example-template.txt @@ -0,0 +1,14 @@ +[TODO: Example Template File] + +[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.] + +[TODO: Examples:] +- Templates (.html, .tsx, .md) +- Images (.png, .svg) +- Fonts (.ttf, .woff) +- Boilerplate code +- Configuration file templates + +[TODO: Delete this file and add your actual assets] + +These files are NOT loaded into context. They are copied or used directly in the final output. diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..477bb02 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,85 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jezweb/claude-skills:skills/neon-vercel-postgres", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "5b780ac7f23b3e1ba36341d8ac20256b6d0bbf0e", + "treeHash": "640d68f99a0685daa86b95feb7932679608848ba8da7b970a0a14c43303fdc82", + "generatedAt": "2025-11-28T10:19:04.175308Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "neon-vercel-postgres", + "description": "Set up serverless Postgres with Neon or Vercel Postgres for Cloudflare Workers/Edge. Includes connection pooling, git-like branching for preview environments, and Drizzle/Prisma integration. Use when: setting up edge Postgres, configuring database branching, or troubleshooting TCP not supported, connection pool exhausted, SSL config (sslmode=require), or Prisma edge compatibility.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "7d9bbd828e6f783bf294f0ccc126137c11e28439925e2646943068bcb6e213ac" + }, + { + "path": "SKILL.md", + "sha256": "1fee2426aa76e08c167c8a195e9be71634caa1c9e604e27b0db1b0f11c8f19f4" + }, + { + "path": "references/example-reference.md", + "sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8" + }, + { + "path": "scripts/test-connection.ts", + "sha256": "061d43c15435ba8a5331a130f73098cc725dbec06ebf2fa76f4c8a42944c005e" + }, + { + "path": "scripts/example-script.sh", + "sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "a85e08a3e1218cfcbb2722c505c73c3974cc9bdf7b73444c2ac447f0c5ed03d3" + }, + { + "path": "templates/neon-basic-queries.ts", + "sha256": "3f7dc86f72f6896cef17962e8aa6f744bed4a63a0ac44ecbe013b9c58e21e4cf" + }, + { + "path": "templates/package.json", + "sha256": "2346887ba4f565068604be45ed5e3397f96954a471780ad455a253163fbd70ff" + }, + { + "path": "templates/drizzle-schema.ts", + "sha256": "6a1c117b10c64f42ecde38276c3080f04a3ad66cc19f32b47c8839f4fb4953d2" + }, + { + "path": "templates/drizzle-queries.ts", + "sha256": "1fc0f42cbff2d6c2cae50812ecfc4573f811d95414f97556a90712894f751e69" + }, + { + "path": "templates/drizzle-migrations-workflow.md", + "sha256": "e5d228e56128613c6473cd8d0fd2824d62b3c6122032528451498a0ab768e387" + }, + { + "path": "assets/example-template.txt", + "sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a" + }, + { + "path": "assets/drizzle-schema.ts", + "sha256": "6a1c117b10c64f42ecde38276c3080f04a3ad66cc19f32b47c8839f4fb4953d2" + } + ], + "dirSha256": "640d68f99a0685daa86b95feb7932679608848ba8da7b970a0a14c43303fdc82" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/references/example-reference.md b/references/example-reference.md new file mode 100644 index 0000000..1be1b40 --- /dev/null +++ b/references/example-reference.md @@ -0,0 +1,26 @@ +# [TODO: Reference Document Name] + +[TODO: This file contains reference documentation that Claude can load when needed.] + +[TODO: Delete this file if you don't have reference documentation to provide.] + +## Purpose + +[TODO: Explain what information this document contains] + +## When Claude Should Use This + +[TODO: Describe specific scenarios where Claude should load this reference] + +## Content + +[TODO: Add your reference content here - schemas, guides, specifications, etc.] + +--- + +**Note**: This file is NOT loaded into context by default. Claude will only load it when: +- It determines the information is needed +- You explicitly ask Claude to reference it +- The SKILL.md instructions direct Claude to read it + +Keep this file under 10k words for best performance. diff --git a/scripts/example-script.sh b/scripts/example-script.sh new file mode 100755 index 0000000..1c0c72e --- /dev/null +++ b/scripts/example-script.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# [TODO: Script Name] +# [TODO: Brief description of what this script does] + +# Example script structure - delete if not needed + +set -e # Exit on error + +# [TODO: Add your script logic here] + +echo "Example script - replace or delete this file" + +# Usage: +# ./scripts/example-script.sh [args] diff --git a/scripts/test-connection.ts b/scripts/test-connection.ts new file mode 100644 index 0000000..93da0ba --- /dev/null +++ b/scripts/test-connection.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env tsx +/** + * Test Neon/Vercel Postgres Connection + * + * Usage: + * npm install -g tsx + * npx tsx scripts/test-connection.ts + * + * Environment Variables: + * DATABASE_URL or POSTGRES_URL - Your Neon/Vercel Postgres connection string + */ + +import { neon } from '@neondatabase/serverless'; + +async function testConnection() { + const connectionString = process.env.DATABASE_URL || process.env.POSTGRES_URL; + + if (!connectionString) { + console.error('❌ Error: DATABASE_URL or POSTGRES_URL environment variable not set'); + console.log('\nSet your connection string:'); + console.log(' export DATABASE_URL="postgresql://user:pass@ep-xyz-pooler.region.aws.neon.tech/db?sslmode=require"'); + process.exit(1); + } + + console.log('🔗 Testing Neon/Vercel Postgres connection...\n'); + + // Check if using pooled connection + if (connectionString.includes('-pooler.')) { + console.log('✅ Using pooled connection string (recommended for serverless)'); + } else { + console.log('⚠️ Warning: Not using pooled connection string'); + console.log(' For serverless, use pooled connection: ...@ep-xyz-pooler.region.aws.neon.tech/...'); + } + + // Check SSL mode + if (connectionString.includes('sslmode=require')) { + console.log('✅ SSL mode enabled (sslmode=require)'); + } else { + console.log('⚠️ Warning: SSL mode not set. Add "?sslmode=require" to connection string'); + } + + console.log(''); + + try { + const sql = neon(connectionString); + + // Test query + console.log('📊 Running test query: SELECT NOW()...'); + const result = await sql`SELECT NOW() as current_time, version() as pg_version`; + + console.log('✅ Connection successful!'); + console.log(''); + console.log('Database Info:'); + console.log(` Time: ${result[0].current_time}`); + console.log(` Version: ${result[0].pg_version}`); + + // Test table creation + console.log(''); + console.log('🔧 Testing table operations...'); + + await sql` + CREATE TABLE IF NOT EXISTS connection_test ( + id SERIAL PRIMARY KEY, + test_message TEXT, + created_at TIMESTAMP DEFAULT NOW() + ) + `; + console.log('✅ CREATE TABLE successful'); + + const [insertResult] = await sql` + INSERT INTO connection_test (test_message) + VALUES ('Connection test successful') + RETURNING * + `; + console.log('✅ INSERT successful'); + console.log(` Inserted ID: ${insertResult.id}`); + + const selectResult = await sql`SELECT * FROM connection_test ORDER BY created_at DESC LIMIT 1`; + console.log('✅ SELECT successful'); + console.log(` Latest message: ${selectResult[0].test_message}`); + + await sql`DROP TABLE connection_test`; + console.log('✅ DROP TABLE successful'); + + console.log(''); + console.log('🎉 All tests passed! Your Neon/Vercel Postgres connection is working correctly.'); + + } catch (error: any) { + console.error('❌ Connection failed:'); + console.error(` ${error.message}`); + console.error(''); + console.error('Common fixes:'); + console.error(' 1. Verify connection string format'); + console.error(' 2. Ensure using pooled connection (-pooler. in hostname)'); + console.error(' 3. Add ?sslmode=require to connection string'); + console.error(' 4. Check Neon dashboard for compute status'); + console.error(' 5. Verify database exists and credentials are correct'); + process.exit(1); + } +} + +testConnection(); diff --git a/templates/drizzle-migrations-workflow.md b/templates/drizzle-migrations-workflow.md new file mode 100644 index 0000000..136aed4 --- /dev/null +++ b/templates/drizzle-migrations-workflow.md @@ -0,0 +1,461 @@ +# Drizzle Migrations Workflow for Neon Postgres + +This guide shows the complete workflow for managing database migrations with Drizzle ORM and Neon Postgres. + +## Initial Setup + +### 1. Install Dependencies + +```bash +npm install drizzle-orm @neondatabase/serverless +npm install -D drizzle-kit +``` + +### 2. Create Configuration File + +Create `drizzle.config.ts` in your project root: + +```typescript +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './db/schema.ts', + out: './db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL! + } +}); +``` + +### 3. Add Scripts to package.json + +```json +{ + "scripts": { + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:push": "drizzle-kit push" + } +} +``` + +--- + +## Schema Definition + +Create your schema in `db/schema.ts`: + +```typescript +import { pgTable, serial, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + avatarUrl: text('avatar_url'), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id), + title: text('title').notNull(), + content: text('content'), + published: boolean('published').default(false), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const comments = pgTable('comments', { + id: serial('id').primaryKey(), + postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + userId: integer('user_id').notNull().references(() => users.id), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow(), +}); +``` + +--- + +## Migration Workflow + +### Step 1: Generate Migration + +After creating or modifying your schema, generate a migration: + +```bash +npm run db:generate +``` + +This creates a new migration file in `db/migrations/` directory: +- `db/migrations/0000_initial.sql` - First migration +- `db/migrations/0001_add_published_column.sql` - Second migration +- etc. + +**What happens:** +- Drizzle compares your schema with the database +- Generates SQL migration file with CREATE/ALTER/DROP statements +- Creates snapshot in `db/migrations/meta/` for future comparisons + +### Step 2: Review Migration + +**ALWAYS review generated SQL before applying!** + +Example generated migration (`db/migrations/0000_initial.sql`): + +```sql +CREATE TABLE IF NOT EXISTS "users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL UNIQUE, + "avatar_url" text, + "created_at" timestamp DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "posts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "title" text NOT NULL, + "content" text, + "published" boolean DEFAULT false, + "created_at" timestamp DEFAULT NOW(), + FOREIGN KEY ("user_id") REFERENCES "users"("id") +); + +CREATE TABLE IF NOT EXISTS "comments" ( + "id" serial PRIMARY KEY NOT NULL, + "post_id" integer NOT NULL, + "user_id" integer NOT NULL, + "content" text NOT NULL, + "created_at" timestamp DEFAULT NOW(), + FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE, + FOREIGN KEY ("user_id") REFERENCES "users"("id") +); +``` + +### Step 3: Apply Migration + +Apply the migration to your database: + +```bash +npm run db:migrate +``` + +**What happens:** +- Drizzle connects to your Neon database +- Runs all pending migrations in order +- Records applied migrations in `__drizzle_migrations` table + +**Output:** +``` +✅ Applying migration: 0000_initial.sql +✅ Migration applied successfully +``` + +### Step 4: Verify Migration + +Use Drizzle Studio to verify your schema: + +```bash +npm run db:studio +``` + +Opens a web UI at `https://local.drizzle.studio` where you can: +- View tables and relationships +- Run queries +- Edit data + +--- + +## Common Scenarios + +### Scenario 1: Add New Column + +1. **Update schema** (`db/schema.ts`): + +```typescript +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + avatarUrl: text('avatar_url'), + bio: text('bio'), // ← New column + createdAt: timestamp('created_at').defaultNow(), +}); +``` + +2. **Generate migration**: + +```bash +npm run db:generate +``` + +Generated SQL (`db/migrations/0001_add_bio.sql`): + +```sql +ALTER TABLE "users" ADD COLUMN "bio" text; +``` + +3. **Apply migration**: + +```bash +npm run db:migrate +``` + +### Scenario 2: Add Index + +1. **Update schema** with index: + +```typescript +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id), + title: text('title').notNull(), + content: text('content'), + published: boolean('published').default(false), + createdAt: timestamp('created_at').defaultNow(), +}, (table) => ({ + userIdIdx: index('user_id_idx').on(table.userId), + createdAtIdx: index('created_at_idx').on(table.createdAt), +})); +``` + +2. **Generate and apply**: + +```bash +npm run db:generate +npm run db:migrate +``` + +### Scenario 3: Rename Column + +**⚠️ WARNING**: Drizzle treats renames as DROP + ADD (data loss!) + +**Safe approach:** + +1. Add new column +2. Migrate data (manually) +3. Drop old column + +**Example:** + +```typescript +// Step 1: Add new column +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), // Old + fullName: text('full_name').notNull(), // New + email: text('email').notNull().unique(), +}); +``` + +```bash +npm run db:generate +npm run db:migrate +``` + +```sql +-- Step 2: Migrate data manually +UPDATE users SET full_name = name WHERE full_name IS NULL; +``` + +```typescript +// Step 3: Remove old column +export const users = pgTable('users', { + id: serial('id').primaryKey(), + fullName: text('full_name').notNull(), + email: text('email').notNull().unique(), +}); +``` + +```bash +npm run db:generate +npm run db:migrate +``` + +### Scenario 4: Production Deployment + +For production, use a separate migration script: + +Create `scripts/migrate.ts`: + +```typescript +import { drizzle } from 'drizzle-orm/neon-http'; +import { neon } from '@neondatabase/serverless'; +import { migrate } from 'drizzle-orm/neon-http/migrator'; + +async function runMigrations() { + const sql = neon(process.env.DATABASE_URL!); + const db = drizzle(sql); + + console.log('Running migrations...'); + await migrate(db, { migrationsFolder: './db/migrations' }); + console.log('Migrations completed!'); + + process.exit(0); +} + +runMigrations().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); +``` + +Add to `package.json`: + +```json +{ + "scripts": { + "db:migrate:prod": "tsx scripts/migrate.ts" + } +} +``` + +Run in CI/CD: + +```bash +# Before deployment +npm run db:migrate:prod +``` + +--- + +## Alternative: Push (Schema Sync) + +For **development only**, you can use `db:push` to sync schema without migrations: + +```bash +npm run db:push +``` + +**What it does:** +- Compares schema with database +- Applies changes directly +- **No migration files created** + +**When to use:** +- ✅ Local development (rapid iteration) +- ✅ Prototyping + +**When NOT to use:** +- ❌ Production +- ❌ Shared databases +- ❌ Need migration history + +--- + +## Best Practices + +### 1. Always Review Generated SQL + +```bash +# Generate migration +npm run db:generate + +# Review file before applying +cat db/migrations/0001_*.sql + +# Apply only if safe +npm run db:migrate +``` + +### 2. Test Migrations on Development Database First + +```bash +# Create Neon branch for testing +neonctl branches create --name test-migration --parent main + +# Get branch connection string +export DATABASE_URL=$(neonctl connection-string test-migration) + +# Test migration +npm run db:migrate + +# If successful, apply to main +neonctl branches delete test-migration +export DATABASE_URL=$(neonctl connection-string main) +npm run db:migrate +``` + +### 3. Commit Migration Files to Git + +```bash +git add db/migrations/ +git add db/schema.ts +git commit -m "feat: add bio column to users table" +``` + +### 4. Use Transactions for Multi-Step Migrations + +Drizzle migrations run in transactions by default. If any step fails, all changes roll back. + +### 5. Handle Data Migrations Separately + +For complex data transformations, create custom migration scripts: + +```typescript +// db/migrations/custom/0001_migrate_user_data.ts +import { neon } from '@neondatabase/serverless'; + +const sql = neon(process.env.DATABASE_URL!); + +// Custom data migration +await sql`UPDATE users SET status = 'active' WHERE status IS NULL`; +``` + +--- + +## Troubleshooting + +### Problem: "No schema changes detected" + +**Solution**: Drizzle uses snapshots. If out of sync: + +```bash +# Delete snapshot +rm -rf db/migrations/meta + +# Regenerate +npm run db:generate +``` + +### Problem: "Relation already exists" + +**Solution**: Migration already applied. Check `__drizzle_migrations` table: + +```sql +SELECT * FROM __drizzle_migrations; +``` + +### Problem: "Cannot drop column with data" + +**Solution**: Set column nullable first, then drop: + +```sql +ALTER TABLE users ALTER COLUMN old_column DROP NOT NULL; +ALTER TABLE users DROP COLUMN old_column; +``` + +--- + +## Complete Checklist + +- [ ] `drizzle.config.ts` configured +- [ ] Schema defined in `db/schema.ts` +- [ ] Scripts added to `package.json` +- [ ] `DATABASE_URL` environment variable set +- [ ] Initial migration generated (`npm run db:generate`) +- [ ] Migration reviewed +- [ ] Migration applied (`npm run db:migrate`) +- [ ] Schema verified in Drizzle Studio +- [ ] Migration files committed to git + +--- + +## Resources + +- **Drizzle ORM Docs**: https://orm.drizzle.team/docs/overview +- **Drizzle Kit Docs**: https://orm.drizzle.team/docs/kit-overview +- **Neon + Drizzle Guide**: https://orm.drizzle.team/docs/quick-postgresql/neon +- **Migration Best Practices**: https://orm.drizzle.team/docs/migrations diff --git a/templates/drizzle-queries.ts b/templates/drizzle-queries.ts new file mode 100644 index 0000000..3687bb2 --- /dev/null +++ b/templates/drizzle-queries.ts @@ -0,0 +1,543 @@ +// Drizzle ORM Query Patterns for Neon Postgres +// Type-safe queries with full TypeScript support + +import { db } from './db'; // Assuming you have db/index.ts set up +import { users, posts, comments } from './db/schema'; // Assuming you have db/schema.ts +import { eq, and, or, gt, lt, gte, lte, like, inArray, isNull, isNotNull, desc, asc } from 'drizzle-orm'; + +// ============================================================================ +// SELECT QUERIES +// ============================================================================ + +// Simple select all +export async function getAllUsers() { + const allUsers = await db.select().from(users); + return allUsers; +} + +// Select specific columns +export async function getUserEmails() { + const emails = await db + .select({ + id: users.id, + email: users.email + }) + .from(users); + return emails; +} + +// Select with WHERE clause +export async function getUserById(id: number) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, id)); + + return user || null; +} + +// Select with multiple conditions (AND) +export async function getActiveUserByEmail(email: string) { + const [user] = await db + .select() + .from(users) + .where( + and( + eq(users.email, email), + eq(users.active, true) + ) + ); + + return user || null; +} + +// Select with OR conditions +export async function searchUsers(searchTerm: string) { + const results = await db + .select() + .from(users) + .where( + or( + like(users.name, `%${searchTerm}%`), + like(users.email, `%${searchTerm}%`) + ) + ); + + return results; +} + +// Select with comparison operators +export async function getRecentPosts(daysAgo: number) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysAgo); + + const recentPosts = await db + .select() + .from(posts) + .where(gte(posts.createdAt, cutoffDate)) + .orderBy(desc(posts.createdAt)); + + return recentPosts; +} + +// Select with IN clause +export async function getUsersByIds(ids: number[]) { + const selectedUsers = await db + .select() + .from(users) + .where(inArray(users.id, ids)); + + return selectedUsers; +} + +// Select with NULL checks +export async function getUsersWithoutAvatar() { + const usersNoAvatar = await db + .select() + .from(users) + .where(isNull(users.avatarUrl)); + + return usersNoAvatar; +} + +// ============================================================================ +// JOINS +// ============================================================================ + +// Inner join +export async function getPostsWithAuthors() { + const postsWithAuthors = await db + .select({ + postId: posts.id, + postTitle: posts.title, + postContent: posts.content, + authorId: users.id, + authorName: users.name, + authorEmail: users.email + }) + .from(posts) + .innerJoin(users, eq(posts.userId, users.id)) + .orderBy(desc(posts.createdAt)); + + return postsWithAuthors; +} + +// Left join (get all posts, even without authors) +export async function getAllPostsWithOptionalAuthors() { + const allPosts = await db + .select({ + postId: posts.id, + postTitle: posts.title, + authorName: users.name // Will be null if no author + }) + .from(posts) + .leftJoin(users, eq(posts.userId, users.id)); + + return allPosts; +} + +// Multiple joins +export async function getPostsWithAuthorsAndComments() { + const data = await db + .select({ + postId: posts.id, + postTitle: posts.title, + authorName: users.name, + commentCount: comments.id // Will need aggregation for actual count + }) + .from(posts) + .innerJoin(users, eq(posts.userId, users.id)) + .leftJoin(comments, eq(comments.postId, posts.id)); + + return data; +} + +// ============================================================================ +// INSERT QUERIES +// ============================================================================ + +// Simple insert +export async function createUser(name: string, email: string) { + const [newUser] = await db + .insert(users) + .values({ + name, + email + }) + .returning(); + + return newUser; +} + +// Insert multiple rows +export async function createMultipleUsers(userData: Array<{ name: string; email: string }>) { + const newUsers = await db + .insert(users) + .values(userData) + .returning(); + + return newUsers; +} + +// Insert with default values +export async function createPost(userId: number, title: string, content: string) { + const [newPost] = await db + .insert(posts) + .values({ + userId, + title, + content + // createdAt will use default NOW() + }) + .returning(); + + return newPost; +} + +// ============================================================================ +// UPDATE QUERIES +// ============================================================================ + +// Simple update +export async function updateUserName(id: number, newName: string) { + const [updated] = await db + .update(users) + .set({ name: newName }) + .where(eq(users.id, id)) + .returning(); + + return updated || null; +} + +// Update multiple fields +export async function updateUser(id: number, updates: { name?: string; email?: string; avatarUrl?: string }) { + const [updated] = await db + .update(users) + .set(updates) + .where(eq(users.id, id)) + .returning(); + + return updated || null; +} + +// Conditional update +export async function publishPost(postId: number, userId: number) { + // Only allow user to publish their own post + const [published] = await db + .update(posts) + .set({ published: true, publishedAt: new Date() }) + .where( + and( + eq(posts.id, postId), + eq(posts.userId, userId) + ) + ) + .returning(); + + return published || null; +} + +// ============================================================================ +// DELETE QUERIES +// ============================================================================ + +// Simple delete +export async function deleteUser(id: number) { + const [deleted] = await db + .delete(users) + .where(eq(users.id, id)) + .returning({ id: users.id }); + + return deleted ? true : false; +} + +// Conditional delete +export async function deleteOldPosts(daysAgo: number) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysAgo); + + const deleted = await db + .delete(posts) + .where(lt(posts.createdAt, cutoffDate)) + .returning({ id: posts.id }); + + return deleted.length; +} + +// Delete with complex conditions +export async function deleteUnpublishedDrafts(userId: number, daysOld: number) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + const deleted = await db + .delete(posts) + .where( + and( + eq(posts.userId, userId), + eq(posts.published, false), + lt(posts.createdAt, cutoffDate) + ) + ) + .returning({ id: posts.id }); + + return deleted; +} + +// ============================================================================ +// TRANSACTIONS +// ============================================================================ + +// Transaction example: Transfer credits between users +export async function transferCredits(fromUserId: number, toUserId: number, amount: number) { + const result = await db.transaction(async (tx) => { + // Get sender's balance + const [sender] = await tx + .select() + .from(users) + .where(eq(users.id, fromUserId)) + .for('update'); // Lock row + + if (!sender || sender.credits < amount) { + throw new Error('Insufficient credits'); + } + + // Deduct from sender + const [updatedSender] = await tx + .update(users) + .set({ credits: sender.credits - amount }) + .where(eq(users.id, fromUserId)) + .returning(); + + // Add to recipient + const [updatedRecipient] = await tx + .update(users) + .set({ credits: tx.sql`${users.credits} + ${amount}` }) + .where(eq(users.id, toUserId)) + .returning(); + + // Log transaction + await tx.insert(creditTransfers).values({ + fromUserId, + toUserId, + amount + }); + + return { sender: updatedSender, recipient: updatedRecipient }; + }); + + return result; +} + +// Transaction with rollback +export async function createUserWithProfile(userData: { + name: string; + email: string; + bio?: string; +}) { + try { + const result = await db.transaction(async (tx) => { + // Create user + const [user] = await tx + .insert(users) + .values({ + name: userData.name, + email: userData.email + }) + .returning(); + + // Create profile + const [profile] = await tx + .insert(profiles) + .values({ + userId: user.id, + bio: userData.bio || '' + }) + .returning(); + + return { user, profile }; + }); + + return result; + } catch (error) { + // Transaction automatically rolls back on error + console.error('Failed to create user with profile:', error); + throw error; + } +} + +// ============================================================================ +// AGGREGATIONS +// ============================================================================ + +// Count +export async function countUsers() { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(users); + + return count; +} + +// Group by +export async function getPostCountByUser() { + const counts = await db + .select({ + userId: posts.userId, + userName: users.name, + postCount: sql`count(${posts.id})` + }) + .from(posts) + .innerJoin(users, eq(posts.userId, users.id)) + .groupBy(posts.userId, users.name); + + return counts; +} + +// Having clause +export async function getUsersWithMultiplePosts(minPosts: number) { + const users = await db + .select({ + userId: posts.userId, + userName: users.name, + postCount: sql`count(${posts.id})` + }) + .from(posts) + .innerJoin(users, eq(posts.userId, users.id)) + .groupBy(posts.userId, users.name) + .having(sql`count(${posts.id}) >= ${minPosts}`); + + return users; +} + +// ============================================================================ +// PAGINATION +// ============================================================================ + +export async function getPaginatedPosts(page: number = 1, pageSize: number = 20) { + const offset = (page - 1) * pageSize; + + // Get total count + const [{ total }] = await db + .select({ total: sql`count(*)` }) + .from(posts); + + // Get page data + const postsData = await db + .select() + .from(posts) + .orderBy(desc(posts.createdAt)) + .limit(pageSize) + .offset(offset); + + return { + posts: postsData, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + hasNext: page * pageSize < total, + hasPrev: page > 1 + } + }; +} + +// ============================================================================ +// ADVANCED QUERIES +// ============================================================================ + +// Subquery +export async function getUsersWithNoPosts() { + const usersWithoutPosts = await db + .select() + .from(users) + .where( + sql`${users.id} NOT IN (SELECT DISTINCT user_id FROM posts)` + ); + + return usersWithoutPosts; +} + +// Raw SQL with Drizzle (when needed) +export async function customQuery(userId: number) { + const result = await db.execute( + sql` + SELECT u.*, COUNT(p.id) as post_count + FROM users u + LEFT JOIN posts p ON p.user_id = u.id + WHERE u.id = ${userId} + GROUP BY u.id + ` + ); + + return result.rows[0]; +} + +// Prepared statement (performance optimization) +const getUserByEmailPrepared = db + .select() + .from(users) + .where(eq(users.email, sql.placeholder('email'))) + .prepare('get_user_by_email'); + +export async function getUserByEmail(email: string) { + const [user] = await getUserByEmailPrepared.execute({ email }); + return user || null; +} + +// ============================================================================ +// USAGE EXAMPLE: Cloudflare Worker with Drizzle +// ============================================================================ + +/* +// db/index.ts +import { drizzle } from 'drizzle-orm/neon-http'; +import { neon } from '@neondatabase/serverless'; +import * as schema from './schema'; + +export function getDb(databaseUrl: string) { + const sql = neon(databaseUrl); + return drizzle(sql, { schema }); +} + +// src/index.ts +import { getDb } from './db'; +import { users } from './db/schema'; +import { eq } from 'drizzle-orm'; + +interface Env { + DATABASE_URL: string; +} + +export default { + async fetch(request: Request, env: Env) { + const db = getDb(env.DATABASE_URL); + + const url = new URL(request.url); + + if (url.pathname === '/users' && request.method === 'GET') { + const allUsers = await db.select().from(users); + return Response.json(allUsers); + } + + if (url.pathname.startsWith('/users/') && request.method === 'GET') { + const id = parseInt(url.pathname.split('/')[2]); + const [user] = await db.select().from(users).where(eq(users.id, id)); + + if (!user) { + return new Response('User not found', { status: 404 }); + } + + return Response.json(user); + } + + if (url.pathname === '/users' && request.method === 'POST') { + const { name, email } = await request.json(); + const [user] = await db.insert(users).values({ name, email }).returning(); + return Response.json(user, { status: 201 }); + } + + return new Response('Not Found', { status: 404 }); + } +}; +*/ diff --git a/templates/drizzle-schema.ts b/templates/drizzle-schema.ts new file mode 100644 index 0000000..1753852 --- /dev/null +++ b/templates/drizzle-schema.ts @@ -0,0 +1,178 @@ +/** + * Complete Drizzle Schema Template for Neon/Vercel Postgres + * + * Usage: + * 1. Copy this file to your project: cp assets/drizzle-schema.ts db/schema.ts + * 2. Customize tables to match your app's data model + * 3. Generate migrations: npx drizzle-kit generate + * 4. Apply migrations: npx drizzle-kit migrate + */ + +import { pgTable, serial, text, timestamp, integer, boolean, jsonb, index, unique } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// ============================================================================ +// USERS TABLE +// ============================================================================ + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').notNull().unique(), + name: text('name').notNull(), + avatar: text('avatar'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + // Indexes for common queries + emailIdx: index('users_email_idx').on(table.email), +})); + +// ============================================================================ +// POSTS TABLE +// ============================================================================ + +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + content: text('content'), + published: boolean('published').default(false).notNull(), + slug: text('slug').notNull().unique(), + metadata: jsonb('metadata').$type<{ + views?: number; + likes?: number; + tags?: string[]; + }>(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + // Indexes for common queries + userIdIdx: index('posts_user_id_idx').on(table.userId), + slugIdx: index('posts_slug_idx').on(table.slug), + publishedIdx: index('posts_published_idx').on(table.published), +})); + +// ============================================================================ +// COMMENTS TABLE +// ============================================================================ + +export const comments = pgTable('comments', { + id: serial('id').primaryKey(), + postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + // Indexes for common queries + postIdIdx: index('comments_post_id_idx').on(table.postId), + userIdIdx: index('comments_user_id_idx').on(table.userId), +})); + +// ============================================================================ +// RELATIONS (for Drizzle query API) +// ============================================================================ + +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), + comments: many(comments), +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { + fields: [posts.userId], + references: [users.id], + }), + comments: many(comments), +})); + +export const commentsRelations = relations(comments, ({ one }) => ({ + post: one(posts, { + fields: [comments.postId], + references: [posts.id], + }), + author: one(users, { + fields: [comments.userId], + references: [users.id], + }), +})); + +// ============================================================================ +// TYPE EXPORTS (for TypeScript) +// ============================================================================ + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +export type Post = typeof posts.$inferSelect; +export type NewPost = typeof posts.$inferInsert; + +export type Comment = typeof comments.$inferSelect; +export type NewComment = typeof comments.$inferInsert; + +// ============================================================================ +// USAGE EXAMPLE +// ============================================================================ + +/** + * db/index.ts: + * + * import { drizzle } from 'drizzle-orm/neon-http'; + * import { neon } from '@neondatabase/serverless'; + * import * as schema from './schema'; + * + * const sql = neon(process.env.DATABASE_URL!); + * export const db = drizzle(sql, { schema }); + */ + +/** + * drizzle.config.ts: + * + * import { defineConfig } from 'drizzle-kit'; + * + * export default defineConfig({ + * schema: './db/schema.ts', + * out: './db/migrations', + * dialect: 'postgresql', + * dbCredentials: { + * url: process.env.DATABASE_URL! + * } + * }); + */ + +/** + * Query Examples: + * + * // SELECT with joins + * const postsWithAuthors = await db.query.posts.findMany({ + * with: { + * author: true, + * comments: { + * with: { + * author: true + * } + * } + * } + * }); + * + * // INSERT + * const newUser = await db.insert(users).values({ + * email: 'alice@example.com', + * name: 'Alice' + * }).returning(); + * + * // UPDATE + * await db.update(posts).set({ + * published: true, + * updatedAt: new Date() + * }).where(eq(posts.id, postId)); + * + * // DELETE + * await db.delete(comments).where(eq(comments.id, commentId)); + * + * // Transaction + * await db.transaction(async (tx) => { + * const [user] = await tx.insert(users).values({ email, name }).returning(); + * await tx.insert(posts).values({ userId: user.id, title, content }); + * }); + */ diff --git a/templates/neon-basic-queries.ts b/templates/neon-basic-queries.ts new file mode 100644 index 0000000..958783c --- /dev/null +++ b/templates/neon-basic-queries.ts @@ -0,0 +1,338 @@ +// Basic Neon Postgres Queries +// Template for raw SQL queries using @neondatabase/serverless + +import { neon } from '@neondatabase/serverless'; + +// Initialize connection (in Cloudflare Workers, get from env.DATABASE_URL) +const sql = neon(process.env.DATABASE_URL!); + +// ============================================================================ +// SELECT QUERIES +// ============================================================================ + +// Simple select +export async function getUser(id: number) { + const users = await sql` + SELECT id, name, email, created_at + FROM users + WHERE id = ${id} + `; + return users[0] || null; +} + +// Select with multiple conditions +export async function searchUsers(searchTerm: string, limit: number = 10) { + const users = await sql` + SELECT id, name, email + FROM users + WHERE name ILIKE ${'%' + searchTerm + '%'} + OR email ILIKE ${'%' + searchTerm + '%'} + ORDER BY created_at DESC + LIMIT ${limit} + `; + return users; +} + +// Select with join +export async function getPostsWithAuthors(userId?: number) { + if (userId) { + return await sql` + SELECT + posts.id, + posts.title, + posts.content, + posts.created_at, + users.name as author_name, + users.email as author_email + FROM posts + INNER JOIN users ON posts.user_id = users.id + WHERE posts.user_id = ${userId} + ORDER BY posts.created_at DESC + `; + } + + return await sql` + SELECT + posts.id, + posts.title, + posts.content, + posts.created_at, + users.name as author_name, + users.email as author_email + FROM posts + INNER JOIN users ON posts.user_id = users.id + ORDER BY posts.created_at DESC + LIMIT 50 + `; +} + +// Aggregation query +export async function getUserStats(userId: number) { + const stats = await sql` + SELECT + COUNT(*)::int as post_count, + MAX(created_at) as last_post_at + FROM posts + WHERE user_id = ${userId} + `; + return stats[0]; +} + +// ============================================================================ +// INSERT QUERIES +// ============================================================================ + +// Simple insert with RETURNING +export async function createUser(name: string, email: string) { + const [user] = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING id, name, email, created_at + `; + return user; +} + +// Batch insert +export async function createUsers(users: Array<{ name: string; email: string }>) { + // Note: For large batches, consider inserting in chunks + const values = users.map((user) => [user.name, user.email]); + + const inserted = await sql` + INSERT INTO users (name, email) + SELECT * FROM UNNEST( + ${values.map(v => v[0])}::text[], + ${values.map(v => v[1])}::text[] + ) + RETURNING id, name, email + `; + return inserted; +} + +// ============================================================================ +// UPDATE QUERIES +// ============================================================================ + +// Simple update +export async function updateUser(id: number, name: string, email: string) { + const [updated] = await sql` + UPDATE users + SET name = ${name}, email = ${email} + WHERE id = ${id} + RETURNING id, name, email, created_at + `; + return updated || null; +} + +// Partial update +export async function updateUserPartial(id: number, updates: { name?: string; email?: string }) { + // Only update provided fields + const setClauses: string[] = []; + const values: any[] = []; + + if (updates.name !== undefined) { + setClauses.push(`name = $${values.length + 1}`); + values.push(updates.name); + } + if (updates.email !== undefined) { + setClauses.push(`email = $${values.length + 1}`); + values.push(updates.email); + } + + if (setClauses.length === 0) { + throw new Error('No fields to update'); + } + + values.push(id); + + // Note: Template literals don't support dynamic SET clauses well + // Use Drizzle ORM for more complex partial updates + const [updated] = await sql` + UPDATE users + SET ${sql(setClauses.join(', '))} + WHERE id = ${id} + RETURNING * + `; + return updated || null; +} + +// ============================================================================ +// DELETE QUERIES +// ============================================================================ + +// Simple delete +export async function deleteUser(id: number) { + const [deleted] = await sql` + DELETE FROM users + WHERE id = ${id} + RETURNING id + `; + return deleted ? true : false; +} + +// Delete with condition +export async function deleteOldPosts(daysAgo: number) { + const deleted = await sql` + DELETE FROM posts + WHERE created_at < NOW() - INTERVAL '${daysAgo} days' + RETURNING id + `; + return deleted.length; +} + +// ============================================================================ +// TRANSACTIONS +// ============================================================================ + +// Automatic transaction (recommended) +export async function transferCredits(fromUserId: number, toUserId: number, amount: number) { + const result = await sql.transaction(async (tx) => { + // Deduct from sender + const [sender] = await tx` + UPDATE accounts + SET balance = balance - ${amount} + WHERE user_id = ${fromUserId} AND balance >= ${amount} + RETURNING * + `; + + if (!sender) { + throw new Error('Insufficient balance'); + } + + // Add to recipient + const [recipient] = await tx` + UPDATE accounts + SET balance = balance + ${amount} + WHERE user_id = ${toUserId} + RETURNING * + `; + + // Log transaction + await tx` + INSERT INTO transfers (from_user_id, to_user_id, amount) + VALUES (${fromUserId}, ${toUserId}, ${amount}) + `; + + return { sender, recipient }; + }); + + return result; +} + +// ============================================================================ +// PAGINATION +// ============================================================================ + +export async function getPaginatedPosts(page: number = 1, pageSize: number = 20) { + const offset = (page - 1) * pageSize; + + // Get total count + const [{ total }] = await sql` + SELECT COUNT(*)::int as total FROM posts + `; + + // Get page data + const posts = await sql` + SELECT id, title, content, user_id, created_at + FROM posts + ORDER BY created_at DESC + LIMIT ${pageSize} + OFFSET ${offset} + `; + + return { + posts, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + hasNext: page * pageSize < total, + hasPrev: page > 1 + } + }; +} + +// ============================================================================ +// FULL-TEXT SEARCH (Postgres-specific) +// ============================================================================ + +export async function searchPosts(query: string, limit: number = 10) { + const posts = await sql` + SELECT + id, + title, + content, + ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', ${query})) as rank + FROM posts + WHERE to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ${query}) + ORDER BY rank DESC + LIMIT ${limit} + `; + return posts; +} + +// ============================================================================ +// USAGE EXAMPLES +// ============================================================================ + +// Example: Cloudflare Worker +/* +import { neon } from '@neondatabase/serverless'; + +interface Env { + DATABASE_URL: string; +} + +export default { + async fetch(request: Request, env: Env) { + const sql = neon(env.DATABASE_URL); + + const url = new URL(request.url); + + if (url.pathname === '/users' && request.method === 'GET') { + const users = await sql`SELECT * FROM users LIMIT 10`; + return Response.json(users); + } + + if (url.pathname === '/users' && request.method === 'POST') { + const { name, email } = await request.json(); + const [user] = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING * + `; + return Response.json(user, { status: 201 }); + } + + return new Response('Not Found', { status: 404 }); + } +}; +*/ + +// Example: Next.js Server Action +/* +'use server'; + +import { neon } from '@neondatabase/serverless'; + +const sql = neon(process.env.DATABASE_URL!); + +export async function getUsers() { + const users = await sql`SELECT * FROM users ORDER BY created_at DESC`; + return users; +} + +export async function createUser(formData: FormData) { + const name = formData.get('name') as string; + const email = formData.get('email') as string; + + const [user] = await sql` + INSERT INTO users (name, email) + VALUES (${name}, ${email}) + RETURNING * + `; + + revalidatePath('/users'); + return user; +} +*/ diff --git a/templates/package.json b/templates/package.json new file mode 100644 index 0000000..0ac8154 --- /dev/null +++ b/templates/package.json @@ -0,0 +1,25 @@ +{ + "name": "neon-postgres-project", + "version": "1.0.0", + "description": "Neon Postgres with Drizzle ORM", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:push": "drizzle-kit push", + "db:migrate:prod": "tsx scripts/migrate.ts" + }, + "dependencies": { + "@neondatabase/serverless": "^1.0.2", + "drizzle-orm": "^0.44.7" + }, + "devDependencies": { + "drizzle-kit": "^0.31.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +}