Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:25:01 +08:00
commit 45ffb77c40
14 changed files with 3275 additions and 0 deletions

View File

@@ -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": [
"./"
]
}

3
README.md Normal file
View File

@@ -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.

1295
SKILL.md Normal file

File diff suppressed because it is too large Load Diff

178
assets/drizzle-schema.ts Normal file
View File

@@ -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 });
* });
*/

View File

@@ -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.

85
plugin.lock.json Normal file
View File

@@ -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": []
}
}

View File

@@ -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.

15
scripts/example-script.sh Executable file
View File

@@ -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]

102
scripts/test-connection.ts Normal file
View File

@@ -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();

View File

@@ -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

View File

@@ -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<number>`count(*)` })
.from(users);
return count;
}
// Group by
export async function getPostCountByUser() {
const counts = await db
.select({
userId: posts.userId,
userName: users.name,
postCount: sql<number>`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<number>`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<number>`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 });
}
};
*/

178
templates/drizzle-schema.ts Normal file
View File

@@ -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 });
* });
*/

View File

@@ -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;
}
*/

25
templates/package.json Normal file
View File

@@ -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"
}
}