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
+}