Database Plugin Example
Complete worked example — building a plugin backend that follows the single-DB multi-schema architecture.
What We're Building#
A Task Tracker plugin that stores projects and tasks in the unified database using the plugin_tracker schema. This example demonstrates:
- Registering a new schema
- Defining models with proper naming and annotations
- Using
@naap/databasefor all data access - CRUD routes with typed Prisma queries
Complete File Structure#
| 1 | plugins/task-tracker/ |
| 2 | ├── backend/ |
| 3 | │ ├── src/ |
| 4 | │ │ ├── server.ts |
| 5 | │ │ ├── db/ |
| 6 | │ │ │ └── client.ts |
| 7 | │ │ └── routes/ |
| 8 | │ │ ├── projects.ts |
| 9 | │ │ └── tasks.ts |
| 10 | │ ├── package.json |
| 11 | │ ├── tsconfig.json |
| 12 | │ └── .env |
| 13 | └── frontend/ |
| 14 | └── ... (React code, not covered here) |
Step 1: Schema Registration#
docker/init-schemas.sql#
-- Add this line alongside the other schemas:
CREATE SCHEMA IF NOT EXISTS plugin_tracker;packages/database/prisma/schema.prisma#
Add to the schemas array:
| 1 | datasource db { |
| 2 | provider = "postgresql" |
| 3 | url = env("DATABASE_URL") |
| 4 | schemas = [ |
| 5 | "public", |
| 6 | "plugin_community", |
| 7 | "plugin_wallet", |
| 8 | "plugin_dashboard", |
| 9 | "plugin_daydream", |
| 10 | "plugin_service_gateway", |
| 11 | "plugin_capacity", |
| 12 | "plugin_developer_api", |
| 13 | "plugin_tracker" // ← New |
| 14 | ] |
| 15 | } |
Add the models:
| 1 | // ============================================= |
| 2 | // Task Tracker Plugin — plugin_tracker schema |
| 3 | // ============================================= |
| 4 | |
| 5 | enum TrackerProjectStatus { |
| 6 | ACTIVE |
| 7 | ARCHIVED |
| 8 | COMPLETED |
| 9 | |
| 10 | @@schema("plugin_tracker") |
| 11 | } |
| 12 | |
| 13 | enum TrackerTaskPriority { |
| 14 | LOW |
| 15 | MEDIUM |
| 16 | HIGH |
| 17 | URGENT |
| 18 | |
| 19 | @@schema("plugin_tracker") |
| 20 | } |
| 21 | |
| 22 | enum TrackerTaskStatus { |
| 23 | TODO |
| 24 | IN_PROGRESS |
| 25 | IN_REVIEW |
| 26 | DONE |
| 27 | |
| 28 | @@schema("plugin_tracker") |
| 29 | } |
| 30 | |
| 31 | model TrackerProject { |
| 32 | id String @id @default(uuid()) |
| 33 | name String |
| 34 | description String? |
| 35 | status TrackerProjectStatus @default(ACTIVE) |
| 36 | ownerId String |
| 37 | teamId String |
| 38 | createdAt DateTime @default(now()) |
| 39 | updatedAt DateTime @updatedAt |
| 40 | |
| 41 | tasks TrackerTask[] |
| 42 | |
| 43 | @@index([teamId, status]) |
| 44 | @@index([ownerId]) |
| 45 | @@schema("plugin_tracker") |
| 46 | } |
| 47 | |
| 48 | model TrackerTask { |
| 49 | id String @id @default(uuid()) |
| 50 | title String |
| 51 | description String? |
| 52 | status TrackerTaskStatus @default(TODO) |
| 53 | priority TrackerTaskPriority @default(MEDIUM) |
| 54 | assigneeId String? |
| 55 | dueDate DateTime? |
| 56 | projectId String |
| 57 | project TrackerProject @relation(fields: [projectId], references: [id], onDelete: Cascade) |
| 58 | createdAt DateTime @default(now()) |
| 59 | updatedAt DateTime @updatedAt |
| 60 | |
| 61 | @@index([projectId, status]) |
| 62 | @@index([assigneeId]) |
| 63 | @@schema("plugin_tracker") |
| 64 | } |
Then generate and push:
Step 2: Plugin Backend Files#
backend/package.json#
| 1 | { |
| 2 | "name": "@naap/plugin-task-tracker-backend", |
| 3 | "version": "1.0.0", |
| 4 | "type": "module", |
| 5 | "scripts": { |
| 6 | "dev": "tsx watch src/server.ts", |
| 7 | "build": "tsc", |
| 8 | "start": "node dist/server.js" |
| 9 | }, |
| 10 | "dependencies": { |
| 11 | "@naap/database": "workspace:*", |
| 12 | "@naap/plugin-server-sdk": "workspace:*", |
| 13 | "express": "^4.18.0", |
| 14 | "zod": "^3.22.0" |
| 15 | }, |
| 16 | "devDependencies": { |
| 17 | "@types/express": "^4.17.21", |
| 18 | "tsx": "^4.7.0", |
| 19 | "typescript": "^5.3.0" |
| 20 | } |
| 21 | } |
Note: No @prisma/client, no prisma devDependency, no db:* scripts.
backend/.env#
backend/src/db/client.ts#
| 1 | /** |
| 2 | * Database client — imports the unified Prisma client. |
| 3 | * NEVER instantiate PrismaClient here. Always re-export from @naap/database. |
| 4 | */ |
| 5 | import { prisma } from '@naap/database'; |
| 6 | export const db = prisma; |
This is the entire file. No new PrismaClient(), no generated imports.
backend/src/server.ts#
| 1 | import express from 'express'; |
| 2 | import { db } from './db/client.js'; |
| 3 | import projectRoutes from './routes/projects.js'; |
| 4 | import taskRoutes from './routes/tasks.js'; |
| 5 | |
| 6 | const app = express(); |
| 7 | app.use(express.json()); |
| 8 | |
| 9 | // Health check |
| 10 | app.get('/health', (_req, res) => { |
| 11 | res.json({ status: 'ok', plugin: 'task-tracker' }); |
| 12 | }); |
| 13 | |
| 14 | // Routes |
| 15 | app.use('/api/v1/task-tracker/projects', projectRoutes); |
| 16 | app.use('/api/v1/task-tracker/tasks', taskRoutes); |
| 17 | |
| 18 | const PORT = parseInt(process.env.PORT || '4070', 10); |
| 19 | app.listen(PORT, () => { |
| 20 | console.log(`Task Tracker backend running on port ${PORT}`); |
| 21 | }); |
| 22 | |
| 23 | // Graceful shutdown |
| 24 | process.on('SIGTERM', async () => { |
| 25 | await db.$disconnect(); |
| 26 | process.exit(0); |
| 27 | }); |
backend/src/routes/projects.ts#
| 1 | import { Router } from 'express'; |
| 2 | import { z } from 'zod'; |
| 3 | import { db } from '../db/client.js'; |
| 4 | |
| 5 | const router = Router(); |
| 6 | |
| 7 | // ── Validation ────────────────────────────────────────── |
| 8 | const CreateProjectSchema = z.object({ |
| 9 | name: z.string().min(1).max(200), |
| 10 | description: z.string().optional(), |
| 11 | }); |
| 12 | |
| 13 | const UpdateProjectSchema = z.object({ |
| 14 | name: z.string().min(1).max(200).optional(), |
| 15 | description: z.string().optional(), |
| 16 | status: z.enum(['ACTIVE', 'ARCHIVED', 'COMPLETED']).optional(), |
| 17 | }); |
| 18 | |
| 19 | // ── Routes ────────────────────────────────────────────── |
| 20 | |
| 21 | // GET /projects — List projects for the team |
| 22 | router.get('/', async (req, res) => { |
| 23 | try { |
| 24 | const teamId = req.headers['x-team-id'] as string; |
| 25 | const projects = await db.trackerProject.findMany({ |
| 26 | where: { teamId }, |
| 27 | include: { tasks: { select: { id: true, status: true } } }, |
| 28 | orderBy: { createdAt: 'desc' }, |
| 29 | }); |
| 30 | |
| 31 | // Attach task counts |
| 32 | const data = projects.map((p) => ({ |
| 33 | ...p, |
| 34 | taskCount: p.tasks.length, |
| 35 | doneCount: p.tasks.filter((t) => t.status === 'DONE').length, |
| 36 | tasks: undefined, |
| 37 | })); |
| 38 | |
| 39 | res.json({ success: true, data }); |
| 40 | } catch (error) { |
| 41 | console.error('Failed to list projects:', error); |
| 42 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 43 | } |
| 44 | }); |
| 45 | |
| 46 | // POST /projects — Create a project |
| 47 | router.post('/', async (req, res) => { |
| 48 | try { |
| 49 | const body = CreateProjectSchema.parse(req.body); |
| 50 | const project = await db.trackerProject.create({ |
| 51 | data: { |
| 52 | ...body, |
| 53 | ownerId: req.headers['x-user-id'] as string, |
| 54 | teamId: req.headers['x-team-id'] as string, |
| 55 | }, |
| 56 | }); |
| 57 | res.status(201).json({ success: true, data: project }); |
| 58 | } catch (error) { |
| 59 | if (error instanceof z.ZodError) { |
| 60 | return res.status(400).json({ success: false, error: error.errors }); |
| 61 | } |
| 62 | console.error('Failed to create project:', error); |
| 63 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 64 | } |
| 65 | }); |
| 66 | |
| 67 | // GET /projects/:id — Get project with all tasks |
| 68 | router.get('/:id', async (req, res) => { |
| 69 | try { |
| 70 | const project = await db.trackerProject.findUnique({ |
| 71 | where: { id: req.params.id }, |
| 72 | include: { |
| 73 | tasks: { orderBy: { createdAt: 'desc' } }, |
| 74 | }, |
| 75 | }); |
| 76 | |
| 77 | if (!project) { |
| 78 | return res.status(404).json({ success: false, error: 'Project not found' }); |
| 79 | } |
| 80 | res.json({ success: true, data: project }); |
| 81 | } catch (error) { |
| 82 | console.error('Failed to get project:', error); |
| 83 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | // PATCH /projects/:id — Update project |
| 88 | router.patch('/:id', async (req, res) => { |
| 89 | try { |
| 90 | const body = UpdateProjectSchema.parse(req.body); |
| 91 | const project = await db.trackerProject.update({ |
| 92 | where: { id: req.params.id }, |
| 93 | data: body, |
| 94 | }); |
| 95 | res.json({ success: true, data: project }); |
| 96 | } catch (error) { |
| 97 | if (error instanceof z.ZodError) { |
| 98 | return res.status(400).json({ success: false, error: error.errors }); |
| 99 | } |
| 100 | console.error('Failed to update project:', error); |
| 101 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 102 | } |
| 103 | }); |
| 104 | |
| 105 | // DELETE /projects/:id — Delete project (cascades to tasks) |
| 106 | router.delete('/:id', async (req, res) => { |
| 107 | try { |
| 108 | await db.trackerProject.delete({ where: { id: req.params.id } }); |
| 109 | res.json({ success: true }); |
| 110 | } catch (error) { |
| 111 | console.error('Failed to delete project:', error); |
| 112 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 113 | } |
| 114 | }); |
| 115 | |
| 116 | export default router; |
backend/src/routes/tasks.ts#
| 1 | import { Router } from 'express'; |
| 2 | import { z } from 'zod'; |
| 3 | import { db } from '../db/client.js'; |
| 4 | |
| 5 | const router = Router(); |
| 6 | |
| 7 | // ── Validation ────────────────────────────────────────── |
| 8 | const CreateTaskSchema = z.object({ |
| 9 | title: z.string().min(1).max(500), |
| 10 | description: z.string().optional(), |
| 11 | priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).default('MEDIUM'), |
| 12 | assigneeId: z.string().uuid().optional(), |
| 13 | dueDate: z.string().datetime().optional(), |
| 14 | projectId: z.string().uuid(), |
| 15 | }); |
| 16 | |
| 17 | const UpdateTaskSchema = z.object({ |
| 18 | title: z.string().min(1).max(500).optional(), |
| 19 | description: z.string().optional(), |
| 20 | status: z.enum(['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE']).optional(), |
| 21 | priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(), |
| 22 | assigneeId: z.string().uuid().nullable().optional(), |
| 23 | dueDate: z.string().datetime().nullable().optional(), |
| 24 | }); |
| 25 | |
| 26 | // ── Routes ────────────────────────────────────────────── |
| 27 | |
| 28 | // GET /tasks?projectId=xxx — List tasks for a project |
| 29 | router.get('/', async (req, res) => { |
| 30 | try { |
| 31 | const { projectId, status, assigneeId } = req.query; |
| 32 | |
| 33 | const tasks = await db.trackerTask.findMany({ |
| 34 | where: { |
| 35 | projectId: projectId as string, |
| 36 | ...(status && { status: status as any }), |
| 37 | ...(assigneeId && { assigneeId: assigneeId as string }), |
| 38 | }, |
| 39 | orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }], |
| 40 | }); |
| 41 | |
| 42 | res.json({ success: true, data: tasks }); |
| 43 | } catch (error) { |
| 44 | console.error('Failed to list tasks:', error); |
| 45 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 46 | } |
| 47 | }); |
| 48 | |
| 49 | // POST /tasks — Create a task |
| 50 | router.post('/', async (req, res) => { |
| 51 | try { |
| 52 | const body = CreateTaskSchema.parse(req.body); |
| 53 | const task = await db.trackerTask.create({ |
| 54 | data: { |
| 55 | ...body, |
| 56 | dueDate: body.dueDate ? new Date(body.dueDate) : undefined, |
| 57 | }, |
| 58 | }); |
| 59 | res.status(201).json({ success: true, data: task }); |
| 60 | } catch (error) { |
| 61 | if (error instanceof z.ZodError) { |
| 62 | return res.status(400).json({ success: false, error: error.errors }); |
| 63 | } |
| 64 | console.error('Failed to create task:', error); |
| 65 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 66 | } |
| 67 | }); |
| 68 | |
| 69 | // PATCH /tasks/:id — Update task (status, assignee, etc.) |
| 70 | router.patch('/:id', async (req, res) => { |
| 71 | try { |
| 72 | const body = UpdateTaskSchema.parse(req.body); |
| 73 | const task = await db.trackerTask.update({ |
| 74 | where: { id: req.params.id }, |
| 75 | data: { |
| 76 | ...body, |
| 77 | dueDate: body.dueDate === null ? null : |
| 78 | body.dueDate ? new Date(body.dueDate) : undefined, |
| 79 | }, |
| 80 | }); |
| 81 | res.json({ success: true, data: task }); |
| 82 | } catch (error) { |
| 83 | if (error instanceof z.ZodError) { |
| 84 | return res.status(400).json({ success: false, error: error.errors }); |
| 85 | } |
| 86 | console.error('Failed to update task:', error); |
| 87 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 88 | } |
| 89 | }); |
| 90 | |
| 91 | // DELETE /tasks/:id |
| 92 | router.delete('/:id', async (req, res) => { |
| 93 | try { |
| 94 | await db.trackerTask.delete({ where: { id: req.params.id } }); |
| 95 | res.json({ success: true }); |
| 96 | } catch (error) { |
| 97 | console.error('Failed to delete task:', error); |
| 98 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 99 | } |
| 100 | }); |
| 101 | |
| 102 | // PATCH /tasks/:id/status — Quick status update (Kanban drag) |
| 103 | router.patch('/:id/status', async (req, res) => { |
| 104 | try { |
| 105 | const { status } = z.object({ |
| 106 | status: z.enum(['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE']), |
| 107 | }).parse(req.body); |
| 108 | |
| 109 | const task = await db.trackerTask.update({ |
| 110 | where: { id: req.params.id }, |
| 111 | data: { status }, |
| 112 | }); |
| 113 | res.json({ success: true, data: task }); |
| 114 | } catch (error) { |
| 115 | console.error('Failed to update task status:', error); |
| 116 | res.status(500).json({ success: false, error: 'Internal server error' }); |
| 117 | } |
| 118 | }); |
| 119 | |
| 120 | export default router; |
Key Patterns to Notice#
1. No PrismaClient instantiation#
The db/client.ts file is a one-liner that re-exports from @naap/database. There's zero boilerplate.
2. Prefixed model names#
Every model starts with Tracker — this maps to the db.trackerProject and db.trackerTask accessors. No collisions with other plugins.
3. Schema annotation on every model AND enum#
Even enums get @@schema("plugin_tracker"). This is required by Prisma's multiSchema preview feature.
4. Single .env#
The .env file has one DATABASE_URL pointing to localhost:5432/naap. Same as every other plugin.
5. No local Prisma commands#
The package.json has no db:generate, db:push, or prisma scripts. All schema management is done centrally from packages/database.
Testing the Example#
Anti-Patterns (Do NOT Do This)#
| 1 | // ❌ WRONG — Creating your own PrismaClient |
| 2 | import { PrismaClient } from '@prisma/client'; |
| 3 | const prisma = new PrismaClient(); |
| 4 | |
| 5 | // ❌ WRONG — Importing from a local generated directory |
| 6 | import { PrismaClient } from '../generated/client'; |
| 7 | |
| 8 | // ❌ WRONG — Models without schema annotation |
| 9 | model Task { |
| 10 | id String @id @default(uuid()) |
| 11 | // missing @@schema("plugin_tracker") |
| 12 | } |
| 13 | |
| 14 | // ❌ WRONG — Unprefixed model names (will collide) |
| 15 | model Project { ... } // Use TrackerProject instead |
| 16 | |
| 17 | // ❌ WRONG — Having prisma/ directory in your plugin |
| 18 | plugins/task-tracker/backend/prisma/schema.prisma // DELETE THIS |
See Also#
- Database Architecture Rules — The mandatory architecture
- Database Setup Tutorial — Step-by-step schema guide
- AI Prompt: Database Compliance — Copy-paste prompt