Skip to content

Commit 76d8f6c

Browse files
Che-Zhuclaude
andauthored
feat: make database optional for projects (#140)
- 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 <noreply@anthropic.com>
1 parent 0044c0a commit 76d8f6c

9 files changed

Lines changed: 279 additions & 62 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1010

1111
**Key Features**:
1212
- **Flexible Project Creation**: Import from GitHub repositories or create new projects from scratch
13+
- **Optional Database**: Add PostgreSQL database on-demand when needed
1314
- **AI Agent Ecosystem**: AI agents handle development, testing, deployment, and infrastructure management
1415
- **Automated Operations**: Deployment, scaling, and infrastructure management happen automatically in the background
15-
- **Full-Stack Development**: Complete environment with database, terminal, and file management
16+
- **Full-Stack Development**: Complete environment with optional database, terminal, and file management
1617
- **Zero Infrastructure Knowledge Required**: Users don't need to understand Kubernetes, networking, or DevOps
1718

1819
**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
158159
- `lib/k8s/k8s-service-helper.ts` - User-specific K8s service
159160
- `lib/events/sandbox/sandboxListener.ts` - Sandbox lifecycle handlers
160161
- `lib/jobs/sandbox/sandboxReconcile.ts` - Sandbox reconciliation job
162+
- `lib/events/database/databaseListener.ts` - Database lifecycle handlers
163+
- `lib/jobs/database/databaseReconcile.ts` - Database reconciliation job
164+
- `lib/actions/project.ts` - Project creation (creates Sandbox only)
165+
- `lib/actions/database.ts` - Database creation/deletion (on-demand)
161166
- `prisma/schema.prisma` - Database schema
162167
- `instrumentation.ts` - Application startup
163168

@@ -181,10 +186,11 @@ npx prisma db push # Push schema to database
181186

182187
## Important Notes
183188

189+
- **Project Resources**: Each project includes a Sandbox (required) and can optionally have a Database (PostgreSQL). Database can be added on-demand after project creation.
184190
- **Reconciliation Delay**: Status updates may take up to 3 seconds
185191
- **User-Specific Namespaces**: Each user operates in their own K8s namespace
186192
- **Frontend Polling**: Client components poll every 3 seconds for status updates
187-
- **Database Wait Time**: PostgreSQL cluster takes 2-3 minutes to reach "Running"
193+
- **Database Wait Time**: PostgreSQL cluster takes 2-3 minutes to reach "Running" (when added)
188194
- **Idempotent Operations**: All K8s methods can be called multiple times safely
189195
- **Lock Duration**: Optimistic locks held for 30 seconds
190196
- **Deployment Domain**: Main app listens on `0.0.0.0:3000` (not localhost) for Sealos
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use client'
2+
3+
import { useTransition } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { toast } from 'sonner'
6+
7+
import { Button } from '@/components/ui/button'
8+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
9+
import { createDatabase } from '@/lib/actions/database'
10+
11+
interface AddDatabaseCardProps {
12+
projectId: string
13+
projectName: string
14+
}
15+
16+
export function AddDatabaseCard({ projectId }: AddDatabaseCardProps) {
17+
const router = useRouter()
18+
const [isPending, startTransition] = useTransition()
19+
20+
const handleCreateDatabase = () => {
21+
startTransition(async () => {
22+
const result = await createDatabase(projectId)
23+
24+
if (!result.success) {
25+
toast.error(result.error || 'Failed to create database')
26+
return
27+
}
28+
29+
toast.success('Database is being created...')
30+
router.refresh()
31+
})
32+
}
33+
34+
return (
35+
<Card>
36+
<CardHeader>
37+
<CardTitle>No Database</CardTitle>
38+
<CardDescription>
39+
This project doesn&apos;t have a database yet. Add a PostgreSQL database to get started.
40+
</CardDescription>
41+
</CardHeader>
42+
<CardContent>
43+
<Button
44+
onClick={handleCreateDatabase}
45+
disabled={isPending}
46+
className="w-full"
47+
>
48+
{isPending ? 'Creating Database...' : 'Add PostgreSQL Database'}
49+
</Button>
50+
<p className="text-xs text-muted-foreground mt-4">
51+
A PostgreSQL cluster will be created with 1Gi storage, 100m CPU, and 128Mi memory.
52+
</p>
53+
</CardContent>
54+
</Card>
55+
)
56+
}

app/(dashboard)/projects/[id]/database/page.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getProject } from '@/lib/data/project';
66

77
import { SettingsLayout } from '../_components/settings-layout';
88

9+
import { AddDatabaseCard } from './_components/add-database-card';
910
import { ConnectionString } from './_components/connection-string';
1011
import { FeatureCards } from './_components/feature-cards';
1112
import { ReadOnlyField } from './_components/read-only-field';
@@ -28,7 +29,17 @@ export default async function DatabasePage({ params }: { params: Promise<{ id: s
2829
if (!project) notFound();
2930

3031
const database = project.databases[0];
31-
const connectionString = database?.connectionUrl || '';
32+
33+
// If no database exists, show "Add Database" card
34+
if (!database) {
35+
return (
36+
<SettingsLayout title="Database Information" description="Add a PostgreSQL database to your project">
37+
<AddDatabaseCard projectId={project.id} projectName={project.name} />
38+
</SettingsLayout>
39+
);
40+
}
41+
42+
const connectionString = database.connectionUrl || '';
3243
const connectionInfo = parseConnectionUrl(connectionString) || {
3344
host: '', port: '', database: '', username: '', password: ''
3445
};
@@ -57,9 +68,9 @@ export default async function DatabasePage({ params }: { params: Promise<{ id: s
5768
</>
5869
) : (
5970
<div className="py-12 text-center">
60-
<p className="text-sm text-muted-foreground">No database configured</p>
71+
<p className="text-sm text-muted-foreground">Database is being created...</p>
6172
<p className="text-xs text-muted-foreground mt-1">
62-
Database will be automatically provisioned when sandbox is created
73+
Connection details will appear once the database is ready
6374
</p>
6475
</div>
6576
)}

app/api/projects/route.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,9 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
196196
const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth
197197
const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username
198198
const fileBrowserPassword = generateRandomString(16) // 16 char random password
199-
const databaseName = `${k8sProjectName}-${randomSuffix}`
200199
const sandboxName = `${k8sProjectName}-${randomSuffix}`
201200

202-
// Create project with database and sandbox in a transaction
201+
// Create project with sandbox in a transaction
203202
const result = await prisma.$transaction(async (tx) => {
204203
// 1. Create Project with status CREATING
205204
const project = await tx.project.create({
@@ -211,25 +210,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
211210
},
212211
})
213212

214-
// 2. Create Database record - lockedUntil is null so reconcile job can process immediately
215-
const database = await tx.database.create({
216-
data: {
217-
projectId: project.id,
218-
name: databaseName,
219-
k8sNamespace: namespace,
220-
databaseName: databaseName,
221-
status: 'CREATING',
222-
lockedUntil: null, // Unlocked - ready for reconcile job to process
223-
// Resource configuration from versions
224-
storageSize: VERSIONS.STORAGE.DATABASE_SIZE,
225-
cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu,
226-
cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu,
227-
memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory,
228-
memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory,
229-
},
230-
})
231-
232-
// 3. Create Sandbox record - lockedUntil is null so reconcile job can process immediately
213+
// 2. Create Sandbox record - lockedUntil is null so reconcile job can process immediately
233214
const sandbox = await tx.sandbox.create({
234215
data: {
235216
projectId: project.id,
@@ -247,7 +228,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
247228
},
248229
})
249230

250-
// 4. Create Environment record for ttyd access token
231+
// 3. Create Environment record for ttyd access token
251232
const ttydEnv = await tx.environment.create({
252233
data: {
253234
projectId: project.id,
@@ -258,7 +239,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
258239
},
259240
})
260241

261-
// 5. Create Environment records for filebrowser credentials
242+
// 4. Create Environment records for filebrowser credentials
262243
const fileBrowserUsernameEnv = await tx.environment.create({
263244
data: {
264245
projectId: project.id,
@@ -281,7 +262,6 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
281262

282263
return {
283264
project,
284-
database,
285265
sandbox,
286266
ttydEnv,
287267
fileBrowserUsernameEnv,
@@ -292,7 +272,7 @@ export const POST = withAuth<PostProjectResponse>(async (req, _context, session)
292272
})
293273

294274
logger.info(
295-
`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}`
275+
`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}`
296276
)
297277

298278
return NextResponse.json(result.project)

components/layout/status-bar.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ export function StatusBar({ projectId }: StatusBarProps) {
1414
const { data: project } = useProject(projectId);
1515

1616
const database = project?.databases?.[0];
17-
const dbStatus = database?.status || 'CREATING';
1817
const sandbox = project?.sandboxes?.[0];
1918
const sbStatus = sandbox?.status || 'CREATING';
2019

@@ -35,10 +34,17 @@ export function StatusBar({ projectId }: StatusBarProps) {
3534
<span>Sandbox: {sbStatus}</span>
3635
</div>
3736
<div className="w-px h-3 bg-card-foreground/60 mx-1" />
38-
<div className="flex items-center gap-1.5 px-1 rounded cursor-pointer transition-colors">
39-
<div className={`w-2 h-2 rounded-full shadow-[0_0_1px_0.5px_currentColor] ${getStatusBgColor(dbStatus)}`} />
40-
<span>Database: {dbStatus}</span>
41-
</div>
37+
{database ? (
38+
<div className="flex items-center gap-1.5 px-1 rounded cursor-pointer transition-colors">
39+
<div className={`w-2 h-2 rounded-full shadow-[0_0_1px_0.5px_currentColor] ${getStatusBgColor(database.status)}`} />
40+
<span>Database: {database.status}</span>
41+
</div>
42+
) : (
43+
<div className="flex items-center gap-1.5 px-1 rounded cursor-pointer transition-colors opacity-60">
44+
<div className="w-2 h-2 rounded-full bg-muted-foreground/40" />
45+
<span>Database: Not Configured</span>
46+
</div>
47+
)}
4248
</div>
4349
</div>
4450
);

lib/actions/database.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use server'
2+
3+
/**
4+
* Database Server Actions
5+
*
6+
* Server Actions for database operations. Frontend components call these
7+
* to create and delete databases on-demand.
8+
*/
9+
10+
import type { Database } from '@prisma/client'
11+
12+
import { auth } from '@/lib/auth'
13+
import { prisma } from '@/lib/db'
14+
import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
15+
import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils'
16+
import { VERSIONS } from '@/lib/k8s/versions'
17+
import { logger as baseLogger } from '@/lib/logger'
18+
19+
import type { ActionResult } from './types'
20+
21+
const logger = baseLogger.child({ module: 'actions/database' })
22+
23+
/**
24+
* Create a database for an existing project
25+
*
26+
* @param projectId - Project ID
27+
* @param databaseName - Optional custom database name (auto-generated if not provided)
28+
*/
29+
export async function createDatabase(
30+
projectId: string,
31+
databaseName?: string
32+
): Promise<ActionResult<Database>> {
33+
const session = await auth()
34+
if (!session) {
35+
return { success: false, error: 'Unauthorized' }
36+
}
37+
38+
// Verify project exists and belongs to user
39+
const project = await prisma.project.findUnique({
40+
where: { id: projectId },
41+
include: { databases: true },
42+
})
43+
44+
if (!project) {
45+
return { success: false, error: 'Project not found' }
46+
}
47+
48+
if (project.userId !== session.user.id) {
49+
return { success: false, error: 'Unauthorized' }
50+
}
51+
52+
// Check if database already exists
53+
if (project.databases.length > 0) {
54+
return { success: false, error: 'Database already exists for this project' }
55+
}
56+
57+
// Get K8s service for user
58+
let k8sService
59+
let namespace
60+
try {
61+
k8sService = await getK8sServiceForUser(session.user.id)
62+
namespace = k8sService.getDefaultNamespace()
63+
} catch (error) {
64+
if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) {
65+
return {
66+
success: false,
67+
error: 'Please configure your kubeconfig before creating a database',
68+
}
69+
}
70+
throw error
71+
}
72+
73+
// Generate database name if not provided
74+
const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name)
75+
const randomSuffix = KubernetesUtils.generateRandomString()
76+
const finalDatabaseName = databaseName || `${k8sProjectName}-db-${randomSuffix}`
77+
78+
// Create Database record
79+
const database = await prisma.database.create({
80+
data: {
81+
projectId: project.id,
82+
name: finalDatabaseName,
83+
k8sNamespace: namespace,
84+
databaseName: finalDatabaseName,
85+
status: 'CREATING',
86+
lockedUntil: null,
87+
storageSize: VERSIONS.STORAGE.DATABASE_SIZE,
88+
cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu,
89+
cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu,
90+
memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory,
91+
memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory,
92+
},
93+
})
94+
95+
logger.info(`Database created: ${database.id} for project: ${project.id}`)
96+
97+
return { success: true, data: database }
98+
}
99+
100+
/**
101+
* Delete a database
102+
*
103+
* @param databaseId - Database ID
104+
*/
105+
export async function deleteDatabase(databaseId: string): Promise<ActionResult<void>> {
106+
const session = await auth()
107+
if (!session) {
108+
return { success: false, error: 'Unauthorized' }
109+
}
110+
111+
// Verify database exists and belongs to user
112+
const database = await prisma.database.findUnique({
113+
where: { id: databaseId },
114+
include: { project: true },
115+
})
116+
117+
if (!database) {
118+
return { success: false, error: 'Database not found' }
119+
}
120+
121+
if (database.project.userId !== session.user.id) {
122+
return { success: false, error: 'Unauthorized' }
123+
}
124+
125+
// Update status to TERMINATING (reconciliation job will handle K8s deletion)
126+
await prisma.database.update({
127+
where: { id: databaseId },
128+
data: {
129+
status: 'TERMINATING',
130+
lockedUntil: null, // Unlock for reconciliation job
131+
},
132+
})
133+
134+
logger.info(`Database ${databaseId} marked for deletion`)
135+
136+
return { success: true, data: undefined }
137+
}

0 commit comments

Comments
 (0)