From a7488276acce6585287863725832a615d3237492 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:20:24 +0800 Subject: [PATCH] feat: make database optional for projects - Remove database creation from project creation flow - Add on-demand database creation via "Add Database" button - Update StatusBar to show "Not Configured" when no database - Add createDatabase() and deleteDatabase() server actions - Add AddDatabaseCard component for database page - Add type guards for optional database handling - Update documentation to reflect optional database Breaking Change: New projects will not automatically create a database. Users can add a database on-demand from the project's database page. Existing projects with databases are not affected. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 10 +- .../_components/add-database-card.tsx | 56 +++++++ .../projects/[id]/database/page.tsx | 17 ++- app/api/projects/route.ts | 30 +--- components/layout/status-bar.tsx | 16 +- lib/actions/database.ts | 137 ++++++++++++++++++ lib/actions/project.ts | 30 +--- lib/util/projectStatus.ts | 8 +- lib/util/type-guards.ts | 37 +++++ 9 files changed, 279 insertions(+), 62 deletions(-) create mode 100644 app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx create mode 100644 lib/actions/database.ts create mode 100644 lib/util/type-guards.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5401728..d31c8f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Key Features**: - **Flexible Project Creation**: Import from GitHub repositories or create new projects from scratch +- **Optional Database**: Add PostgreSQL database on-demand when needed - **AI Agent Ecosystem**: AI agents handle development, testing, deployment, and infrastructure management - **Automated Operations**: Deployment, scaling, and infrastructure management happen automatically in the background -- **Full-Stack Development**: Complete environment with database, terminal, and file management +- **Full-Stack Development**: Complete environment with optional database, terminal, and file management - **Zero Infrastructure Knowledge Required**: Users don't need to understand Kubernetes, networking, or DevOps **Architecture**: The platform uses an **asynchronous reconciliation pattern** where API endpoints return immediately and background jobs sync desired state (database) with actual state (Kubernetes) every 3 seconds. @@ -158,6 +159,10 @@ npx prisma db push # Push schema to database - `lib/k8s/k8s-service-helper.ts` - User-specific K8s service - `lib/events/sandbox/sandboxListener.ts` - Sandbox lifecycle handlers - `lib/jobs/sandbox/sandboxReconcile.ts` - Sandbox reconciliation job +- `lib/events/database/databaseListener.ts` - Database lifecycle handlers +- `lib/jobs/database/databaseReconcile.ts` - Database reconciliation job +- `lib/actions/project.ts` - Project creation (creates Sandbox only) +- `lib/actions/database.ts` - Database creation/deletion (on-demand) - `prisma/schema.prisma` - Database schema - `instrumentation.ts` - Application startup @@ -181,10 +186,11 @@ npx prisma db push # Push schema to database ## Important Notes +- **Project Resources**: Each project includes a Sandbox (required) and can optionally have a Database (PostgreSQL). Database can be added on-demand after project creation. - **Reconciliation Delay**: Status updates may take up to 3 seconds - **User-Specific Namespaces**: Each user operates in their own K8s namespace - **Frontend Polling**: Client components poll every 3 seconds for status updates -- **Database Wait Time**: PostgreSQL cluster takes 2-3 minutes to reach "Running" +- **Database Wait Time**: PostgreSQL cluster takes 2-3 minutes to reach "Running" (when added) - **Idempotent Operations**: All K8s methods can be called multiple times safely - **Lock Duration**: Optimistic locks held for 30 seconds - **Deployment Domain**: Main app listens on `0.0.0.0:3000` (not localhost) for Sealos diff --git a/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx b/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx new file mode 100644 index 0000000..e80046f --- /dev/null +++ b/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx @@ -0,0 +1,56 @@ +'use client' + +import { useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { createDatabase } from '@/lib/actions/database' + +interface AddDatabaseCardProps { + projectId: string + projectName: string +} + +export function AddDatabaseCard({ projectId }: AddDatabaseCardProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const handleCreateDatabase = () => { + startTransition(async () => { + const result = await createDatabase(projectId) + + if (!result.success) { + toast.error(result.error || 'Failed to create database') + return + } + + toast.success('Database is being created...') + router.refresh() + }) + } + + return ( + + + No Database + + This project doesn't have a database yet. Add a PostgreSQL database to get started. + + + + +

+ A PostgreSQL cluster will be created with 1Gi storage, 100m CPU, and 128Mi memory. +

+
+
+ ) +} diff --git a/app/(dashboard)/projects/[id]/database/page.tsx b/app/(dashboard)/projects/[id]/database/page.tsx index edc13f4..dad82bd 100644 --- a/app/(dashboard)/projects/[id]/database/page.tsx +++ b/app/(dashboard)/projects/[id]/database/page.tsx @@ -6,6 +6,7 @@ import { getProject } from '@/lib/data/project'; import { SettingsLayout } from '../_components/settings-layout'; +import { AddDatabaseCard } from './_components/add-database-card'; import { ConnectionString } from './_components/connection-string'; import { FeatureCards } from './_components/feature-cards'; import { ReadOnlyField } from './_components/read-only-field'; @@ -28,7 +29,17 @@ export default async function DatabasePage({ params }: { params: Promise<{ id: s if (!project) notFound(); const database = project.databases[0]; - const connectionString = database?.connectionUrl || ''; + + // If no database exists, show "Add Database" card + if (!database) { + return ( + + + + ); + } + + const connectionString = database.connectionUrl || ''; const connectionInfo = parseConnectionUrl(connectionString) || { host: '', port: '', database: '', username: '', password: '' }; @@ -57,9 +68,9 @@ export default async function DatabasePage({ params }: { params: Promise<{ id: s ) : (
-

No database configured

+

Database is being created...

- Database will be automatically provisioned when sandbox is created + Connection details will appear once the database is ready

)} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 48f677e..e118c56 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -196,10 +196,9 @@ export const POST = withAuth(async (req, _context, session) const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username const fileBrowserPassword = generateRandomString(16) // 16 char random password - const databaseName = `${k8sProjectName}-${randomSuffix}` const sandboxName = `${k8sProjectName}-${randomSuffix}` - // Create project with database and sandbox in a transaction + // Create project with sandbox in a transaction const result = await prisma.$transaction(async (tx) => { // 1. Create Project with status CREATING const project = await tx.project.create({ @@ -211,25 +210,7 @@ export const POST = withAuth(async (req, _context, session) }, }) - // 2. Create Database record - lockedUntil is null so reconcile job can process immediately - const database = await tx.database.create({ - data: { - projectId: project.id, - name: databaseName, - k8sNamespace: namespace, - databaseName: databaseName, - status: 'CREATING', - lockedUntil: null, // Unlocked - ready for reconcile job to process - // Resource configuration from versions - storageSize: VERSIONS.STORAGE.DATABASE_SIZE, - cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu, - cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu, - memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory, - memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory, - }, - }) - - // 3. Create Sandbox record - lockedUntil is null so reconcile job can process immediately + // 2. Create Sandbox record - lockedUntil is null so reconcile job can process immediately const sandbox = await tx.sandbox.create({ data: { projectId: project.id, @@ -247,7 +228,7 @@ export const POST = withAuth(async (req, _context, session) }, }) - // 4. Create Environment record for ttyd access token + // 3. Create Environment record for ttyd access token const ttydEnv = await tx.environment.create({ data: { projectId: project.id, @@ -258,7 +239,7 @@ export const POST = withAuth(async (req, _context, session) }, }) - // 5. Create Environment records for filebrowser credentials + // 4. Create Environment records for filebrowser credentials const fileBrowserUsernameEnv = await tx.environment.create({ data: { projectId: project.id, @@ -281,7 +262,6 @@ export const POST = withAuth(async (req, _context, session) return { project, - database, sandbox, ttydEnv, fileBrowserUsernameEnv, @@ -292,7 +272,7 @@ export const POST = withAuth(async (req, _context, session) }) logger.info( - `Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}` + `Project created: ${result.project.id} with sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}` ) return NextResponse.json(result.project) diff --git a/components/layout/status-bar.tsx b/components/layout/status-bar.tsx index 8afdd08..5bcaaf5 100644 --- a/components/layout/status-bar.tsx +++ b/components/layout/status-bar.tsx @@ -14,7 +14,6 @@ export function StatusBar({ projectId }: StatusBarProps) { const { data: project } = useProject(projectId); const database = project?.databases?.[0]; - const dbStatus = database?.status || 'CREATING'; const sandbox = project?.sandboxes?.[0]; const sbStatus = sandbox?.status || 'CREATING'; @@ -35,10 +34,17 @@ export function StatusBar({ projectId }: StatusBarProps) { Sandbox: {sbStatus}
-
-
- Database: {dbStatus} -
+ {database ? ( +
+
+ Database: {database.status} +
+ ) : ( +
+
+ Database: Not Configured +
+ )}
); diff --git a/lib/actions/database.ts b/lib/actions/database.ts new file mode 100644 index 0000000..0a0079c --- /dev/null +++ b/lib/actions/database.ts @@ -0,0 +1,137 @@ +'use server' + +/** + * Database Server Actions + * + * Server Actions for database operations. Frontend components call these + * to create and delete databases on-demand. + */ + +import type { Database } from '@prisma/client' + +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' +import { VERSIONS } from '@/lib/k8s/versions' +import { logger as baseLogger } from '@/lib/logger' + +import type { ActionResult } from './types' + +const logger = baseLogger.child({ module: 'actions/database' }) + +/** + * Create a database for an existing project + * + * @param projectId - Project ID + * @param databaseName - Optional custom database name (auto-generated if not provided) + */ +export async function createDatabase( + projectId: string, + databaseName?: string +): Promise> { + const session = await auth() + if (!session) { + return { success: false, error: 'Unauthorized' } + } + + // Verify project exists and belongs to user + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { databases: true }, + }) + + if (!project) { + return { success: false, error: 'Project not found' } + } + + if (project.userId !== session.user.id) { + return { success: false, error: 'Unauthorized' } + } + + // Check if database already exists + if (project.databases.length > 0) { + return { success: false, error: 'Database already exists for this project' } + } + + // Get K8s service for user + let k8sService + let namespace + try { + k8sService = await getK8sServiceForUser(session.user.id) + namespace = k8sService.getDefaultNamespace() + } catch (error) { + if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { + return { + success: false, + error: 'Please configure your kubeconfig before creating a database', + } + } + throw error + } + + // Generate database name if not provided + const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name) + const randomSuffix = KubernetesUtils.generateRandomString() + const finalDatabaseName = databaseName || `${k8sProjectName}-db-${randomSuffix}` + + // Create Database record + const database = await prisma.database.create({ + data: { + projectId: project.id, + name: finalDatabaseName, + k8sNamespace: namespace, + databaseName: finalDatabaseName, + status: 'CREATING', + lockedUntil: null, + storageSize: VERSIONS.STORAGE.DATABASE_SIZE, + cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu, + cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu, + memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory, + memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory, + }, + }) + + logger.info(`Database created: ${database.id} for project: ${project.id}`) + + return { success: true, data: database } +} + +/** + * Delete a database + * + * @param databaseId - Database ID + */ +export async function deleteDatabase(databaseId: string): Promise> { + const session = await auth() + if (!session) { + return { success: false, error: 'Unauthorized' } + } + + // Verify database exists and belongs to user + const database = await prisma.database.findUnique({ + where: { id: databaseId }, + include: { project: true }, + }) + + if (!database) { + return { success: false, error: 'Database not found' } + } + + if (database.project.userId !== session.user.id) { + return { success: false, error: 'Unauthorized' } + } + + // Update status to TERMINATING (reconciliation job will handle K8s deletion) + await prisma.database.update({ + where: { id: databaseId }, + data: { + status: 'TERMINATING', + lockedUntil: null, // Unlock for reconciliation job + }, + }) + + logger.info(`Database ${databaseId} marked for deletion`) + + return { success: true, data: undefined } +} diff --git a/lib/actions/project.ts b/lib/actions/project.ts index fb48bd0..85780ff 100644 --- a/lib/actions/project.ts +++ b/lib/actions/project.ts @@ -101,10 +101,9 @@ export async function createProject( const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username const fileBrowserPassword = generateRandomString(16) // 16 char random password - const databaseName = `${k8sProjectName}-${randomSuffix}` const sandboxName = `${k8sProjectName}-${randomSuffix}` - // Create project with database and sandbox in a transaction + // Create project with sandbox in a transaction const result = await prisma.$transaction( async (tx) => { // 1. Create Project with status CREATING @@ -117,25 +116,7 @@ export async function createProject( }, }) - // 2. Create Database record - lockedUntil is null so reconcile job can process immediately - const database = await tx.database.create({ - data: { - projectId: project.id, - name: databaseName, - k8sNamespace: namespace, - databaseName: databaseName, - status: 'CREATING', - lockedUntil: null, // Unlocked - ready for reconcile job to process - // Resource configuration from versions - storageSize: VERSIONS.STORAGE.DATABASE_SIZE, - cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu, - cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu, - memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory, - memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory, - }, - }) - - // 3. Create Sandbox record - lockedUntil is null so reconcile job can process immediately + // 2. Create Sandbox record - lockedUntil is null so reconcile job can process immediately const sandbox = await tx.sandbox.create({ data: { projectId: project.id, @@ -153,7 +134,7 @@ export async function createProject( }, }) - // 4. Create Environment record for ttyd access token + // 3. Create Environment record for ttyd access token const ttydEnv = await tx.environment.create({ data: { projectId: project.id, @@ -164,7 +145,7 @@ export async function createProject( }, }) - // 5. Create Environment records for filebrowser credentials + // 4. Create Environment records for filebrowser credentials const fileBrowserUsernameEnv = await tx.environment.create({ data: { projectId: project.id, @@ -187,7 +168,6 @@ export async function createProject( return { project, - database, sandbox, ttydEnv, fileBrowserUsernameEnv, @@ -200,7 +180,7 @@ export async function createProject( ) logger.info( - `Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}` + `Project created: ${result.project.id} with sandbox: ${result.sandbox.id}` ) return { success: true, data: result.project } diff --git a/lib/util/projectStatus.ts b/lib/util/projectStatus.ts index 511870c..3df7ca6 100644 --- a/lib/util/projectStatus.ts +++ b/lib/util/projectStatus.ts @@ -14,13 +14,17 @@ import type { ProjectStatus, ResourceStatus } from '@prisma/client' * - TERMINATING: All resources ∈ {TERMINATED, TERMINATING} * 6. PARTIAL: Inconsistent mixed states * + * Note: Empty array returns TERMINATED (used when all resources are deleted) + * * @param resourceStatuses - Array of resource statuses * @returns Aggregated project status */ export function aggregateProjectStatus(resourceStatuses: ResourceStatus[]): ProjectStatus { - // Handle empty case + // Handle empty case - this happens when all resources (sandbox + database) are deleted + // In the new design, projects without databases are valid, so this only triggers + // when the project truly has no resources left if (resourceStatuses.length === 0) { - return 'TERMINATED' // Default to TERMINATED for projects with no resources + return 'TERMINATED' } // Rule 1: Check for ERROR - highest priority diff --git a/lib/util/type-guards.ts b/lib/util/type-guards.ts new file mode 100644 index 0000000..af54599 --- /dev/null +++ b/lib/util/type-guards.ts @@ -0,0 +1,37 @@ +import type { Database, Project, Sandbox } from '@prisma/client' + +/** + * Type guard utilities for project resources + */ + +/** + * Project with at least one database + */ +export type ProjectWithDatabase = Project & { + databases: [Database, ...Database[]] +} + +/** + * Check if a project has at least one database + */ +export function hasDatabase( + project: Project & { databases: Database[] } +): project is ProjectWithDatabase { + return project.databases.length > 0 +} + +/** + * Project with at least one sandbox + */ +export type ProjectWithSandbox = Project & { + sandboxes: [Sandbox, ...Sandbox[]] +} + +/** + * Check if a project has at least one sandbox + */ +export function hasSandbox( + project: Project & { sandboxes: Sandbox[] } +): project is ProjectWithSandbox { + return project.sandboxes.length > 0 +}