@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 / choice | string(可带 format/enum) | TEXT | 字符串 |
| number / rating | number | REAL | 数字 |
| bool | boolean | INTEGER | 0/1 |
| multi | array(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:
| 变更 | op | SQL | 数据 |
|---|---|---|---|
| 加字段 | add | ALTER ADD COLUMN(nullable) | 老行该列 NULL |
| 删字段 | archive | ALTER RENAME COLUMN c_x → _archived_c_x | 软删,保数据 |
| 不兼容改类型(sqlType 变) | replace | rename 旧列归档 + 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。 SubmissionStoreseam 不变:d1Stores.submissions.add/list内部读project.record_schema→ 编译器 → 真表 insert/list。backend 逻辑 + in-memory 替身 + 测试全不动。- 建表/迁移时机:
PUT /api/projects/:id时ensureDataTable(建表/迁移);add兜底CREATE IF NOT EXISTS(故首次提交也能自建表)。 submissions共享表废弃(留着不删,避免动现有迁移)。
6. 决策台账
| # | 决策 | 选择 |
|---|---|---|
| D1 | 列身份 | v1 = 净化 key;稳定 id 注册表(免迁移改名)= 后续(放平台侧 key→colId 表,不进 LLM 写的文档,因为 LLM 重写文档不保留 id) |
| D2 | multi | JSON 列(JSON1 可查) |
| D3 | 删字段 | 默认软删(归档列);硬删 DROP COLUMN 需显式确认 |
| D4 | 不兼容改类型 | 归档旧列 + 加新列(不丢数据) |
| D5 | 海量表单=海量表 | 先单库,按 owner 分库 seam 后埋 |
| D6 | 数据 schema 位置 | verticals/<x>/schema(Drizzle 声明,Prisma 精神);form 的 = 系统列骨架 + 字段编译规则 |
| D7 | API 层 | 真表上派生类型化 API(GraphQL 精神),非 graphql-yoga |
| D8 | submit | 主走派生 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链接)。