// docs / storage
存储与持久化
项目数据分两层存储:Supabase PostgreSQL 保存元数据和修改历史, Supabase Storage 保存生成的源文件。本地 sites/ 目录是工作区, Storage 是持久化备份。
双层存储
┌─────────────────────────────────────────────────────┐
│ Supabase PostgreSQL │
│ projects 表:元数据、状态、blueprint、修改历史 │
│ model_configs 表:用户自定义模型配置 │
└──────────────────────┬──────────────────────────────┘
│ 文件路径引用
┌──────────────────────▼──────────────────────────────┐
│ Supabase Storage │
│ bucket: project-files — 源码树快照(project-files/{projectId}/…) │
│ bucket: site-previews — 静态导出预览(site-previews/p/{projectId}/…) │
└──────────────────────┬──────────────────────────────┘
│ 恢复到本地
┌──────────────────────▼──────────────────────────────┐
│ 本地 sites/ 目录 │
│ sites/{projectId}/ │
│ AI 读写、预览构建的工作区 │
└─────────────────────────────────────────────────────┘本地 sites/ 丢失时,从 project-files 桶恢复源码; iframe 静态预览由 site-previews 桶 + 应用内代理共同提供(见预览文档)。
Supabase DB
projects 表保存项目的完整生命周期数据:
-- projects 表核心字段 id TEXT PRIMARY KEY -- 时间戳_slug 格式 user_id UUID -- 所有者(auth.users) owner_username TEXT -- 创建时写入,用于全员列表展示 / 分组 folder_id UUID -- 可选,单层文件夹 project_folders.id name TEXT -- 用户可编辑的项目名 status TEXT -- generating | ready | failed blueprint JSONB -- ProjectBlueprint(含 PlannedProjectBlueprint) buildSteps JSONB[] -- 每个流水线步骤的状态和产物 modificationHistory JSONB[] -- 每次修改的完整记录 verificationStatus TEXT -- passed | failed | unverified sandbox_id TEXT -- E2B 沙箱 ID(用于重连) created_at TIMESTAMPTZ
modificationHistory 结构
每条修改记录包含完整的 Agent 执行轨迹:
ModificationRecord {
instruction: string // 用户指令原文
modifiedAt: string // ISO 时间戳
touchedFiles: string[] // 修改的文件列表
plan: {
analysis: string // Agent 的分析摘要
changes: Change[] // 每个文件的变更说明
}
diffs: FileDiff[] // 完整的 unified diff
toolCalls: ToolCall[] // Agent 调用的工具序列(截断到 500 字符)
thinking: string[] // Agent 的思考过程(截断到 500 字符)
image: string | null // 用户上传的截图(base64,最多 200KB)
}toolCalls 和 thinking 字段用于对话记忆。 下次修改时,Agent 可以看到"上次改了哪些文件、用了哪些工具", 从而理解用户的跟进指令(如"再把那个按钮改大一点")。Supabase Storage
所有生成的源文件存储在 project-files bucket 中, 路径格式为 {projectId}/{relativePath}:
project-files/
└── 1735123456789_my-saas/
├── app/
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── sections/
│ ├── home_HeroSection.tsx
│ ├── home_FeaturesSection.tsx
│ └── layout_NavSection.tsx
├── design-system.md
└── package.json跳过上传的目录:node_modules/、.next/、.git/。 这些可以在恢复后重新生成,不需要持久化。
上传策略
生成完成后,uploadGeneratedFiles 并行上传所有生成文件:
// 并行上传,失败不阻塞(Promise.allSettled)
export async function uploadGeneratedFiles(
projectId: string,
generatedFiles: string[]
): Promise<void> {
await Promise.allSettled(
generatedFiles.map((f) => uploadProjectFile(projectId, f))
);
}
// 单文件上传:读取本地 → upsert 到 Storage
export async function uploadProjectFile(
projectId: string,
relativeFilePath: string
): Promise<void> {
const localPath = path.join(getSiteRoot(projectId), relativeFilePath);
const content = await fs.readFile(localPath);
await supabase.storage
.from("project-files")
.upload(`${projectId}/${relativeFilePath}`, content, { upsert: true });
}使用
Promise.allSettled 而非 Promise.all, 单个文件上传失败不会中断整个批次。失败的文件会在下次修改时重新上传。文件恢复
当本地 sites/{projectId}/ 目录不存在时(服务器重启、新实例),restoreProjectFiles 从 Storage 恢复所有文件:
export async function restoreProjectFiles(projectId: string): Promise<string[]> {
// 1. 递归列出 Storage 中的所有文件
const allPaths = await listAllFiles(projectId);
if (allPaths.length === 0) return [];
// 2. 并行下载,自动创建目录
await Promise.all(
allPaths.map(async (storagePath) => {
const { data } = await supabase.storage
.from("project-files")
.download(storagePath);
const relativePath = storagePath.slice(projectId.length + 1);
const localPath = path.join(getSiteRoot(projectId), relativePath);
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, Buffer.from(await data.arrayBuffer()));
})
);
return restored;
}递归列目录
Supabase Storage 的 list() API 不支持递归, 需要手动实现深度优先遍历:
async function listAllFiles(prefix: string): Promise<string[]> {
const result: string[] = [];
async function walk(currentPrefix: string) {
const { data } = await supabase.storage
.from(BUCKET)
.list(currentPrefix, { limit: 1000 });
for (const item of data) {
const fullPath = `${currentPrefix}/${item.name}`;
if (item.id) result.push(fullPath); // 文件(有 id)
else await walk(fullPath); // 目录(无 id)→ 递归
}
}
await walk(prefix);
return result;
}生命周期
项目创建写入 projects 表,status=generating
生成完成上传所有生成文件到 Storage,status=ready
修改完成上传修改的文件,追加 modificationHistory
预览启动从 Storage 恢复文件(如本地不存在),写入 sandbox_id
项目删除删除 Storage 中所有文件,删除 projects 表记录,清理本地目录
删除操作分批执行(每批最多 1000 个路径),符合 Supabase Storage API 的限制。 Storage 清理和 DB 记录删除是独立操作,Storage 失败不会阻止 DB 删除。