Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal 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
3
README.md
Normal 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.
|
||||
178
assets/drizzle-schema.ts
Normal file
178
assets/drizzle-schema.ts
Normal 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 });
|
||||
* });
|
||||
*/
|
||||
14
assets/example-template.txt
Normal file
14
assets/example-template.txt
Normal 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
85
plugin.lock.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal 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
15
scripts/example-script.sh
Executable 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
102
scripts/test-connection.ts
Normal 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();
|
||||
461
templates/drizzle-migrations-workflow.md
Normal file
461
templates/drizzle-migrations-workflow.md
Normal 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
|
||||
543
templates/drizzle-queries.ts
Normal file
543
templates/drizzle-queries.ts
Normal 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
178
templates/drizzle-schema.ts
Normal 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 });
|
||||
* });
|
||||
*/
|
||||
338
templates/neon-basic-queries.ts
Normal file
338
templates/neon-basic-queries.ts
Normal 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
25
templates/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user