Skip to content

@agentaily/db — 数据层设计(per-project 真表 + 派生数据 API)

收敛自一轮产品设计讨论。本文件是真相源;实现以它为准,漂移就回来改这里。

1. 两层模型(关键:谁设计什么)

是什么谁产出在哪
① 元数据 / 静态层 = 插件{ 内容 schema(zod) + 数据库 schema }我们(开发期写,可借 LLM) —— 用户不碰verticals/<x>/
② 业务 / 实例层用户选插件 → 聊 → LLM 生成 { 内容实例(文档) + index.html };平台按①的 DB schema 备好真表;index.html 调派生 API用户运行时(经 LLM)D1 + 运行时

一句话:插件是静态代码(我们写),实例是运行时数据(用户/LLM 造)。 这把「渲染器是数据」推广成「内容实例 + 页面都是数据,但插件骨架是代码」。

2. 一个项目 = 两个投影(2 schema)

            ┌─ ① 内容 schema(zod, 插件固定) → 文档(fields[]) → index.html 渲染   【UI 投影】
项目 ─ 文档 ─┤
            └─ ② 数据 schema(从①派生 recordSchema, JSON Schema)
                  │ 编译器

               D1 真表(真列) ──自动派生──→ 数据 API(GraphQL 精神) ── index.html 调
  • ② 不手写,是①的派生(recordSchema(doc),已存在,vertical 声明)。
  • 编译器吃②的 JSON Schema(故 vertical-agnostic),吐真表 + 迁移。

3. 编译器:JSON Schema → 真表(src/compiler.ts,纯函数)

3.1 类型映射

form 类型recordSchema(JSON Schema)SQLite 列存的值
text / long / email / date / choicestring(可带 format/enum)TEXT字符串
number / ratingnumberREAL数字
boolbooleanINTEGER0/1
multiarray(enum)TEXT(JSON)JSON 数组(JSON1 可查)

核心原则:DDL 只存「类型/结构」,软约束(required / enum / range / format)留给 checkRecord 列一律 NULLABLE。⇒ 改名、改必填、改选项、改范围 = 零迁移;只有 加/删/不兼容改类型 才碰表。

3.2 表结构

  • 表名:data_<projectId>(非法字符 → _)。
  • 系统列:_id TEXT PK · _created_at INTEGER NOT NULL · _schema_version INTEGER
  • 字段列:c_<key>(key 净化)。v1 列身份 = key(改 key = remove+add);稳定 id 注册表(免迁移改名)是后续增强,见 §6。

3.3 改字段迁移(diffMigration(liveColumns, schema) → ops)

内省现表(PRAGMA table_info)与期望列 diff:

变更opSQL数据
加字段addALTER ADD COLUMN(nullable)老行该列 NULL
删字段archiveALTER RENAME COLUMN c_x → _archived_c_x软删,保数据
不兼容改类型(sqlType 变)replacerename 旧列归档 + ADD COLUMN 新类型(同名)老数据留归档列
改名/必填/选项/范围 / 兼容类型(text↔long)
  • SQLite/D1:ADD/RENAME/DROP COLUMN 支持;改列类型不支持 → 用「归档旧列 + 加新列」绕开,永不重建、永不原地丢数据。
  • 触发:已发布/已收数据的表才迁移;未发布草稿改字段 → 直接重算建表语句。
  • 原子性:一次迁移 ops 串在一个 D1 batch。表级 dataSchemaVersion 记 project,行级 _schema_version 记每行。

4. 派生数据 API(GraphQL 精神,不上 graphql-yoga)

从真表自动派生一套自描述、类型化的数据接口(查 + 提交)。✅ HTTP 面已落地 /api/data/:id:

  • GET = introspection:返回 recordSchema(自描述契约:有哪些字段、什么类型)。
  • POST = 查询:{ where?(等值过滤), select?(投影), limit?, orderBy? } → 类型化记录,编译成对真列的 SQL(非 blob 扫描)。e2e 验证:where{age:30} 真列过滤、select 投影、limit 都对。
  • :沿用 POST /api/forms/:id/submissions(checkRecord 闸门 → 插真表)。

发布的 index.html 桥已落地:公开运行时 /f/:id(functions/f/[id].ts + assemblePublicRuntime)把渲染器 + 注入文档 + window.agentaily(submit/query)桥组装成页面;真浏览器 e2e 验证「渲染字段 → 填表 → 提交 → checkRecord → 真表 → 可回查」闭环。⏳ 仍待接:LLM 数据工具(把 /api/data/:id 包成 agent 工具,入参形状从 recordSchema 派生 + 约束解码)。真要 GraphQL 生态/introspection 工具链,再在这层套一个 graphql-yoga;精神已具备,引擎可后插。

5. 接线(✅ 已落地)

submission 落 per-project 真表需要字段映射(payload[key] → c_key),映射来自 vertical 的 recordSchema。实际做法(比原计划更省 —— 没动 backend seam):

  • 持久化 recordSchema:projects.record_schema(JSON)列。写入方(知道 vertical 的 PUT 函数)用 backend 的 recordSchemaFor(type, document) 算出并存;D1 存储层据此编译真表,不 import 任何 vertical
  • SubmissionStore seam 不变:d1Stores.submissions.add/list 内部读 project.record_schema → 编译器 → 真表 insert/list。backend 逻辑 + in-memory 替身 + 测试全不动。
  • 建表/迁移时机:PUT /api/projects/:idensureDataTable(建表/迁移);add 兜底 CREATE IF NOT EXISTS(故首次提交也能自建表)。
  • submissions 共享表废弃(留着不删,避免动现有迁移)。

6. 决策台账

#决策选择
D1列身份v1 = 净化 key;稳定 id 注册表(免迁移改名)= 后续(放平台侧 key→colId 表,不进 LLM 写的文档,因为 LLM 重写文档不保留 id)
D2multiJSON 列(JSON1 可查)
D3删字段默认软删(归档列);硬删 DROP COLUMN 需显式确认
D4不兼容改类型归档旧列 + 加新列(不丢数据)
D5海量表单=海量表先单库,按 owner 分库 seam 后埋
D6数据 schema 位置verticals/<x>/schema(Drizzle 声明,Prisma 精神);form 的 = 系统列骨架 + 字段编译规则
D7API 层真表上派生类型化 API(GraphQL 精神),非 graphql-yoga
D8submit主走派生 API mutation,REST 留 alias

7. Gap 与顺序

①编译器 ✅ → ②每项目真表 ✅ → ③派生数据 API ✅ → ④index.html 提交桥 + 公开运行 /f/:id ✅ → ⑤渲染器持久化(projects.renderer) ✅ → ⑥LLM 数据工具⑦SPA 发布 UI

已落地:编译器 + 每项目真表 + 派生数据 API + 公开运行时 /f/:id(渲染器持久化 + window.agentaily 提交/查询桥,真浏览器验证闭环)。⏳ 剩:LLM 数据工具(agent 侧)、SPA 发布 UI(把渲染器存上去 + 给 /f/:id 链接)。