feat: migrate from PocketBase to PostgreSQL with Drizzle ORM

- Add @project-e/db package with Drizzle schema and migrations
- Replace PocketBase client with PostgreSQL-based database client
- Migrate auth from custom to NextAuth.js
- Add Docker Compose with PostgreSQL container
- Update worker to use new database client
- Remove PocketBase-specific files and migrations
- Add drizzle config and initial migration
This commit is contained in:
2026-07-24 07:08:29 -04:00
parent 6c438eab32
commit 73335484f8
42 changed files with 2895 additions and 2796 deletions
+6 -8
View File
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -21,16 +22,13 @@ export default function LoginPage() {
setLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (!response.ok) {
const error = await response.json();
throw error;
}
if (!result?.ok) throw new Error('Unable to sign in. Check your credentials and try again.');
router.push('/dashboard');
} catch (error) {
@@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth-config';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
-52
View File
@@ -1,52 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = loginSchema.parse(body);
const pb = createPocketBaseClient();
// Authenticate with PocketBase
const authData = await pb.collection('users').authWithPassword(email, password);
// Set auth token in httpOnly cookie
const response = NextResponse.json({
user: {
id: authData.record.id,
email: authData.record.email,
name: authData.record.name || authData.record.email,
},
token: authData.token,
});
response.cookies.set('pb_auth', authData.token, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: error.issues } },
{ status: 400 }
);
}
return NextResponse.json(
{ error: { code: 'AUTH_ERROR', message: 'Invalid email or password' } },
{ status: 401 }
);
}
}
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
// Clear auth cookie
response.cookies.set('pb_auth', '', {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
path: '/',
maxAge: 0, // Expire immediately
});
return response;
}
+4 -16
View File
@@ -1,29 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { getAuthUser } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('pb_auth')?.value;
if (!token) {
const user = await getAuthUser(request);
if (!user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
{ status: 401 }
);
}
const pb = createPocketBaseClient(token);
// Get current user
const authData = await pb.collection('users').authRefresh();
return NextResponse.json({
user: {
id: authData.record.id,
email: authData.record.email,
name: authData.record.name || authData.record.email,
},
});
return NextResponse.json({ user });
} catch {
return NextResponse.json(
{ error: { code: 'AUTH_ERROR', message: 'Invalid or expired token' } },
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createPocketBaseClient } from '@/lib/pocketbase';
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('pb_auth')?.value;
if (!token) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'No auth token' } },
{ status: 401 }
);
}
const pb = createPocketBaseClient(token);
// Refresh the auth token
await pb.collection('users').authRefresh();
const newToken = pb.authStore.token;
const response = NextResponse.json({
token: newToken,
});
response.cookies.set('pb_auth', newToken, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
} catch {
return NextResponse.json(
{ error: { code: 'AUTH_ERROR', message: 'Token refresh failed' } },
{ status: 401 }
);
}
}
+19 -40
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import { getAuthUser, getAuthToken } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { getAuthUser } from '@/lib/auth';
import postgres from 'postgres';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes
@@ -15,7 +15,7 @@ const DEFAULT_COLLECTIONS = [
'notifications',
];
// GET /api/realtime — Multiplexed SSE endpoint for PocketBase realtime subscriptions
// GET /api/realtime — Multiplexed SSE endpoint backed by PostgreSQL LISTEN/NOTIFY.
export async function GET(request: NextRequest) {
const user = await getAuthUser(request);
if (!user) {
@@ -36,10 +36,10 @@ export async function GET(request: NextRequest) {
const subscribedCollections =
collections.length > 0 ? collections : DEFAULT_COLLECTIONS;
const token = getAuthToken(request);
const pb = createPocketBaseClient(token || undefined);
const encoder = new TextEncoder();
const unsubscribeFns: Array<() => Promise<void>> = [];
const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
let unlisten: (() => Promise<void>) | undefined;
let keepalive: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream({
async start(controller) {
@@ -50,53 +50,32 @@ export async function GET(request: NextRequest) {
)
);
// Subscribe to each collection
for (const collection of subscribedCollections) {
const subscription = await listener.listen('project_e_events', (payload) => {
try {
const unsub = await pb.collection(collection).subscribe('*', (e) => {
try {
const event = {
type: e.action,
collection,
record: e.record,
};
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
);
} catch {
// Controller might be closed
}
});
unsubscribeFns.push(unsub);
const event = JSON.parse(payload) as { collection?: string };
if (!event.collection || subscribedCollections.includes(event.collection)) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
} catch {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'subscription_error', collection })}\n\n`
)
);
// Ignore malformed database notifications and closed streams.
}
}
});
unlisten = subscription.unlisten;
// Keepalive ping every 30 seconds
const keepalive = setInterval(() => {
keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(':ping\n\n'));
} catch {
clearInterval(keepalive);
if (keepalive) clearInterval(keepalive);
}
}, 30000);
},
async cancel() {
// Client disconnected — cleanup all subscriptions
for (const unsub of unsubscribeFns) {
try {
await unsub();
} catch {
// Ignore cleanup errors
}
}
unsubscribeFns.length = 0;
if (keepalive) clearInterval(keepalive);
await unlisten?.();
await listener.end({ timeout: 5 });
},
});
+52
View File
@@ -0,0 +1,52 @@
import bcrypt from 'bcryptjs';
import { count, eq } from 'drizzle-orm';
import type { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { db, users } from '@project-e/db';
export const authOptions: NextAuthOptions = {
session: { strategy: 'jwt' },
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const email = credentials?.email?.trim().toLowerCase();
const password = credentials?.password;
if (!email || !password) return null;
let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!user) {
const [{ total }] = await db.select({ total: count() }).from(users);
const initialEmail = process.env.INITIAL_ADMIN_EMAIL?.trim().toLowerCase();
const initialPassword = process.env.INITIAL_ADMIN_PASSWORD;
if (total === 0 && email === initialEmail && password === initialPassword) {
[user] = await db.insert(users).values({
email,
name: process.env.INITIAL_ADMIN_NAME || email,
passwordHash: await bcrypt.hash(password, 12),
}).returning();
}
}
if (!user || !(await bcrypt.compare(password, user.passwordHash))) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
if (session.user) session.user.id = token.id as string;
return session;
},
},
pages: { signIn: '/login' },
};
+10 -28
View File
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from './auth-config';
export interface AuthUser {
id: string;
@@ -10,7 +12,9 @@ export interface AuthUser {
* Extract auth token from request cookies
*/
export function getAuthToken(request: NextRequest): string | null {
return request.cookies.get('pb_auth')?.value || null;
return request.cookies.get('next-auth.session-token')?.value
|| request.cookies.get('__Secure-next-auth.session-token')?.value
|| null;
}
/**
@@ -18,32 +22,10 @@ export function getAuthToken(request: NextRequest): string | null {
* Returns null if not authenticated
*/
export async function getAuthUser(request: NextRequest): Promise<AuthUser | null> {
const token = getAuthToken(request);
if (!token) return null;
try {
// Decode JWT to get user ID
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
const userId = payload.id;
// Use raw fetch with admin token to get the user record
// (avoids PocketBase SDK authStore issues with superuser tokens)
const adminToken = process.env.POCKETBASE_ADMIN_TOKEN || '';
const pbUrl = process.env.POCKETBASE_URL || 'http://localhost:8090';
const res = await fetch(pbUrl + '/api/collections/users/records/' + userId, {
headers: { Authorization: adminToken },
});
if (!res.ok) return null;
const record = await res.json();
return {
id: record.id,
email: record.email,
name: record.name || record.email,
};
} catch {
return null;
}
if (!getAuthToken(request)) return null;
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email) return null;
return { id: session.user.id, email: session.user.email, name: session.user.name || session.user.email };
}
/**
@@ -145,4 +127,4 @@ export function createErrorResponse(
},
{ status }
);
}
}
+154
View File
@@ -0,0 +1,154 @@
import { and, eq } from 'drizzle-orm';
import { db, records, sql } from '@project-e/db';
export const collectionNames = [
'domains', 'tags', 'projects', 'project_settings', 'milestones',
'milestone_dependencies', 'milestone_templates', 'milestone_history', 'tasks',
'task_subtasks', 'task_dependencies', 'task_attachments', 'task_time_entries',
'time_entries', 'habits', 'habit_logs', 'habit_skip_days', 'notes', 'note_links',
'note_task_links', 'report_templates', 'reports', 'canvases', 'canvas_cards',
'agents', 'agent_activity', 'webhooks', 'webhook_deliveries', 'agent_tasks',
'notifications', 'error_logs', 'queue_jobs',
] as const;
type RecordData = Record<string, any>;
type ListOptions = { filter?: string; sort?: string };
function serialize(record: typeof records.$inferSelect): RecordData {
return {
...record.data,
id: record.id,
created: record.createdAt.toISOString(),
updated: record.updatedAt.toISOString(),
collectionName: record.collection,
};
}
function valueFor(record: RecordData, field: string): unknown {
if (field === 'id' || field === 'created' || field === 'updated') return record[field];
return record[field];
}
function parseValue(raw: string): unknown {
const value = raw.trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1).replace(/\\"/g, '"');
}
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null') return null;
const number = Number(value);
return Number.isNaN(number) ? value : number;
}
function matchesFilter(record: RecordData, filter?: string): boolean {
if (!filter) return true;
return filter.split('||').some((orPart) => orPart.split('&&').every((term) => {
const match = term.trim().match(/^([a-zA-Z_][\w]*)\s*(=|!=|<=|>=|<|>)\s*(.+)$/);
if (!match) return false;
const [, field, operator, rawExpected] = match;
const actual = valueFor(record, field);
const expected = parseValue(rawExpected);
switch (operator) {
case '=': return actual === expected;
case '!=': return actual !== expected;
case '<': return String(actual ?? '') < String(expected ?? '');
case '<=': return String(actual ?? '') <= String(expected ?? '');
case '>': return String(actual ?? '') > String(expected ?? '');
case '>=': return String(actual ?? '') >= String(expected ?? '');
default: return false;
}
}));
}
function sortRecords(items: RecordData[], sort?: string): RecordData[] {
if (!sort) return items;
const descending = sort.startsWith('-');
const field = descending ? sort.slice(1) : sort;
return [...items].sort((a, b) => {
const left = valueFor(a, field);
const right = valueFor(b, field);
const comparison = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
return descending ? -comparison : comparison;
});
}
function cleanData(data: RecordData): RecordData {
const { id: _id, created: _created, updated: _updated, collectionId: _collectionId, collectionName: _collectionName, ...clean } = data;
return clean;
}
async function notify(action: 'create' | 'update' | 'delete', collection: string, record: RecordData) {
await sql`select pg_notify('project_e_events', ${JSON.stringify({ type: action, collection, record })})`;
}
class CollectionRepository {
constructor(private readonly collectionName: string) {}
async getOne(id: string): Promise<RecordData> {
const [record] = await db.select().from(records).where(and(eq(records.id, id), eq(records.collection, this.collectionName))).limit(1);
if (!record) throw new Error(`Record ${id} not found in ${this.collectionName}`);
return serialize(record);
}
async getFullList(options: ListOptions = {}): Promise<RecordData[]> {
const rows = await db.select().from(records).where(eq(records.collection, this.collectionName));
return sortRecords(rows.map(serialize).filter((record) => matchesFilter(record, options.filter)), options.sort);
}
async getList(page = 1, perPage = 50, options: ListOptions = {}) {
const items = await this.getFullList(options);
const totalItems = items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
return {
items: items.slice((page - 1) * perPage, page * perPage),
page,
perPage,
totalItems,
totalPages,
};
}
async create(data: RecordData): Promise<RecordData> {
const [record] = await db.insert(records).values({ collection: this.collectionName, data: cleanData(data) }).returning();
const result = serialize(record);
await notify('create', this.collectionName, result);
return result;
}
async update(id: string, data: RecordData): Promise<RecordData> {
const existing = await this.getOne(id);
const [record] = await db.update(records)
.set({ data: cleanData({ ...existing, ...data }), updatedAt: new Date() })
.where(and(eq(records.id, id), eq(records.collection, this.collectionName)))
.returning();
if (!record) throw new Error(`Record ${id} not found in ${this.collectionName}`);
const result = serialize(record);
await notify('update', this.collectionName, result);
return result;
}
async delete(id: string): Promise<boolean> {
const existing = await this.getOne(id);
await db.delete(records).where(and(eq(records.id, id), eq(records.collection, this.collectionName)));
await notify('delete', this.collectionName, existing);
return true;
}
}
export function createDatabaseClient() {
return {
collection(name: string) {
if (!collectionNames.includes(name as (typeof collectionNames)[number])) {
throw new Error(`Unknown collection: ${name}`);
}
return new CollectionRepository(name);
},
};
}
export function createAdminClient() {
return createDatabaseClient();
}
+19 -38
View File
@@ -1,37 +1,18 @@
import PocketBase from 'pocketbase';
import { createAdminClient as createDatabaseAdminClient, createDatabaseClient } from './database';
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://localhost:8090';
/**
* Create a PocketBase client instance
* @param token - Optional auth token for authenticated requests
*/
export function createPocketBaseClient(token?: string): PocketBase {
const pb = new PocketBase(pocketbaseUrl);
if (token) {
pb.authStore.save(token, null);
}
return pb;
/** @deprecated Import from `@/lib/database` in new code. */
export function createPocketBaseClient(_token?: string) {
return createDatabaseClient();
}
/**
* Get admin token from environment
*/
/** @deprecated PostgreSQL access is authenticated by the application session. */
export function getAdminToken(): string {
return process.env.POCKETBASE_ADMIN_TOKEN || '';
return '';
}
/**
* Create an admin-authenticated PocketBase client
* Used for server-side operations that need admin privileges
*/
export function createAdminClient(): PocketBase {
const pb = new PocketBase(pocketbaseUrl);
const adminToken = getAdminToken();
if (adminToken) {
pb.authStore.save(adminToken, { id: 'admin', email: 'admin@projecte.local', collectionId: '_superusers', collectionName: '_superusers' } as any);
}
return pb;
/** @deprecated Import from `@/lib/database` in new code. */
export function createAdminClient() {
return createDatabaseAdminClient();
}
/**
@@ -42,8 +23,8 @@ export async function getRecord<T extends Record<string, unknown>>(
id: string,
token?: string
): Promise<T> {
const pb = createPocketBaseClient(token);
return pb.collection(collection).getOne(id) as Promise<T>;
const db = createPocketBaseClient(token);
return db.collection(collection).getOne(id) as Promise<T>;
}
/**
@@ -59,8 +40,8 @@ export async function listRecords<T extends Record<string, unknown>>(
token?: string;
}
): Promise<{ items: T[]; totalItems: number; totalPages: number }> {
const pb = createPocketBaseClient(options?.token);
const result = await pb.collection(collection).getList(
const db = createPocketBaseClient(options?.token);
const result = await db.collection(collection).getList(
options?.page || 1,
options?.perPage || 50,
{
@@ -83,8 +64,8 @@ export async function createRecord<T extends Record<string, unknown>>(
data: Partial<T>,
token?: string
): Promise<T> {
const pb = createPocketBaseClient(token);
return pb.collection(collection).create(data) as Promise<T>;
const db = createPocketBaseClient(token);
return db.collection(collection).create(data) as Promise<T>;
}
/**
@@ -96,8 +77,8 @@ export async function updateRecord<T extends Record<string, unknown>>(
data: Partial<T>,
token?: string
): Promise<T> {
const pb = createPocketBaseClient(token);
return pb.collection(collection).update(id, data) as Promise<T>;
const db = createPocketBaseClient(token);
return db.collection(collection).update(id, data) as Promise<T>;
}
/**
@@ -108,6 +89,6 @@ export async function deleteRecord(
id: string,
token?: string
): Promise<boolean> {
const pb = createPocketBaseClient(token);
return pb.collection(collection).delete(id);
const db = createPocketBaseClient(token);
return db.collection(collection).delete(id);
}
+2 -1
View File
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Check if user is authenticated
const token = request.cookies.get('pb_auth')?.value;
const token = request.cookies.get('next-auth.session-token')?.value
|| request.cookies.get('__Secure-next-auth.session-token')?.value;
// If no token and trying to access protected routes, redirect to login
const protectedRoutes = [
+11
View File
@@ -0,0 +1,11 @@
import 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
};
}
}
+1 -2
View File
@@ -7,9 +7,8 @@ const allowedHosts = (process.env.ALLOWED_HOSTS || 'localhost')
.filter(Boolean);
const nextConfig: NextConfig = {
transpilePackages: ['@project-e/shared'],
transpilePackages: ['@project-e/shared', '@project-e/db'],
output: 'standalone',
serverExternalPackages: ['pocketbase'],
// Allow requests from configured hosts (for Nginx Proxy Manager)
allowedDevOrigins: allowedHosts,
+6 -1
View File
@@ -16,6 +16,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@project-e/db": "^0.1.0",
"@project-e/shared": "*",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-alert-dialog": "^1.1.19",
@@ -50,13 +51,16 @@
"@tiptap/starter-kit": "^3.27.4",
"@types/react-big-calendar": "^1.16.3",
"@types/react-grid-layout": "^1.3.6",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.24.0",
"next": "^15.3.0",
"pocketbase": "^0.27.0",
"next-auth": "^4.24.15",
"postgres": "^3.4.9",
"react": "^19.1.0",
"react-big-calendar": "^1.20.0",
"react-calendar-heatmap": "^1.10.0",
@@ -73,6 +77,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.19.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
File diff suppressed because one or more lines are too long