OX
OPEN-OX

// 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)
}
toolCallsthinking 字段用于对话记忆。 下次修改时,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 删除。