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:
+8
-3
@@ -1,9 +1,14 @@
|
|||||||
# Project E Environment Configuration
|
# Project E Environment Configuration
|
||||||
# Copy this file to .env and fill in the values
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
# PocketBase Configuration
|
# PostgreSQL Configuration
|
||||||
POCKETBASE_URL=http://db:8090
|
POSTGRES_PASSWORD=replace-with-a-long-random-password
|
||||||
POCKETBASE_ADMIN_TOKEN=your-secure-random-token-here
|
DATABASE_URL=postgresql://project_e:replace-with-a-long-random-password@localhost:5432/project_e
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
NEXTAUTH_SECRET=replace-with-a-long-random-secret
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=replace-with-a-long-random-password
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ pocketbase/pb_data/
|
|||||||
*.tmp
|
*.tmp
|
||||||
.wrangler/
|
.wrangler/
|
||||||
.vinext/
|
.vinext/
|
||||||
|
pb_data/
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM alpine:latest
|
|
||||||
|
|
||||||
ARG POCKETBASE_VERSION=0.25.5
|
|
||||||
|
|
||||||
RUN apk add --no-cache unzip wget ca-certificates
|
|
||||||
|
|
||||||
RUN wget -O /tmp/pocketbase.zip https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/pocketbase_${POCKETBASE_VERSION}_linux_amd64.zip \
|
|
||||||
&& unzip /tmp/pocketbase.zip -d /usr/local/bin/ \
|
|
||||||
&& rm /tmp/pocketbase.zip \
|
|
||||||
&& chmod +x /usr/local/bin/pocketbase
|
|
||||||
|
|
||||||
# Copy migrations
|
|
||||||
COPY pocketbase/pb_migrations/ /pb_migrations/
|
|
||||||
|
|
||||||
EXPOSE 8090
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--migrationsDir=/pb_migrations"]
|
|
||||||
@@ -3,12 +3,15 @@ FROM node:22-alpine AS deps
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
COPY packages/db/package.json packages/db/package.json
|
||||||
COPY packages/shared/package.json packages/shared/package.json
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Stage 2: Build the application
|
# Stage 2: Build the application
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ARG DATABASE_URL=postgresql://project_e:build-only@localhost:5432/project_e
|
||||||
|
ENV DATABASE_URL=${DATABASE_URL}
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY worker/package.json worker/package.json
|
COPY worker/package.json worker/package.json
|
||||||
COPY packages/shared/package.json packages/shared/package.json
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
COPY packages/db/package.json packages/db/package.json
|
||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY worker/ ./worker/
|
COPY worker/ ./worker/
|
||||||
COPY packages/shared/ ./packages/shared/
|
COPY packages/shared/ ./packages/shared/
|
||||||
|
COPY packages/db/ ./packages/db/
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ A personal project, habit, and task tracker built for the AI-agent era. Track ta
|
|||||||
│ API Layer (Next.js Routes) │
|
│ API Layer (Next.js Routes) │
|
||||||
│ Auth · Validation (Zod) · Realtime SSE Proxy · MCP Server │
|
│ Auth · Validation (Zod) · Realtime SSE Proxy · MCP Server │
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
│ PocketBase SDK
|
│ Drizzle ORM
|
||||||
┌──────────────────────────▼──────────────────────────────────┐
|
┌──────────────────────────▼──────────────────────────────────┐
|
||||||
│ Data Layer (PocketBase) │
|
│ Data Layer (PostgreSQL + Drizzle ORM) │
|
||||||
│ SQLite · Auth · Realtime · File Storage · Admin UI │
|
│ PostgreSQL · Drizzle migrations · NextAuth credentials │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
@@ -50,7 +50,7 @@ A personal project, habit, and task tracker built for the AI-agent era. Track ta
|
|||||||
| Frontend | Next.js 15, React 19, TypeScript 5.9 |
|
| Frontend | Next.js 15, React 19, TypeScript 5.9 |
|
||||||
| UI Components | shadcn/ui, Radix UI, Lucide icons |
|
| UI Components | shadcn/ui, Radix UI, Lucide icons |
|
||||||
| Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority |
|
| Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority |
|
||||||
| State Management | Zustand 5 (UI), PocketBase realtime (data) |
|
| State Management | Zustand 5 |
|
||||||
| Rich Text | Tiptap 3 |
|
| Rich Text | Tiptap 3 |
|
||||||
| Forms | React Hook Form 7, Zod 4 validation |
|
| Forms | React Hook Form 7, Zod 4 validation |
|
||||||
| Calendar | react-big-calendar, date-fns |
|
| Calendar | react-big-calendar, date-fns |
|
||||||
@@ -58,19 +58,18 @@ A personal project, habit, and task tracker built for the AI-agent era. Track ta
|
|||||||
| Graph Visualization | react-force-graph-2d |
|
| Graph Visualization | react-force-graph-2d |
|
||||||
| Drag & Drop | @dnd-kit |
|
| Drag & Drop | @dnd-kit |
|
||||||
| Backend | Next.js API routes (App Router) |
|
| Backend | Next.js API routes (App Router) |
|
||||||
| Database | PocketBase 0.25 (SQLite) |
|
| Database | PostgreSQL 16 with Drizzle ORM |
|
||||||
|
| Authentication | NextAuth 4 with credentials authentication |
|
||||||
| Background Jobs | Node.js worker with polling and exponential backoff |
|
| Background Jobs | Node.js worker with polling and exponential backoff |
|
||||||
| MCP Server | @modelcontextprotocol/sdk 1.29 |
|
| MCP Server | @modelcontextprotocol/sdk 1.29 |
|
||||||
| Monorepo | Turborepo 2.5, npm workspaces |
|
| Monorepo | Turborepo 2.5, npm workspaces |
|
||||||
| Testing | Jest (unit/component), Playwright 1.61 (E2E) |
|
| Testing | Jest (unit/component), Playwright 1.61 (E2E) |
|
||||||
| Deployment | Docker Compose (3 containers) |
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** 22.13.0 or later
|
- **Node.js** 22.13.0 or later
|
||||||
- **npm** 10.0.0 or later
|
- **npm** 10.0.0 or later
|
||||||
- **Docker** and Docker Compose (for production deployment)
|
- **PostgreSQL** 16 or later
|
||||||
- **PocketBase** 0.25.5 (included in Docker setup)
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -89,68 +88,46 @@ A personal project, habit, and task tracker built for the AI-agent era. Track ta
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Start PocketBase** (in a separate terminal)
|
3. **Create the database**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download PocketBase if you haven't already
|
createuser -P project_e
|
||||||
# https://pocketbase.io/docs/
|
createdb -O project_e project_e
|
||||||
|
```
|
||||||
# Start PocketBase with migrations
|
|
||||||
pocketbase serve --dir=./pb_data --publicDir=./pb_public --migrationDir=./pocketbase/pb_migrations
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up db -d
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Set environment variables**
|
4. **Set environment variables**
|
||||||
|
|
||||||
Create a `.env.local` file in the root:
|
Create a `.env.local` file in the root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POCKETBASE_URL=http://localhost:8090
|
DATABASE_URL=postgresql://project_e:your_postgres_password@localhost:5432/project_e
|
||||||
POCKETBASE_ADMIN_TOKEN=your_admin_token_here
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
```
|
NEXTAUTH_SECRET=your_long_random_secret
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=your_initial_admin_password
|
||||||
|
```
|
||||||
|
|
||||||
Get the admin token from PocketBase after creating your first admin account.
|
`INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` create the first admin account when you sign in with those credentials.
|
||||||
|
|
||||||
5. **Start the development server**
|
5. **Apply the database schema**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "postgresql://project_e:your_postgres_password@localhost:5432/project_e" -f drizzle/0000_postgres.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Start the development server**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts all packages via Turborepo:
|
This starts all packages via Turborepo:
|
||||||
- Web app at `http://localhost:3000`
|
- Web app at `http://localhost:3000`
|
||||||
- PocketBase at `http://localhost:8090`
|
- Worker (if configured)
|
||||||
- Worker (if configured)
|
|
||||||
|
|
||||||
6. **Create your first user**
|
7. **Sign in as the initial admin**
|
||||||
|
|
||||||
Open `http://localhost:3000` and sign up, or use the PocketBase admin UI at `http://localhost:8090/_/` to create users.
|
Open `http://localhost:3000` and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`.
|
||||||
|
|
||||||
### Production Deployment (Docker)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and start all containers
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Stop all containers
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
The deployment starts three containers:
|
|
||||||
- **web:** Next.js app on port 3000
|
|
||||||
- **db:** PocketBase on port 8090
|
|
||||||
- **worker:** Background job processor
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -174,38 +151,33 @@ project-e/
|
|||||||
│ │ │ ├── agents/ # Agent CRUD
|
│ │ │ ├── agents/ # Agent CRUD
|
||||||
│ │ │ ├── webhooks/ # Webhook CRUD
|
│ │ │ ├── webhooks/ # Webhook CRUD
|
||||||
│ │ │ ├── analytics/ # Analytics data
|
│ │ │ ├── analytics/ # Analytics data
|
||||||
│ │ │ ├── realtime/ # SSE proxy for PocketBase
|
│ │ │ ├── realtime/ # SSE updates
|
||||||
│ │ │ ├── mcp/ # MCP server endpoint
|
│ │ │ ├── mcp/ # MCP server endpoint
|
||||||
│ │ │ └── health/ # Health check
|
│ │ │ └── health/ # Health check
|
||||||
│ │ └── layout.tsx # Root layout
|
│ │ └── layout.tsx # Root layout
|
||||||
│ ├── components/ # React components (shadcn/ui)
|
│ ├── components/ # React components (shadcn/ui)
|
||||||
│ ├── hooks/ # Custom React hooks
|
│ ├── hooks/ # Custom React hooks
|
||||||
│ ├── lib/ # Utilities and services
|
│ ├── lib/ # Utilities, services, and NextAuth config
|
||||||
│ │ ├── mcp/ # MCP server and tools
|
│ │ ├── mcp/ # MCP server and tools
|
||||||
│ │ ├── services/ # Business logic services
|
│ │ ├── services/ # Business logic services
|
||||||
│ │ ├── stores/ # Zustand stores
|
│ │ ├── stores/ # Zustand stores
|
||||||
│ │ ├── events/ # Event bus
|
│ │ ├── events/ # Event bus
|
||||||
│ │ ├── auth.ts # Auth middleware
|
│ │ ├── auth-config.ts # NextAuth configuration
|
||||||
│ │ ├── pocketbase.ts # PocketBase client
|
|
||||||
│ │ └── errors.ts # Error handling
|
│ │ └── errors.ts # Error handling
|
||||||
│ └── types/ # TypeScript type definitions
|
│ └── types/ # TypeScript type definitions
|
||||||
├── packages/
|
├── packages/
|
||||||
|
│ ├── db/ # Drizzle schema and PostgreSQL client
|
||||||
│ └── shared/ # Shared package
|
│ └── shared/ # Shared package
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── schemas/ # Zod validation schemas
|
│ ├── schemas/ # Zod validation schemas
|
||||||
│ ├── types/ # TypeScript types
|
│ ├── types/ # TypeScript types
|
||||||
│ └── constants/ # Shared constants
|
│ └── constants/ # Shared constants
|
||||||
├── pocketbase/
|
├── drizzle/ # Generated PostgreSQL migrations
|
||||||
│ ├── pb_migrations/ # Database migrations
|
|
||||||
│ └── schema.ts # TypeScript types for collections
|
|
||||||
├── worker/
|
├── worker/
|
||||||
│ └── index.ts # Background job worker
|
│ └── index.ts # Background job worker
|
||||||
├── e2e/ # Playwright E2E tests
|
├── e2e/ # Playwright E2E tests
|
||||||
├── tests/ # Unit and component tests
|
├── tests/ # Unit and component tests
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
├── drizzle.config.ts # Drizzle Kit configuration
|
||||||
├── Dockerfile.web # Web container build
|
|
||||||
├── Dockerfile.pocketbase # PocketBase container build
|
|
||||||
├── Dockerfile.worker # Worker container build
|
|
||||||
├── turbo.json # Turborepo configuration
|
├── turbo.json # Turborepo configuration
|
||||||
└── package.json # Root package.json
|
└── package.json # Root package.json
|
||||||
```
|
```
|
||||||
@@ -222,14 +194,17 @@ project-e/
|
|||||||
| `npm run test:e2e:ui` | Run Playwright tests with UI mode |
|
| `npm run test:e2e:ui` | Run Playwright tests with UI mode |
|
||||||
| `npm run test:e2e:report` | Show Playwright test report |
|
| `npm run test:e2e:report` | Show Playwright test report |
|
||||||
| `npm run typecheck` | Run TypeScript type checking |
|
| `npm run typecheck` | Run TypeScript type checking |
|
||||||
| `npm run db:generate` | Generate database types |
|
| `npm run db:generate` | Generate Drizzle migrations |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `POCKETBASE_URL` | PocketBase server URL | `http://localhost:8090` |
|
| `DATABASE_URL` | PostgreSQL connection string | (required) |
|
||||||
| `POCKETBASE_ADMIN_TOKEN` | Admin authentication token | (required for worker) |
|
| `POSTGRES_PASSWORD` | Password for the `project_e` PostgreSQL user | (required) |
|
||||||
|
| `NEXTAUTH_SECRET` | Secret used to sign NextAuth sessions | (required) |
|
||||||
|
| `INITIAL_ADMIN_EMAIL` | Email for the account created on first sign-in | (required) |
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` | Password for the account created on first sign-in | (required) |
|
||||||
| `NODE_ENV` | Environment (`development`, `production`) | `development` |
|
| `NODE_ENV` | Environment (`development`, `production`) | `development` |
|
||||||
|
|
||||||
Create a `.env.local` file in the root directory for local development.
|
Create a `.env.local` file in the root directory for local development.
|
||||||
@@ -271,41 +246,29 @@ Tests run against five browser configurations: Chromium, Firefox, WebKit, Mobile
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and start
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Stop
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Rebuild after code changes
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
Set these in your deployment environment:
|
Provision PostgreSQL, apply `drizzle/0000_postgres.sql`, and set these in your deployment environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POCKETBASE_URL=http://db:8090
|
DATABASE_URL=postgresql://project_e:your_postgres_password@your-postgres-host:5432/project_e
|
||||||
POCKETBASE_ADMIN_TOKEN=your_secure_admin_token
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
|
NEXTAUTH_SECRET=your_long_random_secret
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=your_initial_admin_password
|
||||||
```
|
```
|
||||||
|
|
||||||
### Volumes
|
Start the web app and worker after the database is available:
|
||||||
|
|
||||||
- `project-e-pb-data`: PocketBase database files
|
```bash
|
||||||
- `project-e-web-uploads`: Uploaded files
|
npm run build
|
||||||
|
npm run --workspace @project-e/web start
|
||||||
|
npm run --workspace @project-e/worker start
|
||||||
|
```
|
||||||
|
|
||||||
### Health Checks
|
### Health Checks
|
||||||
|
|
||||||
All containers include health checks:
|
Use `GET /api/health` to check the web app.
|
||||||
- Web: `GET /api/health`
|
|
||||||
- PocketBase: `GET /api/health`
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -21,16 +22,13 @@ export default function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/login', {
|
const result = await signIn('credentials', {
|
||||||
method: 'POST',
|
email,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
password,
|
||||||
body: JSON.stringify({ email, password }),
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!result?.ok) throw new Error('Unable to sign in. Check your credentials and try again.');
|
||||||
const error = await response.json();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error) {
|
} 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 };
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
import { getAuthUser } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const token = request.cookies.get('pb_auth')?.value;
|
const user = await getAuthUser(request);
|
||||||
|
if (!user) {
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
|
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pb = createPocketBaseClient(token);
|
return NextResponse.json({ user });
|
||||||
|
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: { code: 'AUTH_ERROR', message: 'Invalid or expired token' } },
|
{ error: { code: 'AUTH_ERROR', message: 'Invalid or expired token' } },
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { getAuthUser, getAuthToken } from '@/lib/auth';
|
import { getAuthUser } from '@/lib/auth';
|
||||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
import postgres from 'postgres';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 300; // 5 minutes
|
export const maxDuration = 300; // 5 minutes
|
||||||
@@ -15,7 +15,7 @@ const DEFAULT_COLLECTIONS = [
|
|||||||
'notifications',
|
'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) {
|
export async function GET(request: NextRequest) {
|
||||||
const user = await getAuthUser(request);
|
const user = await getAuthUser(request);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -36,10 +36,10 @@ export async function GET(request: NextRequest) {
|
|||||||
const subscribedCollections =
|
const subscribedCollections =
|
||||||
collections.length > 0 ? collections : DEFAULT_COLLECTIONS;
|
collections.length > 0 ? collections : DEFAULT_COLLECTIONS;
|
||||||
|
|
||||||
const token = getAuthToken(request);
|
|
||||||
const pb = createPocketBaseClient(token || undefined);
|
|
||||||
const encoder = new TextEncoder();
|
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({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
@@ -50,53 +50,32 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to each collection
|
const subscription = await listener.listen('project_e_events', (payload) => {
|
||||||
for (const collection of subscribedCollections) {
|
|
||||||
try {
|
try {
|
||||||
const unsub = await pb.collection(collection).subscribe('*', (e) => {
|
const event = JSON.parse(payload) as { collection?: string };
|
||||||
try {
|
if (!event.collection || subscribedCollections.includes(event.collection)) {
|
||||||
const event = {
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||||
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);
|
|
||||||
} catch {
|
} catch {
|
||||||
controller.enqueue(
|
// Ignore malformed database notifications and closed streams.
|
||||||
encoder.encode(
|
|
||||||
`data: ${JSON.stringify({ type: 'subscription_error', collection })}\n\n`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
unlisten = subscription.unlisten;
|
||||||
|
|
||||||
// Keepalive ping every 30 seconds
|
// Keepalive ping every 30 seconds
|
||||||
const keepalive = setInterval(() => {
|
keepalive = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(':ping\n\n'));
|
controller.enqueue(encoder.encode(':ping\n\n'));
|
||||||
} catch {
|
} catch {
|
||||||
clearInterval(keepalive);
|
if (keepalive) clearInterval(keepalive);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancel() {
|
async cancel() {
|
||||||
// Client disconnected — cleanup all subscriptions
|
if (keepalive) clearInterval(keepalive);
|
||||||
for (const unsub of unsubscribeFns) {
|
await unlisten?.();
|
||||||
try {
|
await listener.end({ timeout: 5 });
|
||||||
await unsub();
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsubscribeFns.length = 0;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
};
|
||||||
+9
-27
@@ -1,4 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from './auth-config';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +12,9 @@ export interface AuthUser {
|
|||||||
* Extract auth token from request cookies
|
* Extract auth token from request cookies
|
||||||
*/
|
*/
|
||||||
export function getAuthToken(request: NextRequest): string | null {
|
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
|
* Returns null if not authenticated
|
||||||
*/
|
*/
|
||||||
export async function getAuthUser(request: NextRequest): Promise<AuthUser | null> {
|
export async function getAuthUser(request: NextRequest): Promise<AuthUser | null> {
|
||||||
const token = getAuthToken(request);
|
if (!getAuthToken(request)) return null;
|
||||||
if (!token) return null;
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id || !session.user.email) return null;
|
||||||
try {
|
return { id: session.user.id, email: session.user.email, name: session.user.name || session.user.email };
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -1,37 +1,18 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import { createAdminClient as createDatabaseAdminClient, createDatabaseClient } from './database';
|
||||||
|
|
||||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://localhost:8090';
|
/** @deprecated Import from `@/lib/database` in new code. */
|
||||||
|
export function createPocketBaseClient(_token?: string) {
|
||||||
/**
|
return createDatabaseClient();
|
||||||
* 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 PostgreSQL access is authenticated by the application session. */
|
||||||
* Get admin token from environment
|
|
||||||
*/
|
|
||||||
export function getAdminToken(): string {
|
export function getAdminToken(): string {
|
||||||
return process.env.POCKETBASE_ADMIN_TOKEN || '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated Import from `@/lib/database` in new code. */
|
||||||
* Create an admin-authenticated PocketBase client
|
export function createAdminClient() {
|
||||||
* Used for server-side operations that need admin privileges
|
return createDatabaseAdminClient();
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +23,8 @@ export async function getRecord<T extends Record<string, unknown>>(
|
|||||||
id: string,
|
id: string,
|
||||||
token?: string
|
token?: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const pb = createPocketBaseClient(token);
|
const db = createPocketBaseClient(token);
|
||||||
return pb.collection(collection).getOne(id) as Promise<T>;
|
return db.collection(collection).getOne(id) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,8 +40,8 @@ export async function listRecords<T extends Record<string, unknown>>(
|
|||||||
token?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
): Promise<{ items: T[]; totalItems: number; totalPages: number }> {
|
): Promise<{ items: T[]; totalItems: number; totalPages: number }> {
|
||||||
const pb = createPocketBaseClient(options?.token);
|
const db = createPocketBaseClient(options?.token);
|
||||||
const result = await pb.collection(collection).getList(
|
const result = await db.collection(collection).getList(
|
||||||
options?.page || 1,
|
options?.page || 1,
|
||||||
options?.perPage || 50,
|
options?.perPage || 50,
|
||||||
{
|
{
|
||||||
@@ -83,8 +64,8 @@ export async function createRecord<T extends Record<string, unknown>>(
|
|||||||
data: Partial<T>,
|
data: Partial<T>,
|
||||||
token?: string
|
token?: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const pb = createPocketBaseClient(token);
|
const db = createPocketBaseClient(token);
|
||||||
return pb.collection(collection).create(data) as Promise<T>;
|
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>,
|
data: Partial<T>,
|
||||||
token?: string
|
token?: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const pb = createPocketBaseClient(token);
|
const db = createPocketBaseClient(token);
|
||||||
return pb.collection(collection).update(id, data) as Promise<T>;
|
return db.collection(collection).update(id, data) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +89,6 @@ export async function deleteRecord(
|
|||||||
id: string,
|
id: string,
|
||||||
token?: string
|
token?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const pb = createPocketBaseClient(token);
|
const db = createPocketBaseClient(token);
|
||||||
return pb.collection(collection).delete(id);
|
return db.collection(collection).delete(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
// Check if user is authenticated
|
// 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
|
// If no token and trying to access protected routes, redirect to login
|
||||||
const protectedRoutes = [
|
const protectedRoutes = [
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import 'next-auth';
|
||||||
|
|
||||||
|
declare module 'next-auth' {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,8 @@ const allowedHosts = (process.env.ALLOWED_HOSTS || 'localhost')
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ['@project-e/shared'],
|
transpilePackages: ['@project-e/shared', '@project-e/db'],
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
serverExternalPackages: ['pocketbase'],
|
|
||||||
|
|
||||||
// Allow requests from configured hosts (for Nginx Proxy Manager)
|
// Allow requests from configured hosts (for Nginx Proxy Manager)
|
||||||
allowedDevOrigins: allowedHosts,
|
allowedDevOrigins: allowedHosts,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@project-e/db": "^0.1.0",
|
||||||
"@project-e/shared": "*",
|
"@project-e/shared": "*",
|
||||||
"@radix-ui/react-accordion": "^1.2.16",
|
"@radix-ui/react-accordion": "^1.2.16",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.19",
|
"@radix-ui/react-alert-dialog": "^1.1.19",
|
||||||
@@ -50,13 +51,16 @@
|
|||||||
"@tiptap/starter-kit": "^3.27.4",
|
"@tiptap/starter-kit": "^3.27.4",
|
||||||
"@types/react-big-calendar": "^1.16.3",
|
"@types/react-big-calendar": "^1.16.3",
|
||||||
"@types/react-grid-layout": "^1.3.6",
|
"@types/react-grid-layout": "^1.3.6",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.24.0",
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"pocketbase": "^0.27.0",
|
"next-auth": "^4.24.15",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-big-calendar": "^1.20.0",
|
"react-big-calendar": "^1.20.0",
|
||||||
"react-calendar-heatmap": "^1.10.0",
|
"react-calendar-heatmap": "^1.10.0",
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.2",
|
"@tailwindcss/postcss": "^4.3.2",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.19.0",
|
"@types/node": "^22.19.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+20
-18
@@ -11,8 +11,11 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- POCKETBASE_URL=http://db:8090
|
- DATABASE_URL=postgresql://project_e:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@db:5432/project_e
|
||||||
- POCKETBASE_ADMIN_TOKEN=${POCKETBASE_ADMIN_TOKEN:-}
|
- NEXTAUTH_URL=${PUBLIC_URL:-http://localhost:3000}
|
||||||
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET}
|
||||||
|
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:?Set INITIAL_ADMIN_EMAIL}
|
||||||
|
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?Set INITIAL_ADMIN_PASSWORD}
|
||||||
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:3000}
|
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:3000}
|
||||||
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
- COOKIE_SECURE=${COOKIE_SECURE:-false}
|
||||||
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost}
|
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost}
|
||||||
@@ -24,7 +27,7 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -32,24 +35,24 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
build:
|
image: postgres:16-alpine
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.pocketbase
|
|
||||||
container_name: project-e-db
|
container_name: project-e-db
|
||||||
# For Nginx Proxy Manager: Don't expose ports directly
|
|
||||||
# Only uncomment if you need direct PocketBase admin access
|
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=project_e
|
||||||
|
- POSTGRES_USER=project_e
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- project-e-pb-data:/pb_data
|
- project-e-pg-data:/var/lib/postgresql/data
|
||||||
|
- ./drizzle/0000_first_mauler.sql:/docker-entrypoint-initdb.d/0000_first_mauler.sql:ro
|
||||||
networks:
|
networks:
|
||||||
- project-e-network
|
- project-e-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
test: ["CMD-SHELL", "pg_isready -U project_e -d project_e"]
|
||||||
interval: 30s
|
interval: 10s
|
||||||
timeout: 10s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 5
|
||||||
start_period: 10s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
@@ -59,8 +62,7 @@ services:
|
|||||||
container_name: project-e-worker
|
container_name: project-e-worker
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- POCKETBASE_URL=http://db:8090
|
- DATABASE_URL=postgresql://project_e:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@db:5432/project_e
|
||||||
- POCKETBASE_ADMIN_TOKEN=${POCKETBASE_ADMIN_TOKEN:-}
|
|
||||||
networks:
|
networks:
|
||||||
- project-e-network
|
- project-e-network
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -73,5 +75,5 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
project-e-pb-data:
|
project-e-pg-data:
|
||||||
project-e-web-uploads:
|
project-e-web-uploads:
|
||||||
|
|||||||
+63
-461
@@ -1,505 +1,107 @@
|
|||||||
# Architecture Documentation
|
# Architecture Documentation
|
||||||
|
|
||||||
Project E is a monorepo application built on Next.js with PocketBase as the data layer. This document explains the system design, data flow, and key architectural decisions.
|
Project E is a Next.js monorepo that stores application data in PostgreSQL 16 through Drizzle ORM. NextAuth credentials authentication manages user sessions. This document describes the implemented system.
|
||||||
|
|
||||||
## Table of Contents
|
## System overview
|
||||||
|
|
||||||
- [System Overview](#system-overview)
|
|
||||||
- [Three-Layer Architecture](#three-layer-architecture)
|
|
||||||
- [Data Flow](#data-flow)
|
|
||||||
- [Realtime Architecture](#realtime-architecture)
|
|
||||||
- [Worker Architecture](#worker-architecture)
|
|
||||||
- [MCP Server Architecture](#mcp-server-architecture)
|
|
||||||
- [Security](#security)
|
|
||||||
- [Performance](#performance)
|
|
||||||
- [Accessibility](#accessibility)
|
|
||||||
|
|
||||||
## System Overview
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
Browser
|
||||||
│ Client (Browser) │
|
│
|
||||||
│ Next.js (React 19) · Zustand · SSE Client · PocketBase SDK │
|
├── REST requests and Server-Sent Events
|
||||||
└──────────────────────────────┬───────────────────────────────────────┘
|
▼
|
||||||
│
|
Next.js application (apps/web)
|
||||||
┌──────────┴──────────┐
|
├── App Router pages and API routes
|
||||||
│ │
|
├── NextAuth credentials provider and JWT sessions
|
||||||
REST API SSE /api/realtime
|
├── Services, validation, and collection adapter
|
||||||
/api/* (PocketBase events)
|
├── MCP server
|
||||||
│ │
|
└── Drizzle ORM
|
||||||
┌───────────────────▼─────────────────────▼────────────────────────────┐
|
│
|
||||||
│ Next.js Server (apps/web) │
|
▼
|
||||||
│ │
|
PostgreSQL 16
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────────┐ │
|
├── users
|
||||||
│ │ API Routes │ │ MCP Server │ │ Auth │ │ Middleware │ │
|
├── records (JSONB application data)
|
||||||
│ │ (CRUD ops) │ │ (61 tools) │ │ (cookies) │ │ (routing) │ │
|
└── project_e_events notifications
|
||||||
│ └──────┬──────┘ └──────┬───────┘ └──────┬──────┘ └───────────┘ │
|
│
|
||||||
│ │ │ │ │
|
▼
|
||||||
│ ┌──────▼────────────────▼──────────────────▼──────────────────────┐ │
|
Background worker
|
||||||
│ │ Service Layer │ │
|
└── Polls queue_jobs records and processes asynchronous work
|
||||||
│ │ task-service · habit-service · note-service · report-service │ │
|
|
||||||
│ │ webhook-service · agent-mention-service · project-service │ │
|
|
||||||
│ └──────────────────────────┬──────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────────▼──────────────────────────────────────┐ │
|
|
||||||
│ │ PocketBase Client │ │
|
|
||||||
│ │ createPocketBaseClient() · createAdminClient() │ │
|
|
||||||
│ └──────────────────────────┬──────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────┼────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────────────▼────────────────────────────────────────┐
|
|
||||||
│ PocketBase (SQLite) │
|
|
||||||
│ Auth · Collections · Realtime · File Storage · Admin UI │
|
|
||||||
│ 30+ collections: tasks, habits, projects, notes, reports, ... │
|
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Background Worker │
|
|
||||||
│ Polls queue_jobs collection · Exponential backoff · 5 job types │
|
|
||||||
│ webhook_delivery · agent_mention · report_generation │
|
|
||||||
│ recurring_task · cleanup │
|
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Three-Layer Architecture
|
## Application layers
|
||||||
|
|
||||||
The application separates concerns into three layers.
|
### Presentation
|
||||||
|
|
||||||
### Presentation Layer
|
`apps/web/app/` contains App Router pages and API routes. `apps/web/components/` contains UI components. Zustand stores and hooks manage client-side state and consume API and SSE updates.
|
||||||
|
|
||||||
**Location:** `apps/web/app/` (pages) and `apps/web/components/` (UI)
|
### Business logic
|
||||||
|
|
||||||
The presentation layer handles rendering, user interaction, and client-side state.
|
`apps/web/lib/services/` contains entity behavior such as task, habit, note, report, project, webhook, and agent-mention services. `packages/shared/` provides Zod schemas and TypeScript types for validation across the app and worker.
|
||||||
|
|
||||||
**Key components:**
|
### Persistence
|
||||||
|
|
||||||
- **Pages**: Next.js App Router pages organized by route groups: `(auth)` for login/signup, `(dashboard)` for the main application
|
`packages/db/src/schema.ts` defines Drizzle's PostgreSQL schema. `packages/db/src/index.ts` creates a Drizzle client from `DATABASE_URL`.
|
||||||
- **UI Components**: shadcn/ui primitives in `components/ui/`, feature components in `components/`
|
|
||||||
- **Client State**: Zustand stores in `lib/stores/` manage UI state (sidebar, filters, theme, timer)
|
|
||||||
- **Hooks**: Custom hooks in `hooks/` encapsulate reusable logic
|
|
||||||
|
|
||||||
The presentation layer communicates with the business logic layer through:
|
The `users` table stores user identity, email addresses, and bcrypt password hashes. The `records` table stores collection data in JSONB with a collection name and timestamps. `apps/web/lib/database.ts` exposes collection operations over those records and emits PostgreSQL notifications after creates, updates, and deletes.
|
||||||
1. REST API calls (fetch to `/api/*`)
|
|
||||||
2. Zustand store actions (which call the API)
|
|
||||||
3. SSE events from the realtime endpoint
|
|
||||||
|
|
||||||
### Business Logic Layer
|
## Authentication and authorization
|
||||||
|
|
||||||
**Location:** `apps/web/lib/services/` and `packages/shared/`
|
NextAuth uses the credentials provider and JWT sessions. A user signs in with an email address and password. The authorization flow normalizes the email address, reads the user from PostgreSQL, and compares the password against its bcrypt hash.
|
||||||
|
|
||||||
The business logic layer handles validation, data transformation, and orchestration.
|
On an empty `users` table, a sign-in matching `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` creates the first account. Set `NEXTAUTH_SECRET` for each environment and keep it stable to preserve active sessions.
|
||||||
|
|
||||||
**Services:**
|
API routes require an authenticated user through the application's authentication middleware. MCP requests use agent API keys and permission tiers. The `read_only` tier limits agents to read operations.
|
||||||
|
|
||||||
| Service | Responsibility |
|
## Request and data flow
|
||||||
|---------|---------------|
|
|
||||||
| `task-service` | Task CRUD, recurring task spawning, dependency resolution |
|
|
||||||
| `habit-service` | Habit tracking, streak calculation, completion scoring |
|
|
||||||
| `note-service` | Note CRUD, wikilink parsing, word count |
|
|
||||||
| `report-service` | Report generation, template rendering |
|
|
||||||
| `project-service` | Project progress calculation, milestone tracking |
|
|
||||||
| `webhook-service` | Webhook event dispatch, delivery tracking |
|
|
||||||
| `agent-mention-service` | Agent @mention parsing, task dispatch |
|
|
||||||
|
|
||||||
**Shared package (`@project-e/shared`):**
|
### Create a task
|
||||||
|
|
||||||
Contains Zod schemas and TypeScript types shared between the web app, worker, and potentially other consumers. Every entity has:
|
|
||||||
- A Zod schema for runtime validation
|
|
||||||
- Inferred TypeScript types for compile-time safety
|
|
||||||
- Create and update schema variants (omit server-generated fields)
|
|
||||||
|
|
||||||
### Data Access Layer
|
|
||||||
|
|
||||||
**Location:** `apps/web/lib/pocketbase.ts` and `apps/web/app/api/`
|
|
||||||
|
|
||||||
The data access layer handles communication with PocketBase.
|
|
||||||
|
|
||||||
**PocketBase client (`lib/pocketbase.ts`):**
|
|
||||||
|
|
||||||
Provides two factory functions:
|
|
||||||
- `createPocketBaseClient(token?)`: Creates a client with optional user auth
|
|
||||||
- `createAdminClient()`: Creates a client with admin privileges (uses `POCKETBASE_ADMIN_TOKEN`)
|
|
||||||
|
|
||||||
Also provides generic helpers: `getRecord`, `listRecords`, `createRecord`, `updateRecord`, `deleteRecord`.
|
|
||||||
|
|
||||||
**API routes (`app/api/`):**
|
|
||||||
|
|
||||||
Each entity has a directory under `app/api/` with a `route.ts` file implementing GET (list) and POST (create). Dynamic routes use `[id]/route.ts` for GET (single), PATCH (update), and DELETE.
|
|
||||||
|
|
||||||
All API routes use the `withAuth` middleware to enforce authentication.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Creating a Task
|
|
||||||
|
|
||||||
```
|
```
|
||||||
User fills form → React component
|
Browser → POST /api/tasks → authenticated API route
|
||||||
│
|
→ Zod validation → service and collection adapter
|
||||||
▼
|
→ Drizzle write to PostgreSQL
|
||||||
POST /api/tasks (JSON body)
|
→ pg_notify('project_e_events')
|
||||||
│
|
→ SSE clients receive the update
|
||||||
▼
|
|
||||||
withAuth middleware validates session
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Zod schema validates body (createTaskSchema)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PocketBase client creates record
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PocketBase triggers realtime event
|
|
||||||
│
|
|
||||||
├──▶ SSE /api/realtime → Browser updates UI
|
|
||||||
│
|
|
||||||
└──▶ Worker picks up job (if webhook subscribed)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Realtime Update Flow
|
### Realtime updates
|
||||||
|
|
||||||
```
|
The `/api/realtime` endpoint authenticates the request, opens a PostgreSQL listener for `project_e_events`, and streams matching collection events through Server-Sent Events. Clients can specify a comma-separated `collections` query parameter. The endpoint sends a keepalive comment every 30 seconds and closes its PostgreSQL listener when the client disconnects.
|
||||||
User A updates a task
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PATCH /api/tasks/abc123
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PocketBase updates SQLite record
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PocketBase emits realtime event
|
|
||||||
│
|
|
||||||
├──▶ User A's SSE connection receives event → UI updates
|
|
||||||
│
|
|
||||||
├──▶ User B's SSE connection receives event → UI updates
|
|
||||||
│
|
|
||||||
└──▶ Webhook delivery queued (if subscribed)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agent Tool Call Flow
|
The reverse proxy must not buffer SSE responses.
|
||||||
|
|
||||||
```
|
## Drizzle migrations and Docker
|
||||||
AI agent sends MCP request
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
GET/POST /api/mcp (Authorization: Bearer <api_key>)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Authenticate: look up agent by API key
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
MCP server routes to tool handler
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Tool handler calls PocketBase (admin client)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Return JSON result to agent
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Agent activity logged to agent_activity collection
|
|
||||||
```
|
|
||||||
|
|
||||||
## Realtime Architecture
|
Drizzle Kit reads `drizzle.config.ts`, which points to `packages/db/src/schema.ts` and writes generated SQL to `drizzle/`. `DATABASE_URL` supplies the PostgreSQL connection string.
|
||||||
|
|
||||||
Project E uses Server-Sent Events (SSE) to push realtime updates from PocketBase to the browser.
|
Docker Compose runs PostgreSQL 16 and mounts `drizzle/0000_first_mauler.sql` as an initialization script. PostgreSQL runs initialization scripts only for a new data volume. Apply later reviewed migrations during deployment; restarting an existing database container does not run them.
|
||||||
|
|
||||||
### Why SSE Instead of WebSockets
|
## Background worker
|
||||||
|
|
||||||
1. **Simpler infrastructure**: SSE works over standard HTTP, no special proxy configuration needed
|
The worker polls `queue_jobs` records for due work. It marks a job `in_progress`, runs the handler, then records `completed` or schedules a retry after an error. Its polling interval starts at five seconds, grows to 60 seconds when no work is available, and resets when it finds jobs.
|
||||||
2. **Unidirectional**: The server pushes events; the client sends commands through REST. This matches the data flow.
|
|
||||||
3. **Auto-reconnect**: The browser's `EventSource` API handles reconnection automatically
|
|
||||||
4. **PocketBase native**: PocketBase has built-in realtime subscriptions. SSE is a natural proxy.
|
|
||||||
|
|
||||||
### SSE Proxy Architecture
|
Supported job types include webhook delivery, agent mentions, report generation, recurring tasks, and cleanup. Failed jobs retry with exponential backoff up to their configured retry limit.
|
||||||
|
|
||||||
PocketBase's realtime uses WebSockets internally. The Next.js app acts as a proxy:
|
## MCP server
|
||||||
|
|
||||||
```
|
The MCP server runs at `/api/mcp` over Streamable HTTP. It establishes sessions with `GET`, accepts JSON-RPC requests with `POST`, and ends sessions with `DELETE`. The app keeps active transports in memory.
|
||||||
Browser ──SSE──▶ /api/realtime ──WebSocket──▶ PocketBase
|
|
||||||
```
|
|
||||||
|
|
||||||
The proxy:
|
Tools live in `apps/web/lib/mcp/tools/` and register by entity. Tool handlers use the database-backed collection adapter. Agent API keys identify callers, permission tiers restrict access, and the app records agent activity.
|
||||||
1. Opens a PocketBase WebSocket connection for each subscribed collection
|
|
||||||
2. Translates PocketBase events into SSE `data:` frames
|
|
||||||
3. Sends a `:ping` comment every 30 seconds to keep the connection alive
|
|
||||||
4. Cleans up all subscriptions when the client disconnects
|
|
||||||
|
|
||||||
### Subscription Management
|
|
||||||
|
|
||||||
Clients specify which collections to subscribe to via query parameters:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/realtime?collections=tasks,habits,projects
|
|
||||||
```
|
|
||||||
|
|
||||||
Default subscriptions (if no parameter): `tasks`, `habits`, `projects`, `notes`, `reports`, `milestones`, `notifications`.
|
|
||||||
|
|
||||||
### Event Format
|
|
||||||
|
|
||||||
```
|
|
||||||
data: {"type":"connected","collections":["tasks","habits"]}
|
|
||||||
|
|
||||||
data: {"type":"create","collection":"tasks","record":{"id":"abc","title":"New task"}}
|
|
||||||
|
|
||||||
data: {"type":"update","collection":"tasks","record":{"id":"abc","status":"done"}}
|
|
||||||
|
|
||||||
data: {"type":"delete","collection":"tasks","record":{"id":"abc"}}
|
|
||||||
|
|
||||||
:ping
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client-Side Handling
|
|
||||||
|
|
||||||
The Zustand stores subscribe to SSE events and update local state:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const eventSource = new EventSource("/api/realtime?collections=tasks");
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === "update" && data.collection === "tasks") {
|
|
||||||
useTaskStore.getState().updateTask(data.record);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Worker Architecture
|
|
||||||
|
|
||||||
The background worker processes asynchronous jobs stored in the `queue_jobs` PocketBase collection.
|
|
||||||
|
|
||||||
### Job Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
API creates job → status: "pending"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Worker polls for pending jobs (every 5s base)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Worker marks job as "in_progress"
|
|
||||||
│
|
|
||||||
├──▶ Success → status: "completed"
|
|
||||||
│
|
|
||||||
└──▶ Failure → retry_count < max_retries?
|
|
||||||
│
|
|
||||||
├──▶ Yes → status: "pending", scheduled_at: now + backoff
|
|
||||||
│
|
|
||||||
└──▶ No → status: "failed", error: message
|
|
||||||
```
|
|
||||||
|
|
||||||
### Polling Strategy
|
|
||||||
|
|
||||||
The worker uses exponential backoff to reduce database load when idle:
|
|
||||||
|
|
||||||
| Condition | Poll Interval |
|
|
||||||
|-----------|--------------|
|
|
||||||
| Jobs found | Reset to 5 seconds |
|
|
||||||
| No jobs | Multiply by 1.5 (max 60 seconds) |
|
|
||||||
| Error | Keep current interval |
|
|
||||||
|
|
||||||
### Job Types
|
|
||||||
|
|
||||||
| Type | Handler | Description |
|
|
||||||
|------|---------|-------------|
|
|
||||||
| `webhook_delivery` | `handleWebhookDelivery` | POST payload to webhook URL with HMAC signature |
|
|
||||||
| `agent_mention` | `handleAgentMention` | Dispatch task to agent webhook |
|
|
||||||
| `report_generation` | `handleReportGeneration` | Collect data and populate report content |
|
|
||||||
| `recurring_task` | `handleRecurringTask` | Compute next due date from RRULE and spawn task |
|
|
||||||
| `cleanup` | `handleCleanup` | Purge old webhook deliveries and error logs |
|
|
||||||
|
|
||||||
### Retry Strategy
|
|
||||||
|
|
||||||
Failed jobs retry with exponential backoff:
|
|
||||||
|
|
||||||
```
|
|
||||||
Retry 1: 5 seconds
|
|
||||||
Retry 2: 10 seconds
|
|
||||||
Retry 3: 20 seconds
|
|
||||||
...
|
|
||||||
Max backoff: 5 minutes
|
|
||||||
Max retries: 3 (configurable per job)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cleanup
|
|
||||||
|
|
||||||
The worker schedules a daily cleanup job that:
|
|
||||||
- Deletes webhook deliveries older than 90 days
|
|
||||||
- Deletes error logs older than 30 days
|
|
||||||
|
|
||||||
## MCP Server Architecture
|
|
||||||
|
|
||||||
The MCP server runs inside the Next.js API at `/api/mcp`. It uses the `@modelcontextprotocol/sdk` package.
|
|
||||||
|
|
||||||
### Transport
|
|
||||||
|
|
||||||
The server uses **Streamable HTTP** transport:
|
|
||||||
|
|
||||||
1. `GET /api/mcp`: Establish a session, returns `mcp-session-id` header
|
|
||||||
2. `POST /api/mcp`: Send JSON-RPC requests with `mcp-session-id` header
|
|
||||||
3. `DELETE /api/mcp`: End the session
|
|
||||||
|
|
||||||
Sessions are stored in an in-memory `Map<string, Transport>`.
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
Every request validates the `Authorization: Bearer <api_key>` header against the `agents` collection. Only active agents can connect.
|
|
||||||
|
|
||||||
### Tool Organization
|
|
||||||
|
|
||||||
Tools are organized by entity in `apps/web/lib/mcp/tools/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/mcp/
|
|
||||||
├── server.ts : Creates McpServer, registers all tools
|
|
||||||
└── tools/
|
|
||||||
├── tasks.ts : 8 tools
|
|
||||||
├── habits.ts : 7 tools
|
|
||||||
├── projects.ts : 6 tools
|
|
||||||
├── notes.ts : 6 tools
|
|
||||||
├── reports.ts : 5 tools
|
|
||||||
├── milestones.ts : 5 tools
|
|
||||||
├── domains.ts : 5 tools
|
|
||||||
├── tags.ts : 5 tools
|
|
||||||
├── agents.ts : 5 tools
|
|
||||||
├── webhooks.ts : 5 tools
|
|
||||||
└── analytics.ts : 4 tools
|
|
||||||
```
|
|
||||||
|
|
||||||
Each file exports a `register*Tools(server)` function that adds tools to the server.
|
|
||||||
|
|
||||||
### Tool Execution
|
|
||||||
|
|
||||||
Tools use the admin PocketBase client (`createAdminClient()`) to bypass row-level security. This is safe because:
|
|
||||||
1. The MCP endpoint requires a valid agent API key
|
|
||||||
2. Agent permission tiers control which tools are available
|
|
||||||
3. All tool calls are logged to `agent_activity`
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
### Authentication
|
- PostgreSQL credentials come from `DATABASE_URL` and `POSTGRES_PASSWORD`.
|
||||||
|
- NextAuth signs JWT sessions with `NEXTAUTH_SECRET`.
|
||||||
|
- The app stores password hashes with bcrypt, never plaintext passwords.
|
||||||
|
- Cookies use `httpOnly`, `sameSite: lax`, and `secure` settings appropriate to the deployment.
|
||||||
|
- Zod schemas validate API request bodies.
|
||||||
|
- Reverse proxies should enforce TLS, host restrictions, and rate limits.
|
||||||
|
|
||||||
**User authentication** uses PocketBase's built-in auth system:
|
## Performance and operations
|
||||||
- Email/password login
|
|
||||||
- JWT tokens stored in `httpOnly` cookies
|
|
||||||
- 7-day token expiry with refresh endpoint
|
|
||||||
- Tokens validated on every API request via `withAuth` middleware
|
|
||||||
|
|
||||||
**Agent authentication** uses API keys:
|
PostgreSQL stores indexed collection and creation-time metadata while retaining flexible collection fields in JSONB. API routes paginate list responses. The Drizzle client uses a connection pool with a maximum of 10 connections.
|
||||||
- Each agent has a unique API key (UUID)
|
|
||||||
- Keys validated against the `agents` collection
|
|
||||||
- Disabled agents cannot authenticate
|
|
||||||
- Used for both MCP server and agent webhook callbacks
|
|
||||||
|
|
||||||
### Authorization
|
Each realtime client holds a PostgreSQL notification listener. Monitor long-lived SSE connections and proxy timeouts as concurrency grows. The worker uses database-backed job records, so multiple workers can process queued work when the job-claiming behavior supports the workload.
|
||||||
|
|
||||||
**API routes** use the `withAuth` middleware. All routes require a valid user session. PocketBase row-level security provides additional protection at the database level.
|
|
||||||
|
|
||||||
**MCP tools** use agent permission tiers. The `read_only` tier can only call `get_*` and `list_*` tools. The server enforces this at the tool registration level.
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
|
|
||||||
- **Passwords**: Hashed by PocketBase using bcrypt
|
|
||||||
- **API keys**: Stored as plain text (UUIDs). Treat them like passwords.
|
|
||||||
- **Webhook secrets**: Used for HMAC-SHA256 payload signing
|
|
||||||
- **Admin token**: Stored in environment variable, not in the database
|
|
||||||
- **Cookies**: `httpOnly`, `secure` (production), `sameSite: lax`
|
|
||||||
|
|
||||||
### Input Validation
|
|
||||||
|
|
||||||
All API endpoints validate request bodies with Zod schemas from `@project-e/shared`. Invalid input returns a `400 VALIDATION_ERROR` with details about which fields failed.
|
|
||||||
|
|
||||||
### CORS
|
|
||||||
|
|
||||||
Configure CORS in `next.config.ts` for cross-origin requests. By default, Next.js allows same-origin requests only.
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
|
|
||||||
The application does not enforce rate limiting. Configure it at the reverse proxy or infrastructure layer. See [Deployment Guide](DEPLOYMENT.md#rate-limiting) for recommendations.
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
PocketBase uses SQLite, which handles read-heavy workloads well.
|
|
||||||
|
|
||||||
**Optimizations:**
|
|
||||||
- PocketBase enables WAL mode by default for concurrent reads
|
|
||||||
- Indexes on frequently queried fields (status, domain, project_id)
|
|
||||||
- Pagination limits prevent large result sets
|
|
||||||
|
|
||||||
**Limits:**
|
|
||||||
- SQLite handles thousands of concurrent reads
|
|
||||||
- Write throughput is limited by single-writer model
|
|
||||||
- For most personal/team use cases, this is not a bottleneck
|
|
||||||
|
|
||||||
### API Responses
|
|
||||||
|
|
||||||
**Caching:**
|
|
||||||
- List endpoints set `Cache-Control: private, max-age=60, stale-while-revalidate=300`
|
|
||||||
- This allows the browser to use stale data while revalidating in the background
|
|
||||||
|
|
||||||
**Compression:**
|
|
||||||
- Next.js compresses responses automatically (gzip/brotli)
|
|
||||||
- The reverse proxy should also enable compression
|
|
||||||
|
|
||||||
### Realtime
|
|
||||||
|
|
||||||
**Connection management:**
|
|
||||||
- Each SSE connection holds a PocketBase WebSocket subscription
|
|
||||||
- The proxy cleans up subscriptions on client disconnect
|
|
||||||
- Keepalive pings prevent idle connection timeouts
|
|
||||||
|
|
||||||
**Scaling limits:**
|
|
||||||
- Each SSE connection uses a server-side WebSocket to PocketBase
|
|
||||||
- For hundreds of concurrent users, consider connection pooling or a dedicated realtime service
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
**Next.js optimizations:**
|
|
||||||
- App Router with React Server Components for initial page loads
|
|
||||||
- Client components only where interactivity is needed
|
|
||||||
- Automatic code splitting per route
|
|
||||||
- Image optimization via `next/image`
|
|
||||||
|
|
||||||
**Bundle size:**
|
|
||||||
- shadcn/ui components are tree-shaken (only imported components are included)
|
|
||||||
- Zustand stores are lightweight (no boilerplate)
|
|
||||||
- Tiptap editor loads lazily
|
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
Project E follows WCAG 2.1 AA guidelines.
|
Project E targets WCAG 2.1 AA. Interactive elements support keyboard navigation, icon-only controls include accessible names, form fields use labels, dialogs manage focus, and dynamic content uses live regions. The UI respects `prefers-reduced-motion` and pairs status colors with text or icons.
|
||||||
|
|
||||||
### Keyboard Navigation
|
|
||||||
|
|
||||||
- All interactive elements are reachable via keyboard
|
|
||||||
- Focus order follows visual layout
|
|
||||||
- Focus indicators are visible (Tailwind `focus-visible:ring-2`)
|
|
||||||
- Modal dialogs trap focus and return it on close
|
|
||||||
|
|
||||||
### ARIA Labels
|
|
||||||
|
|
||||||
- Icon-only buttons include `aria-label` attributes
|
|
||||||
- Form fields have associated `<label>` elements
|
|
||||||
- Dynamic content uses `aria-live` regions
|
|
||||||
- Dialogs use `role="dialog"` and `aria-modal="true"`
|
|
||||||
|
|
||||||
### Color and Contrast
|
|
||||||
|
|
||||||
- Text meets 4.5:1 contrast ratio against backgrounds
|
|
||||||
- Status indicators use both color and text/icons
|
|
||||||
- Domain colors are customizable but default to accessible palette
|
|
||||||
|
|
||||||
### Screen Readers
|
|
||||||
|
|
||||||
- Semantic HTML elements (`<nav>`, `<main>`, `<section>`, `<article>`)
|
|
||||||
- Headings follow proper hierarchy (h1 → h2 → h3)
|
|
||||||
- Tables use `<th>` with `scope` attributes
|
|
||||||
- Toast notifications announce via `aria-live="polite"`
|
|
||||||
|
|
||||||
### Motion
|
|
||||||
|
|
||||||
- Animations respect `prefers-reduced-motion`
|
|
||||||
- No auto-playing content
|
|
||||||
- Transitions are subtle and brief
|
|
||||||
|
|||||||
+66
-547
@@ -1,404 +1,126 @@
|
|||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
Deploy Project E with Docker Compose and Nginx Proxy Manager. Three containers run the application: web (Next.js), db (PocketBase), and worker (background jobs).
|
Deploy Project E with Docker Compose and a reverse proxy such as Nginx Proxy Manager. Compose runs three services: the Next.js web app, PostgreSQL 16, and the background worker.
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Prerequisites](#prerequisites)
|
|
||||||
- [Quick Deploy](#quick-deploy)
|
|
||||||
- [Environment Configuration](#environment-configuration)
|
|
||||||
- [Nginx Proxy Manager Setup](#nginx-proxy-manager-setup)
|
|
||||||
- [PocketBase Setup](#pocketbase-setup)
|
|
||||||
- [Backups](#backups)
|
|
||||||
- [Monitoring](#monitoring)
|
|
||||||
- [Scaling](#scaling)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Docker** 24.0 or later
|
- Docker 24.0 or later
|
||||||
- **Docker Compose** 2.20 or later
|
- Docker Compose 2.20 or later
|
||||||
- **Nginx Proxy Manager** installed and running
|
- A reverse proxy for TLS and public routing
|
||||||
- **At least 1GB RAM** and 10GB disk space
|
- At least 1 GB RAM and 10 GB disk space
|
||||||
|
|
||||||
## Quick Deploy
|
## Deploy Project E
|
||||||
|
|
||||||
1. **Clone the repository on your server**
|
1. Clone the repository on the server.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd ProjectE
|
cd ProjectE
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create the environment file**
|
2. Create the environment file.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env` and set the required variables:
|
3. Set the required values in `.env`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POCKETBASE_ADMIN_TOKEN=your-secure-random-token
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
PUBLIC_URL=http://project-e.local
|
DATABASE_URL=postgresql://project_e:your_postgres_password@localhost:5432/project_e
|
||||||
COOKIE_SECURE=false
|
NEXTAUTH_SECRET=your_long_random_secret
|
||||||
ALLOWED_HOSTS=project-e.local,localhost
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=your_initial_admin_password
|
||||||
|
PUBLIC_URL=https://project-e.example.com
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
ALLOWED_HOSTS=project-e.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate a secure token:
|
Generate secrets with `openssl rand -base64 32`. Use the same `POSTGRES_PASSWORD` in `DATABASE_URL`. The web and worker containers use an internal database URL that Compose builds from `POSTGRES_PASSWORD`.
|
||||||
|
|
||||||
```bash
|
4. Build and start the services.
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Build and start containers**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Verify all containers are running**
|
5. Confirm that the services are healthy.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see three containers with status `Up (healthy)`:
|
6. Route your domain to `project-e-web:3000` through your reverse proxy.
|
||||||
- `project-e-web` (internal only, no exposed ports)
|
|
||||||
- `project-e-db` (internal only, no exposed ports)
|
|
||||||
- `project-e-worker` running in the background
|
|
||||||
|
|
||||||
5. **Configure Nginx Proxy Manager** (see below)
|
7. Open the app and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`. Project E creates that account only when the database contains no users.
|
||||||
|
|
||||||
6. **Create your admin account**
|
## Environment variables
|
||||||
|
|
||||||
Once NPM is configured, access Project E through your domain. Create your first user account.
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `DATABASE_URL` | Yes | PostgreSQL connection string for local tools and processes outside Compose. |
|
||||||
|
| `POSTGRES_PASSWORD` | Yes | Password for the `project_e` PostgreSQL user. |
|
||||||
|
| `NEXTAUTH_SECRET` | Yes | Secret used to sign NextAuth JWT sessions. |
|
||||||
|
| `INITIAL_ADMIN_EMAIL` | Yes | Email address for the first account. |
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` | Yes | Password for the first account. |
|
||||||
|
| `PUBLIC_URL` | No | Public application URL. Defaults to `http://localhost:3000`. |
|
||||||
|
| `COOKIE_SECURE` | No | Set to `true` behind HTTPS. Defaults to `false`. |
|
||||||
|
| `ALLOWED_HOSTS` | No | Comma-separated allowed hostnames. |
|
||||||
|
|
||||||
## Environment Configuration
|
Keep `.env` out of version control. Rotate `NEXTAUTH_SECRET` only when you intend to end active sessions.
|
||||||
|
|
||||||
### Required Variables
|
## PostgreSQL and Drizzle
|
||||||
|
|
||||||
| Variable | Description |
|
The `db` service runs `postgres:16-alpine`, stores its data in the `project-e-pg-data` Docker volume, and exposes port 5432. The web and worker services connect to `db:5432` over the Compose network.
|
||||||
|----------|-------------|
|
|
||||||
| `POCKETBASE_ADMIN_TOKEN` | Admin token from PocketBase. Required for the worker and server-side API operations. |
|
|
||||||
|
|
||||||
### Optional Variables
|
Docker mounts `drizzle/0000_first_mauler.sql` into PostgreSQL's initialization directory. PostgreSQL runs that file only while it initializes an empty data volume. For a later Drizzle migration, apply the reviewed SQL as part of your release process. Do not expect a container restart to apply a new migration to an existing volume.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
To apply a migration from the host, run:
|
||||||
|----------|---------|-------------|
|
|
||||||
| `POCKETBASE_URL` | `http://db:8090` | PocketBase URL (internal Docker network) |
|
|
||||||
| `NODE_ENV` | `production` | Node environment |
|
|
||||||
| `PUBLIC_URL` | `http://localhost:3000` | Public URL where users access Project E (used for generating absolute URLs) |
|
|
||||||
| `COOKIE_SECURE` | `false` | Set to `true` if using HTTPS through NPM, `false` for HTTP-only LAN access |
|
|
||||||
| `ALLOWED_HOSTS` | `localhost` | Comma-separated list of domains that can access the app |
|
|
||||||
|
|
||||||
### Setting Variables
|
|
||||||
|
|
||||||
Create a `.env` file in the project root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POCKETBASE_ADMIN_TOKEN=abc123def456...
|
docker compose exec -T db psql -U project_e -d project_e < drizzle/<migration>.sql
|
||||||
POCKETBASE_URL=http://db:8090
|
|
||||||
PUBLIC_URL=http://project-e.local
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
ALLOWED_HOSTS=project-e.local,localhost
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker Compose reads this file automatically.
|
Back up the database before applying schema changes.
|
||||||
|
|
||||||
## Nginx Proxy Manager Setup
|
## Nginx Proxy Manager
|
||||||
|
|
||||||
### Add Proxy Host
|
1. In Nginx Proxy Manager, open **Hosts** > **Proxy Hosts** > **Add Proxy Host**.
|
||||||
|
2. Set the domain name, choose `http`, set the forward hostname to `project-e-web`, and set the forward port to `3000`.
|
||||||
1. Open Nginx Proxy Manager admin interface
|
3. Select a certificate and force SSL for HTTPS deployments.
|
||||||
2. Go to **Hosts** → **Proxy Hosts** → **Add Proxy Host**
|
4. Add the following advanced configuration to support Server-Sent Events:
|
||||||
|
|
||||||
3. Configure the following:
|
|
||||||
|
|
||||||
**Details Tab:**
|
|
||||||
- **Domain Names:** `project-e.local` (or your chosen domain)
|
|
||||||
- **Scheme:** `http`
|
|
||||||
- **Forward Hostname/IP:** `project-e-web` (the Docker container name)
|
|
||||||
- **Forward Port:** `3000`
|
|
||||||
|
|
||||||
**SSL Tab:**
|
|
||||||
- If using HTTPS: Select your SSL certificate
|
|
||||||
- If HTTP-only on LAN: Leave SSL disabled
|
|
||||||
|
|
||||||
**Advanced Tab:**
|
|
||||||
Add these custom Nginx configuration lines:
|
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# WebSocket support for realtime features
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
# Forward real client information
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
||||||
# SSE support for realtime endpoint
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
# Increase timeout for long-running requests
|
|
||||||
proxy_connect_timeout 300s;
|
proxy_connect_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Click **Save**
|
5. Save the host and open the configured domain. You should reach the Project E sign-in page.
|
||||||
|
|
||||||
### Testing the Connection
|
## Back up and restore PostgreSQL
|
||||||
|
|
||||||
1. From a device on your LAN, open a browser
|
Create a logical backup without stopping the database:
|
||||||
2. Navigate to `http://project-e.local` (or your configured domain)
|
|
||||||
3. You should see the Project E login page
|
|
||||||
|
|
||||||
### Optional: PocketBase Admin Access
|
|
||||||
|
|
||||||
If you need direct access to the PocketBase admin UI:
|
|
||||||
|
|
||||||
1. In NPM, add another Proxy Host:
|
|
||||||
- **Domain Names:** `pb.project-e.local`
|
|
||||||
- **Scheme:** `http`
|
|
||||||
- **Forward Hostname/IP:** `project-e-db`
|
|
||||||
- **Forward Port:** `8090`
|
|
||||||
|
|
||||||
2. Or temporarily expose the port in `docker-compose.yml`:
|
|
||||||
```yaml
|
|
||||||
db:
|
|
||||||
ports:
|
|
||||||
- "8090:8090"
|
|
||||||
```
|
|
||||||
|
|
||||||
## PocketBase Setup
|
|
||||||
|
|
||||||
### Initial Configuration
|
|
||||||
|
|
||||||
PocketBase runs as a standalone container. After starting it for the first time:
|
|
||||||
|
|
||||||
1. Access the admin UI at `http://your-server:8090/_/`
|
|
||||||
2. Create your admin account
|
|
||||||
3. Configure authentication settings under **Settings > Auth**
|
|
||||||
4. Enable the auth methods you need (email/password is enabled by default)
|
|
||||||
|
|
||||||
### Migrations
|
|
||||||
|
|
||||||
Database migrations are in `pocketbase/pb_migrations/`. They run automatically when the PocketBase container starts.
|
|
||||||
|
|
||||||
To add a new migration:
|
|
||||||
|
|
||||||
1. Create a file in `pocketbase/pb_migrations/` with the naming convention `YYYYMMDDHHMMSS_description.js`
|
|
||||||
2. Restart the PocketBase container: `docker compose restart db`
|
|
||||||
|
|
||||||
### Data Directory
|
|
||||||
|
|
||||||
PocketBase stores all data (SQLite database, uploads, logs) in the `/pb_data` volume. This volume persists across container restarts.
|
|
||||||
|
|
||||||
## Reverse Proxy
|
|
||||||
|
|
||||||
Put a reverse proxy in front of the application to handle SSL, compression, and routing.
|
|
||||||
|
|
||||||
### Caddy (Recommended)
|
|
||||||
|
|
||||||
Caddy handles SSL automatically.
|
|
||||||
|
|
||||||
Create a `Caddyfile`:
|
|
||||||
|
|
||||||
```
|
|
||||||
your-domain.com {
|
|
||||||
reverse_proxy localhost:3000
|
|
||||||
|
|
||||||
header {
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pb.your-domain.com {
|
|
||||||
reverse_proxy localhost:8090
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Install and run Caddy:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Caddy
|
mkdir -p backups
|
||||||
sudo apt install caddy # Debian/Ubuntu
|
|
||||||
# or
|
|
||||||
brew install caddy # macOS
|
|
||||||
|
|
||||||
# Start Caddy
|
|
||||||
caddy start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Traefik
|
Restore a backup into a new or empty database:
|
||||||
|
|
||||||
Create a `traefik.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
entryPoints:
|
|
||||||
web:
|
|
||||||
address: ":80"
|
|
||||||
http:
|
|
||||||
redirections:
|
|
||||||
entryPoint:
|
|
||||||
to: websecure
|
|
||||||
scheme: https
|
|
||||||
websecure:
|
|
||||||
address: ":443"
|
|
||||||
|
|
||||||
certificatesResolvers:
|
|
||||||
letsencrypt:
|
|
||||||
acme:
|
|
||||||
email: your-email@example.com
|
|
||||||
storage: acme.json
|
|
||||||
httpChallenge:
|
|
||||||
entryPoint: web
|
|
||||||
|
|
||||||
providers:
|
|
||||||
docker:
|
|
||||||
exposedByDefault: false
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `docker-compose.yml` to add Traefik labels:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
web:
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.web.rule=Host(`your-domain.com`)"
|
|
||||||
- "traefik.http.routers.web.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.web.tls.certresolver=letsencrypt"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nginx
|
|
||||||
|
|
||||||
Create `/etc/nginx/sites-available/project-e`:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name your-domain.com;
|
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name your-domain.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
# SSE support for realtime endpoint
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable the site and reload:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ln -s /etc/nginx/sites-available/project-e /etc/nginx/sites-enabled/
|
docker compose exec -T db psql -U project_e -d project_e < backups/project-e-YYYYMMDD_HHMMSS.sql
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSL/TLS
|
Back up the `project-e-web-uploads` volume if your deployment stores uploads there:
|
||||||
|
|
||||||
### Let's Encrypt with Certbot
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install certbot
|
|
||||||
sudo apt install certbot python3-certbot-nginx
|
|
||||||
|
|
||||||
# Get certificate
|
|
||||||
sudo certbot --nginx -d your-domain.com -d pb.your-domain.com
|
|
||||||
|
|
||||||
# Auto-renewal is configured automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
### Caddy
|
|
||||||
|
|
||||||
Caddy obtains and renews certificates automatically. No configuration needed beyond the domain name in the `Caddyfile`.
|
|
||||||
|
|
||||||
### Internal Communication
|
|
||||||
|
|
||||||
The web and worker containers connect to PocketBase over the internal Docker network (`http://db:8090`). This traffic does not need SSL.
|
|
||||||
|
|
||||||
Only expose ports 3000 and 8090 to the reverse proxy, not directly to the internet.
|
|
||||||
|
|
||||||
## Backups
|
|
||||||
|
|
||||||
### PocketBase Database
|
|
||||||
|
|
||||||
The database is a single SQLite file at `/pb_data/data.db`.
|
|
||||||
|
|
||||||
**Manual backup:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop the container to ensure consistency
|
|
||||||
docker compose stop db
|
|
||||||
|
|
||||||
# Copy the database file
|
|
||||||
docker cp project-e-db:/pb_data/data.db ./backups/data-$(date +%Y%m%d).db
|
|
||||||
|
|
||||||
# Restart the container
|
|
||||||
docker compose start db
|
|
||||||
```
|
|
||||||
|
|
||||||
**Automated backup script:**
|
|
||||||
|
|
||||||
Create `scripts/backup.sh`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
BACKUP_DIR="/path/to/backups"
|
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
# Use PocketBase's backup API (no downtime)
|
|
||||||
curl -X POST http://localhost:8090/api/backup \
|
|
||||||
-H "Authorization: Admin your-admin-token" \
|
|
||||||
-o "$BACKUP_DIR/backup-$DATE.zip"
|
|
||||||
|
|
||||||
# Keep only last 30 backups
|
|
||||||
find $BACKUP_DIR -name "backup-*.zip" -mtime +30 -delete
|
|
||||||
|
|
||||||
echo "Backup completed: backup-$DATE.zip"
|
|
||||||
```
|
|
||||||
|
|
||||||
Schedule with cron:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run daily at 2 AM
|
|
||||||
0 2 * * * /path/to/scripts/backup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Upload Files
|
|
||||||
|
|
||||||
Uploaded files are stored in the `project-e-web-uploads` volume.
|
|
||||||
|
|
||||||
**Backup uploads:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
@@ -408,232 +130,29 @@ docker run --rm \
|
|||||||
tar czf /backup/uploads-$(date +%Y%m%d).tar.gz -C /source .
|
tar czf /backup/uploads-$(date +%Y%m%d).tar.gz -C /source .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restore
|
Test restores on a separate environment before relying on a backup.
|
||||||
|
|
||||||
**Restore database:**
|
## Monitoring and logs
|
||||||
|
|
||||||
```bash
|
Check service status and logs:
|
||||||
# Stop containers
|
|
||||||
docker compose stop db
|
|
||||||
|
|
||||||
# Remove old data
|
|
||||||
docker volume rm project-e-pb-data
|
|
||||||
|
|
||||||
# Copy backup into new volume
|
|
||||||
docker volume create project-e-pb-data
|
|
||||||
docker run --rm \
|
|
||||||
-v project-e-pb-data:/pb_data \
|
|
||||||
-v $(pwd)/backups:/backup \
|
|
||||||
alpine \
|
|
||||||
sh -c "cp /backup/data-YYYYMMDD.db /pb_data/data.db"
|
|
||||||
|
|
||||||
# Restart
|
|
||||||
docker compose start db
|
|
||||||
```
|
|
||||||
|
|
||||||
**Restore uploads:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm \
|
|
||||||
-v project-e-web-uploads:/target \
|
|
||||||
-v $(pwd)/backups:/backup \
|
|
||||||
alpine \
|
|
||||||
sh -c "cd /target && tar xzf /backup/uploads-YYYYMMDD.tar.gz"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Container Health
|
|
||||||
|
|
||||||
All containers include health checks. Check status:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
### Application Health
|
The web service exposes `GET /api/health` on port 3000. Configure uptime monitoring for `https://your-domain.example/api/health`.
|
||||||
|
|
||||||
The web container exposes a health endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
|
||||||
"version": "0.1.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
View logs from all containers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All containers
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Specific container
|
|
||||||
docker compose logs -f web
|
|
||||||
docker compose logs -f db
|
|
||||||
docker compose logs -f worker
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resource Usage
|
|
||||||
|
|
||||||
Monitor container resource usage:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker stats
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uptime Monitoring
|
|
||||||
|
|
||||||
Use an external service to monitor your deployment:
|
|
||||||
|
|
||||||
- **UptimeRobot** (free tier): HTTP monitoring with email/SMS alerts
|
|
||||||
- **Healthchecks.io**: Cron job monitoring
|
|
||||||
- **Better Stack**: Status pages and incident management
|
|
||||||
|
|
||||||
Set up a check for `https://your-domain.com/api/health` with a 60-second interval.
|
|
||||||
|
|
||||||
## Scaling
|
|
||||||
|
|
||||||
### Vertical Scaling
|
|
||||||
|
|
||||||
The application runs as a single instance of each container. To handle more load:
|
|
||||||
|
|
||||||
1. **Increase server resources**: Add more CPU and RAM to your host
|
|
||||||
2. **Increase Node.js memory**: Set `NODE_OPTIONS=--max-old-space-size=4096` in the web container
|
|
||||||
3. **Increase PocketBase limits**: PocketBase handles thousands of concurrent connections on modest hardware
|
|
||||||
|
|
||||||
### Horizontal Scaling
|
|
||||||
|
|
||||||
Horizontal scaling requires changes to the architecture:
|
|
||||||
|
|
||||||
**Current limitations:**
|
|
||||||
- MCP sessions are stored in memory (not shared between instances)
|
|
||||||
- SSE connections are tied to a specific container
|
|
||||||
- File uploads go to a local volume
|
|
||||||
|
|
||||||
**To scale horizontally:**
|
|
||||||
1. Use a shared session store (Redis)
|
|
||||||
2. Use a load balancer with sticky sessions for SSE
|
|
||||||
3. Use object storage (S3) for file uploads
|
|
||||||
4. Run multiple web containers behind a load balancer
|
|
||||||
|
|
||||||
For most personal and small-team use cases, a single instance handles the load. PocketBase with SQLite performs well up to hundreds of concurrent users.
|
|
||||||
|
|
||||||
### Worker Scaling
|
|
||||||
|
|
||||||
The worker uses polling with exponential backoff. For high-throughput job processing:
|
|
||||||
|
|
||||||
1. Run multiple worker containers (they coordinate through the database)
|
|
||||||
2. Reduce the base poll interval (currently 5 seconds)
|
|
||||||
3. Use a dedicated job queue (Bull, BullMQ) instead of database polling
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Container Won't Start
|
| Problem | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Database connection fails | Confirm that `db` is healthy and that `POSTGRES_PASSWORD` matches the value in `DATABASE_URL`. |
|
||||||
|
| The web service will not start | Set `NEXTAUTH_SECRET`, `INITIAL_ADMIN_EMAIL`, and `INITIAL_ADMIN_PASSWORD` in `.env`, then restart the service. |
|
||||||
|
| Sign-in fails | Confirm the email and password match the initial-admin values. Those values create an account only before any user exists. |
|
||||||
|
| A schema change is missing | Apply the generated Drizzle SQL. PostgreSQL initialization scripts do not rerun for an existing volume. |
|
||||||
|
| Realtime updates stop | Disable proxy buffering and set a read timeout of at least 300 seconds. |
|
||||||
|
| Disk space runs low | Inspect `project-e-pg-data` and `project-e-web-uploads`, back up data, and expand the host disk. |
|
||||||
|
|
||||||
**Check logs:**
|
## Scaling
|
||||||
|
|
||||||
```bash
|
The default deployment runs one web service, one worker, and one PostgreSQL instance. Add CPU and memory before changing the topology. Multiple web services require shared session-aware infrastructure and a reverse proxy that supports long-lived SSE connections. Multiple workers coordinate through the PostgreSQL-backed job records.
|
||||||
docker compose logs web
|
|
||||||
docker compose logs db
|
|
||||||
docker compose logs worker
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common issues:**
|
|
||||||
|
|
||||||
| Problem | Solution |
|
|
||||||
|---------|----------|
|
|
||||||
| `Cannot connect to PocketBase` | Ensure the `db` container is healthy. Check `docker compose ps`. |
|
|
||||||
| `Port 3000 already in use` | Change the port mapping in `docker-compose.yml` |
|
|
||||||
| `POCKETBASE_ADMIN_TOKEN not set` | Set the token in `.env` and restart |
|
|
||||||
| `Migration failed` | Check migration files in `pocketbase/pb_migrations/` |
|
|
||||||
|
|
||||||
### Web App Returns 500 Errors
|
|
||||||
|
|
||||||
1. Check the web container logs: `docker compose logs web`
|
|
||||||
2. Verify PocketBase is running: `docker compose exec db wget -qO- http://localhost:8090/api/health`
|
|
||||||
3. Verify the admin token is correct: `docker compose exec web env | grep POCKETBASE`
|
|
||||||
|
|
||||||
### Realtime Events Not Arriving
|
|
||||||
|
|
||||||
1. Check the SSE connection: `curl -N http://localhost:3000/api/realtime`
|
|
||||||
2. Verify the reverse proxy is not buffering SSE responses
|
|
||||||
3. For Nginx, ensure `proxy_buffering off` is set
|
|
||||||
4. Check browser console for connection errors
|
|
||||||
|
|
||||||
### Worker Not Processing Jobs
|
|
||||||
|
|
||||||
1. Check worker logs: `docker compose logs worker`
|
|
||||||
2. Verify the admin token is set correctly
|
|
||||||
3. Check for pending jobs in PocketBase admin: `http://localhost:8090/_/#/collections/queue_jobs`
|
|
||||||
4. Jobs retry automatically with exponential backoff (max 5 minutes between retries)
|
|
||||||
|
|
||||||
### Database Corruption
|
|
||||||
|
|
||||||
If the SQLite database becomes corrupted:
|
|
||||||
|
|
||||||
1. Stop all containers: `docker compose down`
|
|
||||||
2. Restore from the most recent backup (see [Backups](#backups))
|
|
||||||
3. If no backup exists, try SQLite's recovery:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sqlite3 data.db ".recover" | sqlite3 new-data.db
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Replace the corrupted file and restart
|
|
||||||
|
|
||||||
### Out of Disk Space
|
|
||||||
|
|
||||||
PocketBase stores the database and uploads in the `project-e-pb-data` volume.
|
|
||||||
|
|
||||||
**Check disk usage:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker system df
|
|
||||||
docker volume inspect project-e-pb-data
|
|
||||||
```
|
|
||||||
|
|
||||||
**Clean up:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Remove unused images
|
|
||||||
docker image prune -a
|
|
||||||
|
|
||||||
# Remove unused volumes (WARNING: deletes all data)
|
|
||||||
docker volume prune
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expand volume:**
|
|
||||||
|
|
||||||
Docker volumes use the host filesystem. If the host disk is full, expand it or move the volume to a larger disk.
|
|
||||||
|
|
||||||
### SSL Certificate Errors
|
|
||||||
|
|
||||||
**Caddy:** Check the Caddy logs for ACME errors. Ensure port 80 is accessible from the internet for the HTTP challenge.
|
|
||||||
|
|
||||||
**Let's Encrypt:** Certificates renew automatically. Force renewal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo certbot renew --force-renewal
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common errors:**
|
|
||||||
- `Connection refused`: Port 80 is blocked by firewall
|
|
||||||
- `DNS problem`: Domain does not resolve to this server
|
|
||||||
- `Rate limit exceeded`: Too many certificate requests. Wait and retry.
|
|
||||||
|
|
||||||
### Performance Issues
|
|
||||||
|
|
||||||
1. **Slow page loads**: Check if the server has enough RAM. Node.js needs at least 512MB.
|
|
||||||
2. **Slow API responses**: Check PocketBase query performance in the admin panel
|
|
||||||
3. **SSE disconnects**: Ensure the reverse proxy has appropriate timeout settings (300s+)
|
|
||||||
4. **Worker falling behind**: Increase the poll frequency or add more worker instances
|
|
||||||
|
|||||||
+86
-669
@@ -1,729 +1,146 @@
|
|||||||
# Development Guide
|
# Development Guide
|
||||||
|
|
||||||
This guide covers the development workflow for Project E. Read this before contributing code.
|
Use this guide to run Project E locally, change the database schema, and prepare a pull request.
|
||||||
|
|
||||||
## Table of Contents
|
## Prerequisites
|
||||||
|
|
||||||
- [Environment Setup](#environment-setup)
|
- Node.js 22.13.0 or later
|
||||||
- [Project Structure](#project-structure)
|
- npm 10.0.0 or later
|
||||||
- [Code Organization](#code-organization)
|
- Docker and Docker Compose, for PostgreSQL 16
|
||||||
- [Adding a New Feature](#adding-a-new-feature)
|
- Git
|
||||||
- [Database Schema Changes](#database-schema-changes)
|
|
||||||
- [Testing Strategy](#testing-strategy)
|
|
||||||
- [Code Style and Conventions](#code-style-and-conventions)
|
|
||||||
- [Git Workflow](#git-workflow)
|
|
||||||
- [PR Review Process](#pr-review-process)
|
|
||||||
- [Common Tasks](#common-tasks)
|
|
||||||
|
|
||||||
## Environment Setup
|
## Set up your local environment
|
||||||
|
|
||||||
### Prerequisites
|
1. Clone the repository and install dependencies.
|
||||||
|
|
||||||
- **Node.js** 22.13.0 or later (use `nvm` to manage versions)
|
|
||||||
- **npm** 10.0.0 or later
|
|
||||||
- **Git**
|
|
||||||
- **A code editor** (VS Code recommended)
|
|
||||||
- **PocketBase** binary (download from [pocketbase.io](https://pocketbase.io))
|
|
||||||
|
|
||||||
### Initial Setup
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd ProjectE
|
cd ProjectE
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Start PocketBase**
|
2. Copy the environment template.
|
||||||
|
|
||||||
In a separate terminal:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pocketbase serve \
|
cp .env.example .env
|
||||||
--dir=./pb_data \
|
|
||||||
--publicDir=./pb_public \
|
|
||||||
--migrationDir=./pocketbase/pb_migrations
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use Docker:
|
3. Set the database and authentication values in `.env`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://project_e:your_postgres_password@localhost:5432/project_e
|
||||||
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
|
NEXTAUTH_SECRET=your_long_random_secret
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=your_initial_admin_password
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATABASE_URL` connects local processes to PostgreSQL. `POSTGRES_PASSWORD` must match the password in that URL. Generate `NEXTAUTH_SECRET` with `openssl rand -base64 32`.
|
||||||
|
|
||||||
|
4. Start PostgreSQL 16.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up db -d
|
docker compose up db -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Set environment variables**
|
Docker initializes the `project_e` database and applies `drizzle/0000_first_mauler.sql` when it creates an empty database volume.
|
||||||
|
|
||||||
Create `apps/web/.env.local`:
|
5. Start the app.
|
||||||
|
|
||||||
```bash
|
|
||||||
POCKETBASE_URL=http://localhost:8090
|
|
||||||
POCKETBASE_ADMIN_TOKEN=your_admin_token
|
|
||||||
```
|
|
||||||
|
|
||||||
Get the admin token from PocketBase after creating your first admin account.
|
|
||||||
|
|
||||||
5. **Start the development server**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts the Next.js app at `http://localhost:3000` with Turbopack.
|
6. Open `http://localhost:3000` and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`.
|
||||||
|
|
||||||
6. **Verify everything works**
|
The credentials create the first admin account only when the `users` table has no accounts.
|
||||||
|
|
||||||
- Open `http://localhost:3000` in your browser
|
## Project structure
|
||||||
- Open `http://localhost:8090/_/` for the PocketBase admin UI
|
|
||||||
- Run `npm run typecheck` to verify TypeScript compiles
|
|
||||||
|
|
||||||
### VS Code Setup
|
|
||||||
|
|
||||||
Recommended extensions:
|
|
||||||
- ESLint
|
|
||||||
- Tailwind CSS IntelliSense
|
|
||||||
- TypeScript and JavaScript Language Features (built-in)
|
|
||||||
- Prettier - Code formatter
|
|
||||||
|
|
||||||
Create `.vscode/settings.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"tailwindCSS.experimental.classRegex": [
|
|
||||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
project-e/
|
project-e/
|
||||||
├── apps/
|
├── apps/web/ # Next.js application
|
||||||
│ └── web/ # Next.js application (monorepo app)
|
│ ├── app/ # App Router pages and API routes
|
||||||
│ ├── app/ # App Router (pages + API routes)
|
│ ├── components/ # React components
|
||||||
│ │ ├── (auth)/ # Auth route group (login, signup)
|
│ ├── hooks/ # Custom React hooks
|
||||||
│ │ ├── (dashboard)/ # Dashboard route group
|
│ └── lib/ # Services, database adapter, and NextAuth config
|
||||||
│ │ └── api/ # REST API endpoints
|
|
||||||
│ ├── components/ # React components
|
|
||||||
│ │ ├── ui/ # shadcn/ui primitives
|
|
||||||
│ │ └── ... # Feature components
|
|
||||||
│ ├── hooks/ # Custom React hooks
|
|
||||||
│ ├── lib/ # Core utilities
|
|
||||||
│ │ ├── mcp/ # MCP server and tools
|
|
||||||
│ │ ├── services/ # Business logic
|
|
||||||
│ │ ├── stores/ # Zustand stores
|
|
||||||
│ │ ├── events/ # Event bus
|
|
||||||
│ │ ├── auth.ts # Auth middleware
|
|
||||||
│ │ ├── pocketbase.ts # PocketBase client
|
|
||||||
│ │ └── errors.ts # Error handling
|
|
||||||
│ └── types/ # TypeScript type definitions
|
|
||||||
├── packages/
|
├── packages/
|
||||||
│ └── shared/ # Shared package (@project-e/shared)
|
│ ├── db/ # Drizzle schema and PostgreSQL client
|
||||||
│ └── src/
|
│ └── shared/ # Shared schemas, types, and constants
|
||||||
│ ├── schemas/ # Zod validation schemas
|
├── drizzle/ # Generated PostgreSQL migrations
|
||||||
│ ├── types/ # Shared TypeScript types
|
├── worker/ # Background job worker
|
||||||
│ └── constants/ # Shared constants
|
├── e2e/ # Playwright tests
|
||||||
├── pocketbase/
|
├── tests/ # Unit and component tests
|
||||||
│ ├── pb_migrations/ # Database migrations
|
├── drizzle.config.ts # Drizzle Kit configuration
|
||||||
│ └── schema.ts # TypeScript types for collections
|
└── docker-compose.yml # Web, PostgreSQL, and worker services
|
||||||
├── worker/ # Background job worker
|
|
||||||
│ └── index.ts # Worker entry point
|
|
||||||
├── e2e/ # Playwright E2E tests
|
|
||||||
├── tests/ # Unit and component tests
|
|
||||||
└── docker-compose.yml # Docker Compose configuration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Organization
|
## Data access and authentication
|
||||||
|
|
||||||
### Layers
|
The app uses Drizzle ORM with PostgreSQL. `packages/db/src/schema.ts` defines the schema, and `packages/db/src/index.ts` creates the database client from `DATABASE_URL`.
|
||||||
|
|
||||||
The application follows a three-layer architecture:
|
Application records live in the `records` table as JSONB data grouped by collection. The `users` table stores email addresses and bcrypt password hashes. API routes use the database adapter in `apps/web/lib/database.ts` for collection operations.
|
||||||
|
|
||||||
1. **Presentation**: React components in `apps/web/components/` and pages in `apps/web/app/`
|
NextAuth uses the credentials provider. It creates JWT sessions after a user signs in with an email address and password. Keep `NEXTAUTH_SECRET` stable for an environment; changing it invalidates existing sessions.
|
||||||
2. **Business Logic**: Services in `apps/web/lib/services/` and shared schemas in `packages/shared/`
|
|
||||||
3. **Data Access**: PocketBase client in `apps/web/lib/pocketbase.ts` and API routes in `apps/web/app/api/`
|
|
||||||
|
|
||||||
### Naming Conventions
|
## Add a feature
|
||||||
|
|
||||||
| Type | Convention | Example |
|
1. Define or update the data shape in `packages/db/src/schema.ts`.
|
||||||
|------|-----------|---------|
|
2. Generate a Drizzle migration.
|
||||||
| Components | PascalCase | `TaskCard.tsx` |
|
|
||||||
| Hooks | camelCase with `use` prefix | `use-task-filter.ts` |
|
|
||||||
| Utilities | camelCase | `format-date.ts` |
|
|
||||||
| Types | PascalCase | `Task.ts` |
|
|
||||||
| Schemas | camelCase with `Schema` suffix | `taskSchema` |
|
|
||||||
| API routes | kebab-case directory | `api/habit-logs/route.ts` |
|
|
||||||
| Stores | camelCase with `use` prefix | `use-dashboard-store.ts` |
|
|
||||||
|
|
||||||
### Import Paths
|
|
||||||
|
|
||||||
Use the `@/` alias for imports within `apps/web`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
|
||||||
import { TaskCard } from '@/components/task-card';
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the package name for shared imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createTaskSchema } from '@project-e/shared';
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Feature
|
|
||||||
|
|
||||||
Follow these steps to add a feature end-to-end. This example adds a "bookmarks" feature to notes.
|
|
||||||
|
|
||||||
### Step 1: Define the Schema
|
|
||||||
|
|
||||||
Add the field to the PocketBase collection schema. Create a migration file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# pocketbase/pb_migrations/20240115120000_add_bookmarks.js
|
|
||||||
export default {
|
|
||||||
up(db) {
|
|
||||||
const collection = db.findCollectionByNameOrId("notes");
|
|
||||||
collection.fields.add(new Field({
|
|
||||||
name: "bookmarked",
|
|
||||||
type: "bool",
|
|
||||||
options: { default: false }
|
|
||||||
}));
|
|
||||||
return db.saveCollection(collection);
|
|
||||||
},
|
|
||||||
down(db) {
|
|
||||||
const collection = db.findCollectionByNameOrId("notes");
|
|
||||||
collection.fields.removeByName("bookmarked");
|
|
||||||
return db.saveCollection(collection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Update TypeScript Types
|
|
||||||
|
|
||||||
Update the type definition in `pocketbase/schema.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface Note extends BaseRecord {
|
|
||||||
// ... existing fields
|
|
||||||
bookmarked: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Add Validation Schema
|
|
||||||
|
|
||||||
Update the Zod schema in `packages/shared/src/schemas/note.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const noteSchema = z.object({
|
|
||||||
// ... existing fields
|
|
||||||
bookmarked: z.boolean().default(false),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Update the API
|
|
||||||
|
|
||||||
If the API route needs changes, update it in `apps/web/app/api/notes/route.ts`. Most CRUD operations work automatically through PocketBase, so you may not need API changes.
|
|
||||||
|
|
||||||
### Step 5: Build the UI
|
|
||||||
|
|
||||||
Create or update components:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// apps/web/components/note-bookmark-button.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Bookmark } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface NoteBookmarkButtonProps {
|
|
||||||
noteId: string;
|
|
||||||
bookmarked: boolean;
|
|
||||||
onToggle: (noteId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NoteBookmarkButton({ noteId, bookmarked, onToggle }: NoteBookmarkButtonProps) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onToggle(noteId)}
|
|
||||||
aria-label={bookmarked ? "Remove bookmark" : "Add bookmark"}
|
|
||||||
>
|
|
||||||
<Bookmark className={bookmarked ? "fill-current" : ""} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Add State Management
|
|
||||||
|
|
||||||
If needed, update the Zustand store:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// apps/web/lib/stores/use-notes-store.ts
|
|
||||||
interface NotesState {
|
|
||||||
// ... existing state
|
|
||||||
toggleBookmark: (noteId: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Write Tests
|
|
||||||
|
|
||||||
Add tests for the new functionality:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// tests/note-bookmark.test.ts
|
|
||||||
describe("NoteBookmarkButton", () => {
|
|
||||||
it("toggles bookmark state on click", () => {
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8: Update MCP Tools (if applicable)
|
|
||||||
|
|
||||||
If the feature should be accessible to AI agents, add or update MCP tools in `apps/web/lib/mcp/tools/`.
|
|
||||||
|
|
||||||
### Step 9: Verify
|
|
||||||
|
|
||||||
1. Run `npm run typecheck`: TypeScript compiles without errors
|
|
||||||
2. Run `npm run lint`: No lint errors
|
|
||||||
3. Run `npm run test`: All tests pass
|
|
||||||
4. Run `npm run test:e2e`: E2E tests pass (if applicable)
|
|
||||||
5. Test manually in the browser
|
|
||||||
|
|
||||||
## Database Schema Changes
|
|
||||||
|
|
||||||
### Creating Migrations
|
|
||||||
|
|
||||||
PocketBase migrations are JavaScript files in `pocketbase/pb_migrations/`.
|
|
||||||
|
|
||||||
**Naming convention:** `YYYYMMDDHHMMSS_description.js`
|
|
||||||
|
|
||||||
**Example (add a new collection):**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export default {
|
|
||||||
async up(db) {
|
|
||||||
const collection = new Collection({
|
|
||||||
name: "bookmarks",
|
|
||||||
type: "base",
|
|
||||||
fields: [
|
|
||||||
{ name: "title", type: "text", required: true },
|
|
||||||
{ name: "url", type: "url", required: true },
|
|
||||||
{ name: "note_id", type: "relation", options: { collectionId: "notes" } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return db.saveCollection(collection);
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(db) {
|
|
||||||
return db.deleteCollection("bookmarks");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Migrations
|
|
||||||
|
|
||||||
Migrations run automatically when PocketBase starts. To run them manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pocketbase migrate --dir=./pocketbase/pb_migrations --dir=./pb_data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Updating TypeScript Types
|
|
||||||
|
|
||||||
After changing the schema, update the TypeScript types in `pocketbase/schema.ts` to match. This keeps the type system in sync with the database.
|
|
||||||
|
|
||||||
### Rules for Schema Changes
|
|
||||||
|
|
||||||
1. **Always provide both `up` and `down`**: Migrations must be reversible
|
|
||||||
2. **Never modify existing migrations**: Create new ones instead
|
|
||||||
3. **Test migrations locally** before committing
|
|
||||||
4. **Update TypeScript types** in the same PR as the migration
|
|
||||||
5. **Update Zod schemas** in `packages/shared/` if the change affects validation
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
Unit tests cover pure functions and business logic. They run with Jest.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Location:** `tests/` directory or alongside source files.
|
|
||||||
|
|
||||||
**What to test:**
|
|
||||||
- Zod schema validation
|
|
||||||
- Utility functions (date formatting, string manipulation)
|
|
||||||
- Service layer logic
|
|
||||||
- Store actions
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// tests/format-duration.test.ts
|
|
||||||
import { formatDuration } from "@/lib/utils";
|
|
||||||
|
|
||||||
describe("formatDuration", () => {
|
|
||||||
it("formats minutes to hours and minutes", () => {
|
|
||||||
expect(formatDuration(90)).toBe("1h 30m");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles zero minutes", () => {
|
|
||||||
expect(formatDuration(0)).toBe("0m");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Tests
|
|
||||||
|
|
||||||
Component tests verify React components render correctly and handle user interactions.
|
|
||||||
|
|
||||||
**Location:** `tests/` directory or alongside component files.
|
|
||||||
|
|
||||||
**What to test:**
|
|
||||||
- Components render with required props
|
|
||||||
- User interactions trigger correct callbacks
|
|
||||||
- Conditional rendering works as expected
|
|
||||||
|
|
||||||
### E2E Tests
|
|
||||||
|
|
||||||
E2E tests verify complete user flows using Playwright. They run against a real browser.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all E2E tests
|
|
||||||
npm run test:e2e
|
|
||||||
|
|
||||||
# Run with UI mode (interactive debugging)
|
|
||||||
npm run test:e2e:ui
|
|
||||||
|
|
||||||
# View test report
|
|
||||||
npm run test:e2e:report
|
|
||||||
```
|
|
||||||
|
|
||||||
**Location:** `e2e/` directory.
|
|
||||||
|
|
||||||
**Browser configurations:**
|
|
||||||
- Chromium (Desktop)
|
|
||||||
- Firefox (Desktop)
|
|
||||||
- WebKit (Desktop Safari)
|
|
||||||
- Mobile Chrome (Pixel 5)
|
|
||||||
- Mobile Safari (iPhone 12)
|
|
||||||
|
|
||||||
**What to test:**
|
|
||||||
- Complete user flows (login → create task → complete task)
|
|
||||||
- Navigation between pages
|
|
||||||
- Form submissions
|
|
||||||
- Realtime updates
|
|
||||||
- Error states
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// e2e/tasks.spec.ts
|
|
||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Tasks", () => {
|
|
||||||
test("create and complete a task", async ({ page }) => {
|
|
||||||
await page.goto("/dashboard/tasks");
|
|
||||||
await page.click('[data-testid="create-task-button"]');
|
|
||||||
await page.fill('[data-testid="task-title"]', "New task");
|
|
||||||
await page.click('[data-testid="create-button"]');
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="task-item"]')).toContainText("New task");
|
|
||||||
|
|
||||||
await page.click('[data-testid="task-checkbox"]');
|
|
||||||
await expect(page.locator('[data-testid="task-status"]')).toHaveText("done");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Naming
|
|
||||||
|
|
||||||
- Unit tests: `describe("functionName", () => { ... })`
|
|
||||||
- Component tests: `describe("ComponentName", () => { ... })`
|
|
||||||
- E2E tests: `test.describe("Feature", () => { ... })`
|
|
||||||
|
|
||||||
### Running Tests in CI
|
|
||||||
|
|
||||||
CI runs all tests automatically on every PR:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run typecheck # TypeScript check
|
|
||||||
npm run lint # Linting
|
|
||||||
npm run test # Unit + component tests
|
|
||||||
npm run test:e2e # E2E tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style and Conventions
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- Use strict mode (enabled in `tsconfig.json`)
|
|
||||||
- Prefer interfaces for object shapes, types for unions and utilities
|
|
||||||
- Use `unknown` instead of `any` for external data
|
|
||||||
- Add JSDoc comments for exported functions
|
|
||||||
|
|
||||||
### React
|
|
||||||
|
|
||||||
- Use functional components with hooks
|
|
||||||
- Mark client components with `"use client"` directive
|
|
||||||
- Keep components small and focused
|
|
||||||
- Extract reusable logic into custom hooks
|
|
||||||
- Use `React.memo` only when profiling shows a need
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
- Use Tailwind CSS utility classes
|
|
||||||
- Use `cn()` from `lib/utils.ts` to merge class names
|
|
||||||
- Use `cva` for component variants
|
|
||||||
- Avoid inline styles unless dynamic values are required
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Use `ApiError` and `AuthError` classes from `lib/auth.ts`
|
|
||||||
- Return consistent error responses: `{ error: { code, message, details? } }`
|
|
||||||
- Catch errors at the API route boundary
|
|
||||||
- Log errors with context (user ID, request path)
|
|
||||||
|
|
||||||
### Async/Await
|
|
||||||
|
|
||||||
- Use async/await instead of `.then()` chains
|
|
||||||
- Handle errors with try/catch
|
|
||||||
- Use `AbortSignal.timeout()` for fetch requests with timeouts
|
|
||||||
|
|
||||||
## Git Workflow
|
|
||||||
|
|
||||||
### Branch Naming
|
|
||||||
|
|
||||||
```
|
|
||||||
feature/description : New features
|
|
||||||
fix/description : Bug fixes
|
|
||||||
refactor/description : Code refactoring
|
|
||||||
docs/description : Documentation changes
|
|
||||||
test/description : Test additions
|
|
||||||
chore/description : Maintenance tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit Messages
|
|
||||||
|
|
||||||
Use conventional commits:
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: add bookmark support to notes
|
|
||||||
fix: resolve realtime SSE reconnection loop
|
|
||||||
refactor: extract task filtering into custom hook
|
|
||||||
docs: update API documentation for tasks endpoint
|
|
||||||
test: add E2E tests for habit logging flow
|
|
||||||
chore: upgrade Next.js to 15.3.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit Guidelines
|
|
||||||
|
|
||||||
- One logical change per commit
|
|
||||||
- Keep commits atomic and reversible
|
|
||||||
- Write the subject line in imperative mood ("add feature" not "added feature")
|
|
||||||
- Keep subject lines under 72 characters
|
|
||||||
- Add a body for complex changes explaining the "why"
|
|
||||||
|
|
||||||
### Before Pushing
|
|
||||||
|
|
||||||
1. Run `npm run typecheck`: Must pass
|
|
||||||
2. Run `npm run lint`: Must pass
|
|
||||||
3. Run `npm run test`: Must pass
|
|
||||||
4. Run `npm run test:e2e`: Must pass (for feature/fix branches)
|
|
||||||
5. Review your diff: `git diff --stat`
|
|
||||||
|
|
||||||
## PR Review Process
|
|
||||||
|
|
||||||
### Creating a PR
|
|
||||||
|
|
||||||
1. Push your branch to the remote
|
|
||||||
2. Open a PR against `main`
|
|
||||||
3. Fill in the PR template:
|
|
||||||
- What does this PR do?
|
|
||||||
- Why is this change needed?
|
|
||||||
- How was it tested?
|
|
||||||
- Screenshots (for UI changes)
|
|
||||||
|
|
||||||
### Review Checklist
|
|
||||||
|
|
||||||
Reviewers check:
|
|
||||||
|
|
||||||
- [ ] Code compiles without TypeScript errors
|
|
||||||
- [ ] Lint passes
|
|
||||||
- [ ] Tests pass (unit + E2E)
|
|
||||||
- [ ] Code follows project conventions
|
|
||||||
- [ ] No unnecessary dependencies added
|
|
||||||
- [ ] Error handling is complete
|
|
||||||
- [ ] UI is accessible (keyboard navigation, ARIA labels)
|
|
||||||
- [ ] Documentation updated (if API changed)
|
|
||||||
|
|
||||||
### Merging
|
|
||||||
|
|
||||||
- PRs require at least one approval
|
|
||||||
- All CI checks must pass
|
|
||||||
- Squash merge preferred for clean history
|
|
||||||
- Delete the branch after merging
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Adding a New API Endpoint
|
|
||||||
|
|
||||||
1. Create a directory under `apps/web/app/api/`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir apps/web/app/api/bookmarks
|
npm run db:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Create `route.ts`:
|
3. Review and commit the generated SQL in `drizzle/`.
|
||||||
|
4. Apply the migration to your local PostgreSQL database before testing. The initial Docker setup applies `drizzle/0000_first_mauler.sql`; apply later migrations through your deployment migration process.
|
||||||
|
5. Update shared Zod schemas in `packages/shared/` when validation changes.
|
||||||
|
6. Update the relevant API route, service, state, and UI.
|
||||||
|
7. Add tests for the new behavior.
|
||||||
|
|
||||||
```typescript
|
## Database schema changes
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { withAuth } from "@/lib/auth";
|
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
|
||||||
|
|
||||||
export const GET = withAuth(async (request: NextRequest, user) => {
|
- Do not edit a migration after another environment has applied it.
|
||||||
const pb = createPocketBaseClient();
|
- Keep the Drizzle schema and generated SQL in the same pull request.
|
||||||
const result = await pb.collection("bookmarks").getList(1, 50);
|
- Test a migration against a database with representative data.
|
||||||
return NextResponse.json(result);
|
- Add indexes for fields used in common filters or sorts.
|
||||||
});
|
- Back up production data before applying a migration.
|
||||||
|
|
||||||
export const POST = withAuth(async (request: NextRequest, user) => {
|
`drizzle.config.ts` reads `DATABASE_URL` and writes generated migrations to `drizzle/`.
|
||||||
const body = await request.json();
|
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
const bookmark = await pb.collection("bookmarks").create(body);
|
|
||||||
return NextResponse.json(bookmark, { status: 201 });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For dynamic routes, create `[id]/route.ts`:
|
## Testing
|
||||||
|
|
||||||
```typescript
|
Run the checks that match your change:
|
||||||
export const GET = withAuth(async (request: NextRequest, user, context: { params: { id: string } }) => {
|
|
||||||
const { id } = await context.params;
|
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
const bookmark = await pb.collection("bookmarks").getOne(id);
|
|
||||||
return NextResponse.json(bookmark);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding a New Zustand Store
|
```bash
|
||||||
|
npm run typecheck
|
||||||
```typescript
|
npm run lint
|
||||||
// apps/web/lib/stores/use-bookmarks-store.ts
|
npm run test
|
||||||
import { create } from "zustand";
|
npm run test:e2e
|
||||||
|
|
||||||
interface Bookmark {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookmarksState {
|
|
||||||
bookmarks: Bookmark[];
|
|
||||||
loading: boolean;
|
|
||||||
fetchBookmarks: () => Promise<void>;
|
|
||||||
addBookmark: (bookmark: Bookmark) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBookmarksStore = create<BookmarksState>((set) => ({
|
|
||||||
bookmarks: [],
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
fetchBookmarks: async () => {
|
|
||||||
set({ loading: true });
|
|
||||||
const response = await fetch("/api/bookmarks");
|
|
||||||
const data = await response.json();
|
|
||||||
set({ bookmarks: data.items, loading: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
addBookmark: (bookmark) =>
|
|
||||||
set((state) => ({
|
|
||||||
bookmarks: [...state.bookmarks, bookmark],
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding a New MCP Tool
|
Unit tests cover validation, utilities, services, and store actions. Component tests cover rendering and user interactions. Playwright tests cover browser flows such as signing in, creating a task, and completing it.
|
||||||
|
|
||||||
1. Add the tool to the appropriate file in `apps/web/lib/mcp/tools/`:
|
## Code conventions
|
||||||
|
|
||||||
```typescript
|
- Use TypeScript strict mode.
|
||||||
// apps/web/lib/mcp/tools/bookmarks.ts
|
- Prefer `unknown` over `any` for external input.
|
||||||
server.tool("create_bookmark", "Create a new bookmark", {
|
- Keep React components focused and extract shared logic into hooks.
|
||||||
title: z.string(),
|
- Use Tailwind utility classes, `cn()` for class merging, and `cva` for variants.
|
||||||
url: z.string().url(),
|
- Validate API input with shared Zod schemas.
|
||||||
note_id: z.string().optional(),
|
- Catch errors at API boundaries and log request context.
|
||||||
}, async (args) => {
|
|
||||||
try {
|
|
||||||
const bookmark = await pb.collection("bookmarks").create({
|
|
||||||
title: args.title,
|
|
||||||
url: args.url,
|
|
||||||
note_id: args.note_id || "",
|
|
||||||
});
|
|
||||||
return textContent(JSON.stringify({ success: true, bookmark }));
|
|
||||||
} catch (error) {
|
|
||||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Register the tool in `apps/web/lib/mcp/server.ts`:
|
## Git workflow
|
||||||
|
|
||||||
```typescript
|
Use conventional commits and keep each commit focused. Before pushing, run the relevant checks and review `git diff --stat`.
|
||||||
import { registerBookmarkTools } from "./tools/bookmarks";
|
|
||||||
// ...
|
|
||||||
registerBookmarkTools(server);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding a New Background Job Type
|
Use these branch prefixes:
|
||||||
|
|
||||||
1. Add a case to the worker's `processJob` function in `worker/index.ts`:
|
```
|
||||||
|
feature/description
|
||||||
|
fix/description
|
||||||
|
refactor/description
|
||||||
|
chore/description
|
||||||
|
```
|
||||||
|
|
||||||
```typescript
|
Pull requests should explain the change, its reason, and the tests you ran. Include screenshots for UI changes.
|
||||||
case "send_notification":
|
|
||||||
await handleSendNotification(job);
|
|
||||||
break;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Implement the handler:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async function handleSendNotification(job: QueueJob): Promise<void> {
|
|
||||||
const payload = job.payload as { user_id: string; message: string };
|
|
||||||
const pb = createAdminClient();
|
|
||||||
await pb.collection("notifications").create({
|
|
||||||
user_id: payload.user_id,
|
|
||||||
message: payload.message,
|
|
||||||
type: "info",
|
|
||||||
read: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Schedule the job from your API route or service:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await pb.collection("queue_jobs").create({
|
|
||||||
type: "send_notification",
|
|
||||||
queue: "default",
|
|
||||||
payload: { user_id: "user123", message: "Task completed" },
|
|
||||||
status: "pending",
|
|
||||||
retry_count: 0,
|
|
||||||
max_retries: 3,
|
|
||||||
scheduled_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './packages/db/src/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://project_e:project_e@localhost:5432/project_e',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "records" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"collection" text NOT NULL,
|
||||||
|
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"password_hash" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "records_collection_created_at_idx" ON "records" USING btree ("collection","created_at");
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"id": "acb6d62e-12bc-428d-893f-8e20dc766dc9",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.records": {
|
||||||
|
"name": "records",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"name": "collection",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"records_collection_created_at_idx": {
|
||||||
|
"name": "records_collection_created_at_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "collection",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "postgresql",
|
||||||
"entries": []
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784889026419,
|
||||||
|
"tag": "0000_first_mauler",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1579
-224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@project-e/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./schema": "./src/schema.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sql = postgres(databaseUrl, { max: 10 });
|
||||||
|
export const db = drizzle(sql, { schema });
|
||||||
|
export * from './schema';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collection data is intentionally stored as JSONB. The application has flexible
|
||||||
|
// per-collection fields, and this preserves that shape while PostgreSQL owns storage.
|
||||||
|
export const records = pgTable(
|
||||||
|
'records',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
collection: text('collection').notNull(),
|
||||||
|
data: jsonb('data').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index('records_collection_created_at_idx').on(table.collection, table.createdAt)]
|
||||||
|
);
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
## v0.25.5
|
||||||
|
|
||||||
|
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.4
|
||||||
|
|
||||||
|
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
|
||||||
|
|
||||||
|
While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
|
||||||
|
|
||||||
|
This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
|
||||||
|
|
||||||
|
For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
|
||||||
|
```
|
||||||
|
// go.mod
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.36.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||||
|
|
||||||
|
// after that run
|
||||||
|
go clean -modcache && go mod tidy
|
||||||
|
```
|
||||||
|
_The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.3
|
||||||
|
|
||||||
|
- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.2
|
||||||
|
|
||||||
|
- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.1
|
||||||
|
|
||||||
|
- Fixed the batch API Preview success sample response.
|
||||||
|
|
||||||
|
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.0
|
||||||
|
|
||||||
|
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
|
||||||
|
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
|
||||||
|
- `/v3/userinfo` auth response changes:
|
||||||
|
```
|
||||||
|
meta.rawUser.id => meta.rawUser.sub
|
||||||
|
meta.rawUser.verified_email => meta.rawUser.email_verified
|
||||||
|
```
|
||||||
|
- `/v2/auth` query parameters changes:
|
||||||
|
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
|
||||||
|
|
||||||
|
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
|
||||||
|
|
||||||
|
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
|
||||||
|
|
||||||
|
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
|
||||||
|
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
|
||||||
|
|
||||||
|
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
|
||||||
|
|
||||||
|
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
|
||||||
|
|
||||||
|
- Upgraded to `golang-jwt/jwt/v5`.
|
||||||
|
|
||||||
|
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
|
||||||
|
|
||||||
|
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
|
||||||
|
|
||||||
|
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
|
||||||
|
|
||||||
|
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.4
|
||||||
|
|
||||||
|
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
|
||||||
|
|
||||||
|
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.3
|
||||||
|
|
||||||
|
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
|
||||||
|
|
||||||
|
- Reload the created/edited records data in the RecordsPicker UI.
|
||||||
|
|
||||||
|
- Updated Go dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.2
|
||||||
|
|
||||||
|
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.1
|
||||||
|
|
||||||
|
- Added missing time macros in the UI autocomplete.
|
||||||
|
|
||||||
|
- Fixed JSVM types for structs and functions with multiple generic parameters.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.0
|
||||||
|
|
||||||
|
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
|
||||||
|
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
|
||||||
|
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
|
||||||
|
With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
|
||||||
|
(_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
|
||||||
|
As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
|
||||||
|
|
||||||
|
- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
|
||||||
|
For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
|
||||||
|
|
||||||
|
- Added `@yesterday` and `@tomorrow` datetime filter macros.
|
||||||
|
|
||||||
|
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
|
||||||
|
|
||||||
|
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
|
||||||
|
|
||||||
|
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
|
||||||
|
|
||||||
|
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
|
||||||
|
|
||||||
|
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
|
||||||
|
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
|
||||||
|
|
||||||
|
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
|
||||||
|
|
||||||
|
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
|
||||||
|
|
||||||
|
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
|
||||||
|
|
||||||
|
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
|
||||||
|
|
||||||
|
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
|
||||||
|
|
||||||
|
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
|
||||||
|
|
||||||
|
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
|
||||||
|
|
||||||
|
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
|
||||||
|
|
||||||
|
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.12
|
||||||
|
|
||||||
|
- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
|
||||||
|
|
||||||
|
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.11
|
||||||
|
|
||||||
|
- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
|
||||||
|
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.10
|
||||||
|
|
||||||
|
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
|
||||||
|
|
||||||
|
- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
|
||||||
|
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.9
|
||||||
|
|
||||||
|
- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.8
|
||||||
|
|
||||||
|
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
|
||||||
|
|
||||||
|
- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.7
|
||||||
|
|
||||||
|
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.6
|
||||||
|
|
||||||
|
- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
|
||||||
|
|
||||||
|
- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
|
||||||
|
|
||||||
|
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
|
||||||
|
|
||||||
|
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.5
|
||||||
|
|
||||||
|
- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
|
||||||
|
|
||||||
|
- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
|
||||||
|
|
||||||
|
- Other minor fixes (comment typos, JSVM types update).
|
||||||
|
|
||||||
|
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.4
|
||||||
|
|
||||||
|
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
|
||||||
|
|
||||||
|
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
|
||||||
|
|
||||||
|
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
|
||||||
|
|
||||||
|
- Updated Go and npm dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.3
|
||||||
|
|
||||||
|
- Fixed Gzip middleware not applying when serving static files.
|
||||||
|
|
||||||
|
- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.2
|
||||||
|
|
||||||
|
- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
|
||||||
|
|
||||||
|
- Fixed `--dev` log query print formatting.
|
||||||
|
|
||||||
|
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
|
||||||
|
|
||||||
|
- Added collection rules change list in the confirmation popup
|
||||||
|
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.1
|
||||||
|
|
||||||
|
- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
|
||||||
|
|
||||||
|
- Added more descriptive error messages.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.0
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
|
||||||
|
> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues
|
||||||
|
> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes
|
||||||
|
> will be backported for at least until Q1 of 2025 (_if not longer_).
|
||||||
|
>
|
||||||
|
> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:**
|
||||||
|
> - JS SDK: `<0.22.0`
|
||||||
|
> - Dart SDK: `<0.19.0`
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> This release introduces many Go/JSVM and Web APIs breaking changes!
|
||||||
|
>
|
||||||
|
> Existing `pb_data` will be automatically upgraded with the start of the new executable,
|
||||||
|
> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually.
|
||||||
|
> Please refer to the below upgrade guides:
|
||||||
|
> - Go: https://pocketbase.io/v023upgrade/go/.
|
||||||
|
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
|
||||||
|
>
|
||||||
|
> If you had already switched to some of the earlier `<v0.23.0-rc14` versions and have generated a full collections snapshot migration (aka. `./pocketbase migrate collections`), then you may have to regenerate the migration file to ensure that it includes the latest changes.
|
||||||
|
|
||||||
|
PocketBase v0.23.0 is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
|
||||||
|
There are a lot of changes but to highlight some of the most notable ones:
|
||||||
|
|
||||||
|
- New and more [detailed documentation](https://pocketbase.io/docs/).
|
||||||
|
_The old documentation could be accessed at [pocketbase.io/old](https://pocketbase.io/old/)._
|
||||||
|
- Replaced `echo` with a new router built on top of the Go 1.22 `net/http` mux enhancements.
|
||||||
|
- Merged `daos` packages in `core.App` to simplify the DB operations (_the `models` package structs are also migrated in `core`_).
|
||||||
|
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
|
||||||
|
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
|
||||||
|
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
|
||||||
|
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
||||||
|
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
|
||||||
|
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
|
||||||
|
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
|
||||||
|
- Admins are now system `_superusers` auth records.
|
||||||
|
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
|
||||||
|
- Batch/transactional Web API endpoint.
|
||||||
|
- Impersonate Web API endpoint (_it could be also used for generating fixed/non-refreshable superuser tokens, aka. "API keys"_).
|
||||||
|
- Support for custom user request activity log attributes.
|
||||||
|
- One-Time Password (OTP) auth method (_via email code_).
|
||||||
|
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
|
||||||
|
- Support for Record "proxy/projection" in preparation for the planned autogeneration of typed Go record models.
|
||||||
|
- Linear OAuth2 provider ([#5909](https://github.com/pocketbase/pocketbase/pull/5909); thanks @chnfyi).
|
||||||
|
- WakaTime OAuth2 provider ([#5829](https://github.com/pocketbase/pocketbase/pull/5829); thanks @tigawanna).
|
||||||
|
- Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
|
||||||
|
- monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
|
||||||
|
- New Instagram provider compatible with the new Instagram Login APIs ([#5588](https://github.com/pocketbase/pocketbase/pull/5588); thanks @pnmcosta).
|
||||||
|
_The provider key is `instagram2` to prevent conflicts with existing linked users._
|
||||||
|
- Option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
|
||||||
|
- Various minor UI improvements (_recursive `Presentable` view, slightly different collection options organization, zoom/pan for the logs chart, etc._)
|
||||||
|
- and many more...
|
||||||
|
|
||||||
|
#### Go/JSVM APIs changes
|
||||||
|
|
||||||
|
> - Go: https://pocketbase.io/v023upgrade/go/.
|
||||||
|
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
|
||||||
|
|
||||||
|
#### SDKs changes
|
||||||
|
|
||||||
|
- [JS SDK v0.22.0](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
|
||||||
|
- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
|
||||||
|
|
||||||
|
#### Web APIs changes
|
||||||
|
|
||||||
|
- New `POST /api/batch` endpoint.
|
||||||
|
|
||||||
|
- New `GET /api/collections/meta/scaffolds` endpoint.
|
||||||
|
|
||||||
|
- New `DELETE /api/collections/{collection}/truncate` endpoint.
|
||||||
|
|
||||||
|
- New `POST /api/collections/{collection}/request-otp` endpoint.
|
||||||
|
|
||||||
|
- New `POST /api/collections/{collection}/auth-with-otp` endpoint.
|
||||||
|
|
||||||
|
- New `POST /api/collections/{collection}/impersonate/{id}` endpoint.
|
||||||
|
|
||||||
|
- ⚠️ If you are constructing requests to `/api/*` routes manually remove the trailing slash (_there is no longer trailing slash removal middleware registered by default_).
|
||||||
|
|
||||||
|
- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records.
|
||||||
|
|
||||||
|
- ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values.
|
||||||
|
This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix:
|
||||||
|
```js
|
||||||
|
"documents": [file1, file2] // => [file1_name, file2_name]
|
||||||
|
"+documents": [file1, file2] // => [file1_name, file2_name, old1_name, old2_name]
|
||||||
|
"documents+": [file1, file2] // => [old1_name, old2_name, file1_name, file2_name]
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ Removed `GET /records/{id}/external-auths` and `DELETE /records/{id}/external-auths/{provider}` endpoints because this is now handled by sending list and delete requests to the `_externalAuths` collection.
|
||||||
|
|
||||||
|
- ⚠️ Changes to the app settings model fields and response (+new options such as `trustedProxy`, `rateLimits`, `batch`, etc.). The app settings Web APIs are mostly used by the Dashboard UI and rarely by the end users, but if you want to check all settings changes please refer to the [Settings Go struct](https://github.com/pocketbase/pocketbase/blob/develop/core/settings_model.go#L121).
|
||||||
|
|
||||||
|
- ⚠️ New flatten Collection model and fields structure. The Collection model Web APIs are mostly used by the Dashboard UI and rarely by the end users, but if you want to check all changes please refer to the [Collection Go struct](https://github.com/pocketbase/pocketbase/blob/develop/core/collection_model.go#L308).
|
||||||
|
|
||||||
|
- ⚠️ The top level error response `code` key was renamed to `status` for consistency with the Go APIs.
|
||||||
|
The error field key remains `code`:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"status": 400, // <-- old: "code"
|
||||||
|
"message": "Failed to create record.",
|
||||||
|
"data": {
|
||||||
|
"title": {
|
||||||
|
"code": "validation_required",
|
||||||
|
"message": "Missing required value."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ New fields in the `GET /api/collections/{collection}/auth-methods` response.
|
||||||
|
_The old `authProviders`, `usernamePassword`, `emailPassword` fields are still returned in the response but are considered deprecated and will be removed in the future._
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mfa": {
|
||||||
|
"duration": 100,
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"otp": {
|
||||||
|
"duration": 0,
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"enabled": true,
|
||||||
|
"identityFields": ["email", "username"]
|
||||||
|
},
|
||||||
|
"oauth2": {
|
||||||
|
"enabled": true,
|
||||||
|
"providers": [{"name": "gitlab", ...}, {"name": "google", ...}]
|
||||||
|
},
|
||||||
|
// old fields...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ Soft-deprecated the OAuth2 auth success `meta.avatarUrl` field in favour of `meta.avatarURL`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
Copyright (c) 2022 - present, Gani Georgiev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||||
|
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||||
|
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||||
|
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or
|
||||||
|
substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||||
|
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
Executable
BIN
Binary file not shown.
@@ -1,498 +0,0 @@
|
|||||||
// pocketbase/schema.ts
|
|
||||||
// TypeScript type definitions for PocketBase collections
|
|
||||||
// Used by API layer for type safety
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface RecurringConfig {
|
|
||||||
/** RRULE string */
|
|
||||||
rule: string;
|
|
||||||
next_due?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Attachment {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
mime_type: string;
|
|
||||||
size: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Subtask {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
done: boolean;
|
|
||||||
sort_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Base PocketBase record fields present on every collection row. */
|
|
||||||
export interface BaseRecord {
|
|
||||||
id: string;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
collectionId: string;
|
|
||||||
collectionName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// System collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Domain extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
icon: string;
|
|
||||||
sort_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tag extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Project collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Project extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
color?: string;
|
|
||||||
icon?: string;
|
|
||||||
/** Relation → domains */
|
|
||||||
domain: string;
|
|
||||||
status: 'active' | 'paused' | 'archived';
|
|
||||||
/** Relation → tags (multiple) */
|
|
||||||
tags: string[];
|
|
||||||
goal?: string;
|
|
||||||
deadline?: string;
|
|
||||||
progress: number;
|
|
||||||
members?: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectSettings extends BaseRecord {
|
|
||||||
/** Relation → projects */
|
|
||||||
project_id: string;
|
|
||||||
default_priority?: 'low' | 'medium' | 'high' | 'urgent';
|
|
||||||
/** Relation → domains */
|
|
||||||
default_domain?: string;
|
|
||||||
/** Relation → tags (multiple) */
|
|
||||||
default_tags?: string[];
|
|
||||||
auto_archive_completed_after_days?: number;
|
|
||||||
default_view?: 'kanban' | 'list';
|
|
||||||
habit_reminder_time_default?: string;
|
|
||||||
pomodoro_focus_minutes: number;
|
|
||||||
pomodoro_break_minutes: number;
|
|
||||||
accent_color_override?: string;
|
|
||||||
milestone_dependency_enforcement: boolean;
|
|
||||||
custom_fields?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Milestone extends BaseRecord {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
/** Relation → projects */
|
|
||||||
project_id: string;
|
|
||||||
status: 'planned' | 'in_progress' | 'complete';
|
|
||||||
due_date?: string;
|
|
||||||
sort_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MilestoneDependency extends BaseRecord {
|
|
||||||
/** Relation → milestones */
|
|
||||||
milestone_id: string;
|
|
||||||
/** Relation → milestones */
|
|
||||||
depends_on_milestone_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MilestoneTemplate extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
milestones: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MilestoneHistory extends BaseRecord {
|
|
||||||
/** Relation → milestones */
|
|
||||||
milestone_id: string;
|
|
||||||
old_status: string;
|
|
||||||
new_status: string;
|
|
||||||
changed_by: string;
|
|
||||||
changed_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Task collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Task extends BaseRecord {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
|
||||||
due_date?: string;
|
|
||||||
/** Relation → projects */
|
|
||||||
project_id?: string;
|
|
||||||
/** Relation → milestones */
|
|
||||||
milestone_id?: string;
|
|
||||||
/** Relation → tags (multiple) */
|
|
||||||
tags: string[];
|
|
||||||
/** Relation → domains */
|
|
||||||
domain: string;
|
|
||||||
assignee?: string;
|
|
||||||
estimate?: number;
|
|
||||||
time_spent: number;
|
|
||||||
recurring_config?: RecurringConfig;
|
|
||||||
attachments: Attachment[];
|
|
||||||
dependencies: string[];
|
|
||||||
subtasks: Subtask[];
|
|
||||||
custom_fields?: Record<string, unknown>;
|
|
||||||
completed_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskSubtask extends BaseRecord {
|
|
||||||
/** Relation → tasks */
|
|
||||||
task_id: string;
|
|
||||||
title: string;
|
|
||||||
done: boolean;
|
|
||||||
sort_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskDependency extends BaseRecord {
|
|
||||||
/** Relation → tasks */
|
|
||||||
task_id: string;
|
|
||||||
/** Relation → tasks */
|
|
||||||
depends_on_task_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskAttachment extends BaseRecord {
|
|
||||||
/** Relation → tasks */
|
|
||||||
task_id: string;
|
|
||||||
/** File reference */
|
|
||||||
file: string;
|
|
||||||
filename?: string;
|
|
||||||
mime_type?: string;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskTimeEntry extends BaseRecord {
|
|
||||||
/** Relation → tasks */
|
|
||||||
task_id: string;
|
|
||||||
duration: number;
|
|
||||||
started_at?: string;
|
|
||||||
ended_at?: string;
|
|
||||||
source: 'manual' | 'timer' | 'pomodoro';
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Habit collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Habit extends BaseRecord {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
frequency: 'daily' | 'weekly' | 'custom';
|
|
||||||
cron_expression?: string;
|
|
||||||
target_count: number;
|
|
||||||
streak_current: number;
|
|
||||||
streak_best: number;
|
|
||||||
last_completed_at?: string;
|
|
||||||
/** Relation → projects */
|
|
||||||
project_id?: string;
|
|
||||||
/** Relation → tags (multiple) */
|
|
||||||
tags: string[];
|
|
||||||
/** Relation → domains */
|
|
||||||
domain: string;
|
|
||||||
difficulty: 'easy' | 'medium' | 'hard';
|
|
||||||
mood_tracking: boolean;
|
|
||||||
reminder_times?: string[];
|
|
||||||
skip_days?: string[];
|
|
||||||
unit?: string;
|
|
||||||
goal_per_period?: number;
|
|
||||||
score: number;
|
|
||||||
score_config?: Record<string, unknown>;
|
|
||||||
completion_mode: 'quick' | 'detailed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HabitLog extends BaseRecord {
|
|
||||||
/** Relation → habits */
|
|
||||||
habit_id: string;
|
|
||||||
date: string;
|
|
||||||
mood?: string;
|
|
||||||
quantity?: number;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HabitSkipDay extends BaseRecord {
|
|
||||||
/** Relation → habits */
|
|
||||||
habit_id: string;
|
|
||||||
date: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Note collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Note extends BaseRecord {
|
|
||||||
title: string;
|
|
||||||
content?: string;
|
|
||||||
/** Relation → domains */
|
|
||||||
domain: string;
|
|
||||||
/** Relation → tags (multiple) */
|
|
||||||
tags: string[];
|
|
||||||
linked_entities?: unknown[];
|
|
||||||
created_by?: string;
|
|
||||||
ai_generated: boolean;
|
|
||||||
bookmarked: boolean;
|
|
||||||
pinned: boolean;
|
|
||||||
frontmatter?: Record<string, unknown>;
|
|
||||||
wikilinks?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteLink extends BaseRecord {
|
|
||||||
/** Relation → notes */
|
|
||||||
source_note_id: string;
|
|
||||||
/** Relation → notes */
|
|
||||||
target_note_id: string;
|
|
||||||
link_text?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteTaskLink extends BaseRecord {
|
|
||||||
/** Relation → notes */
|
|
||||||
note_id: string;
|
|
||||||
/** Relation → tasks */
|
|
||||||
task_id: string;
|
|
||||||
checkbox_position: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Report collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface ReportTemplate extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
report_type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom';
|
|
||||||
content_template?: string;
|
|
||||||
is_builtin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Report extends BaseRecord {
|
|
||||||
title: string;
|
|
||||||
content?: string;
|
|
||||||
report_type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom';
|
|
||||||
date_range_start?: string;
|
|
||||||
date_range_end?: string;
|
|
||||||
generated_by?: string;
|
|
||||||
ai_generated: boolean;
|
|
||||||
linked_entities?: unknown[];
|
|
||||||
/** Relation → domains */
|
|
||||||
domain: string;
|
|
||||||
/** Relation → tags (multiple) */
|
|
||||||
tags: string[];
|
|
||||||
/** Relation → report_templates */
|
|
||||||
template_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Canvas collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Canvas extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
mode: 'freeform' | 'graph';
|
|
||||||
/** Relation → projects */
|
|
||||||
project_id?: string;
|
|
||||||
state?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CanvasCard extends BaseRecord {
|
|
||||||
/** Relation → canvases */
|
|
||||||
canvas_id: string;
|
|
||||||
card_type: 'note' | 'task' | 'image' | 'entity';
|
|
||||||
entity_id?: string;
|
|
||||||
entity_type?: string;
|
|
||||||
position_x: number;
|
|
||||||
position_y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
content?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Agent collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Agent extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
api_key: string;
|
|
||||||
avatar?: string;
|
|
||||||
description?: string;
|
|
||||||
permission_tier:
|
|
||||||
| 'full_access'
|
|
||||||
| 'read_only'
|
|
||||||
| 'content_creator'
|
|
||||||
| 'task_manager'
|
|
||||||
| 'custom';
|
|
||||||
custom_permissions?: Record<string, unknown>;
|
|
||||||
webhook_url?: string;
|
|
||||||
last_activity_at?: string;
|
|
||||||
status: 'active' | 'disabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentActivity extends BaseRecord {
|
|
||||||
/** Relation → agents */
|
|
||||||
agent_id: string;
|
|
||||||
action: string;
|
|
||||||
entity_type: string;
|
|
||||||
entity_id: string;
|
|
||||||
before_state?: Record<string, unknown>;
|
|
||||||
after_state?: Record<string, unknown>;
|
|
||||||
prompt?: string;
|
|
||||||
response?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Webhook collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Webhook extends BaseRecord {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
events: string[];
|
|
||||||
secret?: string;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebhookDelivery extends BaseRecord {
|
|
||||||
/** Relation → webhooks */
|
|
||||||
webhook_id: string;
|
|
||||||
event_type: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
response_status?: number;
|
|
||||||
response_body?: string;
|
|
||||||
success: boolean;
|
|
||||||
attempts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Agent task collection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface AgentTask extends BaseRecord {
|
|
||||||
/** Relation → agents */
|
|
||||||
agent_id: string;
|
|
||||||
entity_type: string;
|
|
||||||
entity_id: string;
|
|
||||||
instruction: string;
|
|
||||||
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'delivery_failed';
|
|
||||||
result?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// System collections
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Notification extends BaseRecord {
|
|
||||||
title: string;
|
|
||||||
message?: string;
|
|
||||||
type: 'info' | 'success' | 'warning' | 'error';
|
|
||||||
entity_type?: string;
|
|
||||||
entity_id?: string;
|
|
||||||
read: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorLog extends BaseRecord {
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
stack_trace?: string;
|
|
||||||
source?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueJob extends BaseRecord {
|
|
||||||
job_type: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
|
||||||
attempts: number;
|
|
||||||
max_attempts: number;
|
|
||||||
error?: string;
|
|
||||||
scheduled_at?: string;
|
|
||||||
started_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Collection names & type map
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type CollectionName =
|
|
||||||
| 'domains'
|
|
||||||
| 'tags'
|
|
||||||
| 'projects'
|
|
||||||
| 'project_settings'
|
|
||||||
| 'milestones'
|
|
||||||
| 'milestone_dependencies'
|
|
||||||
| 'milestone_templates'
|
|
||||||
| 'milestone_history'
|
|
||||||
| 'tasks'
|
|
||||||
| 'task_subtasks'
|
|
||||||
| 'task_dependencies'
|
|
||||||
| 'task_attachments'
|
|
||||||
| 'task_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';
|
|
||||||
|
|
||||||
/** Maps each PocketBase collection name to its corresponding TypeScript type. */
|
|
||||||
export interface CollectionTypes {
|
|
||||||
domains: Domain;
|
|
||||||
tags: Tag;
|
|
||||||
projects: Project;
|
|
||||||
project_settings: ProjectSettings;
|
|
||||||
milestones: Milestone;
|
|
||||||
milestone_dependencies: MilestoneDependency;
|
|
||||||
milestone_templates: MilestoneTemplate;
|
|
||||||
milestone_history: MilestoneHistory;
|
|
||||||
tasks: Task;
|
|
||||||
task_subtasks: TaskSubtask;
|
|
||||||
task_dependencies: TaskDependency;
|
|
||||||
task_attachments: TaskAttachment;
|
|
||||||
task_time_entries: TaskTimeEntry;
|
|
||||||
habits: Habit;
|
|
||||||
habit_logs: HabitLog;
|
|
||||||
habit_skip_days: HabitSkipDay;
|
|
||||||
notes: Note;
|
|
||||||
note_links: NoteLink;
|
|
||||||
note_task_links: NoteTaskLink;
|
|
||||||
report_templates: ReportTemplate;
|
|
||||||
reports: Report;
|
|
||||||
canvases: Canvas;
|
|
||||||
canvas_cards: CanvasCard;
|
|
||||||
agents: Agent;
|
|
||||||
agent_activity: AgentActivity;
|
|
||||||
webhooks: Webhook;
|
|
||||||
webhook_deliveries: WebhookDelivery;
|
|
||||||
agent_tasks: AgentTask;
|
|
||||||
notifications: Notification;
|
|
||||||
error_logs: ErrorLog;
|
|
||||||
queue_jobs: QueueJob;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { db, records } from '@project-e/db';
|
||||||
|
|
||||||
|
type RecordData = Record<string, any>;
|
||||||
|
|
||||||
|
function serialize(record: typeof records.$inferSelect): RecordData {
|
||||||
|
return {
|
||||||
|
...record.data,
|
||||||
|
id: record.id,
|
||||||
|
created: record.createdAt.toISOString(),
|
||||||
|
updated: record.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(record: RecordData, filter?: string): boolean {
|
||||||
|
if (!filter) return true;
|
||||||
|
return filter.split('&&').every((term) => {
|
||||||
|
const match = term.trim().match(/^([a-zA-Z_][\w]*)\s*(=|<=|<)\s*(.+)$/);
|
||||||
|
if (!match) return false;
|
||||||
|
const [, field, operator, rawExpected] = match;
|
||||||
|
const expected = rawExpected.trim().replace(/^"|"$/g, '');
|
||||||
|
const actual = record[field];
|
||||||
|
if (operator === '=') return String(actual) === expected;
|
||||||
|
if (operator === '<=') return String(actual ?? '') <= expected;
|
||||||
|
return String(actual ?? '') < expected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDatabaseClient() {
|
||||||
|
return {
|
||||||
|
collection(collection: string) {
|
||||||
|
return {
|
||||||
|
async getList(page = 1, perPage = 50, options: { filter?: string; sort?: string } = {}) {
|
||||||
|
const rows = (await db.select().from(records).where(eq(records.collection, collection)))
|
||||||
|
.map(serialize)
|
||||||
|
.filter((record) => matchesFilter(record, options.filter));
|
||||||
|
return {
|
||||||
|
items: rows.slice((page - 1) * perPage, page * perPage),
|
||||||
|
totalItems: rows.length,
|
||||||
|
totalPages: Math.max(1, Math.ceil(rows.length / perPage)),
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async create(data: RecordData) {
|
||||||
|
const [record] = await db.insert(records).values({ collection, data }).returning();
|
||||||
|
return serialize(record);
|
||||||
|
},
|
||||||
|
async update(id: string, data: RecordData) {
|
||||||
|
const [existing] = await db.select().from(records).where(and(eq(records.id, id), eq(records.collection, collection))).limit(1);
|
||||||
|
if (!existing) throw new Error(`Record ${id} not found`);
|
||||||
|
const [record] = await db.update(records)
|
||||||
|
.set({ data: { ...existing.data, ...data }, updatedAt: new Date() })
|
||||||
|
.where(and(eq(records.id, id), eq(records.collection, collection)))
|
||||||
|
.returning();
|
||||||
|
return serialize(record);
|
||||||
|
},
|
||||||
|
async delete(id: string) {
|
||||||
|
await db.delete(records).where(and(eq(records.id, id), eq(records.collection, collection)));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-12
@@ -1,7 +1,4 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import { createDatabaseClient } from './database.js';
|
||||||
|
|
||||||
const POCKETBASE_URL = process.env.POCKETBASE_URL || 'http://localhost:8090';
|
|
||||||
const ADMIN_TOKEN = process.env.POCKETBASE_ADMIN_TOKEN || '';
|
|
||||||
const POLL_INTERVAL_BASE = 5000; // 5 seconds base
|
const POLL_INTERVAL_BASE = 5000; // 5 seconds base
|
||||||
const POLL_INTERVAL_MAX = 60000; // 60 seconds max
|
const POLL_INTERVAL_MAX = 60000; // 60 seconds max
|
||||||
|
|
||||||
@@ -20,12 +17,8 @@ interface QueueJob {
|
|||||||
let currentPollInterval = POLL_INTERVAL_BASE;
|
let currentPollInterval = POLL_INTERVAL_BASE;
|
||||||
let isProcessing = false;
|
let isProcessing = false;
|
||||||
|
|
||||||
function createAdminClient(): PocketBase {
|
function createAdminClient() {
|
||||||
const pb = new PocketBase(POCKETBASE_URL);
|
return createDatabaseClient();
|
||||||
if (ADMIN_TOKEN) {
|
|
||||||
pb.authStore.save(ADMIN_TOKEN, null);
|
|
||||||
}
|
|
||||||
return pb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +36,7 @@ async function poll(): Promise<void> {
|
|||||||
const jobs = await pb.collection('queue_jobs').getList(1, 10, {
|
const jobs = await pb.collection('queue_jobs').getList(1, 10, {
|
||||||
filter: `status = "pending" && scheduled_at <= "${now}"`,
|
filter: `status = "pending" && scheduled_at <= "${now}"`,
|
||||||
sort: 'created',
|
sort: 'created',
|
||||||
}) as { items: QueueJob[] };
|
}) as unknown as { items: QueueJob[] };
|
||||||
|
|
||||||
if (jobs.items.length > 0) {
|
if (jobs.items.length > 0) {
|
||||||
console.log(`[Worker] Processing ${jobs.items.length} job(s)`);
|
console.log(`[Worker] Processing ${jobs.items.length} job(s)`);
|
||||||
@@ -335,7 +328,7 @@ async function scheduleCleanup(): Promise<void> {
|
|||||||
|
|
||||||
// Start the worker
|
// Start the worker
|
||||||
console.log('[Worker] Starting Project E worker...');
|
console.log('[Worker] Starting Project E worker...');
|
||||||
console.log(`[Worker] PocketBase URL: ${POCKETBASE_URL}`);
|
console.log('[Worker] PostgreSQL queue enabled');
|
||||||
console.log(`[Worker] Poll interval: ${POLL_INTERVAL_BASE}ms (base), ${POLL_INTERVAL_MAX}ms (max)`);
|
console.log(`[Worker] Poll interval: ${POLL_INTERVAL_BASE}ms (base), ${POLL_INTERVAL_MAX}ms (max)`);
|
||||||
|
|
||||||
// Initial cleanup schedule
|
// Initial cleanup schedule
|
||||||
|
|||||||
+7
-4
@@ -9,11 +9,14 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pocketbase": "^0.25.0",
|
"@project-e/db": "^0.1.0",
|
||||||
"@project-e/shared": "*"
|
"@project-e/shared": "*",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
"tsx": "^4.23.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"@types/node": "^22.19.0",
|
||||||
"@types/node": "^22.19.0"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user