← 全部文章
应用搭建 开发者 · · 作者 ObjectStack Team

视图、仪表盘和应用是本体的投影:这对审阅意味着什么

给 ObjectStack 自带示例加一个字段并逐面实测:数据表、REST、MCP 与控制台表单自己跟上,钉死字段名的视图、数据集和翻译没动。命名测试告诉审阅者哪个是哪个。

视图、仪表盘和应用是本体的投影:这对审阅意味着什么 视图、仪表盘和应用是本体的投影:这对审阅意味着什么
  • 本体
  • 可执行业务本体
  • 可审阅的 diff
  • 元数据审阅

下面就是整份 diff。五行,一个字段,加在 ObjectStack 仓库自带的 Todo 示例的 todo_task 对象上:

     actual_hours: Field.number({
       label: 'Actual Hours',
       scale: 2,
       min: 0,
     }),
+
+    budget: Field.currency({
+      label: 'Budget',
+      min: 0,
+    }),

在 ObjectStack 运行时上启动之后,这五行改动了数据库表、REST 的读与写、元数据接口、MCP 的 describe_object 与 query_records 应答,以及控制台的新建表单——七个面,没有一个被编辑过。它们没有改动三个列表视图、仪表盘的数据集和中文翻译,因为这三样东西都逐个写明了字段名。前一组是从定义推导出来的,后一组是钉在定义上的。命名测试在你打开任何一个投影之前就能把它归到两组之一:它写明了这个字段,你就审它;它只写明了对象,运行时已经替你审过了。本文余下的部分,是这句话背后的测量——在 ObjectStack 18.3.0(运行时 npm 上的 latest 版本)上、示例源码取自该版本的发布提交 83d7e4ae——以及它对读一份 diff 这件事改变了什么。

投影的定义住在哪里

投影是什么,已经有三处定义,本文不重复;《本体即软件》这篇长文也划了同一条线,那是它每次用这个词都要声明的两条边界中的第一条。定义那篇点名了四种投影,并给出区分核心与投影的删除测试。本体页用一句话说明规则:改动核心,投影跟着变;删掉一个投影,它所展示的业务不变。术语表条目补上了对审阅者的推论:只碰投影的 diff 改的是呈现,碰到核心的 diff 改的是权限与事实。

它们都没说的是,投影是怎么跟着变的。「跟着变」底下藏着两种不同的机制,审阅者得知道眼前的是哪一种。下面这次运行回答的就是这个问题。

这次运行:一个字段,十一个读数

对象是自带的 Todo 示例(examples/app-todo),从仓库提交 83d7e4ae(@objectstack/[email protected] 的发布标签)复制出来,用已发布的 18.3.0 包以 os dev 启动。数据库是一个本地 SQLite 文件;API key 用种子化的开发管理员通过 REST 签发。每个读数取两次:一次在提交原样的定义上,一次在上面那五行 diff 之后、带 --compile 重启之后。「之前」和「之后」就是这两次的答案。

面怎么读的之前之后跟着变?
数据库表 todo_task对 SQLite 文件执行 PRAGMA table_info28 列29 列;budget float 加到了已有的表上推导
REST 读GET /api/v1/data/todo_task每条记录 28 个键29 个键推导
REST 写POST /api/v1/data/todo_task,带 budget: 1200400——Unknown field 'budget' on object 'todo_task',错误码 INVALID_FIELD201,记录已创建推导
元数据接口GET /api/v1/meta/object/todo_task28 个字段29 个;{ label: 'Budget', type: 'currency', min: 0, … }推导
MCP describe_object对 /api/v1/mcp 发 tools/call28 个字段29 个推导
MCP query_recordswhere: { budget: { $gte: 1000 } }, fields: ['subject', 'budget']—[["Renew the domain", 1200]]推导
控制台新建表单/_console/apps/com.example.todo/todo_task/new16 个输入项17 个;Budget 按声明顺序出现在 Actual Hours 和 Notes 之间推导
控制台记录详情新记录的页面—Overview 标签页显示 BUDGET 1,200.00推导
控制台列表视图 All TasksGET /api/v1/meta/view → todo_task.default6 列6 列,没有变化钉死
翻译,zh-CN带 Accept-Language: zh-CN 的 GET /api/v1/meta/object/todo_tasksubject → 主题budget → Budget(回落到英文)钉死
仪表盘数据集 task_metrics数据集定义本身6 个维度、3 个度量,每个都点名一个字段没有变化;在某个度量点名 budget 之前,没有任何图表能画它钉死

另有两个读数没有变化,而它们也不是投影:OpenAPI 文档仍然为 todo_task 列出 17 条路径,没有逐字段的 schema;MCP 的 tools/list 仍然应答同样的 12 个工具(list_objects、describe_object、validate_expression、query_records、aggregate_records、get_record、create_record、update_record、delete_record、list_actions、run_action、resume_run)。两者描述的都是对象,不是字段;字段出现在它们的应答里,而不是它们的形状里。

命名测试:中间是那份五行 diff;左边是只写明对象、未经编辑就跟上了定义的面,附前后读数;右边是写明了字段名、原样未动的投影;底部是构建门禁自己打出的警告——新字段没有任何消费者

还有一个读数是白送的。os dev --compile 在写出工件之前会跑一遍编写期规则,下面是门禁对这个新字段打印的原话:

⚠ object "todo_task" · field "budget": declared, but nothing in this stack displays or reads it (inert)
  fix: add it to a view column or a form section, or remove the declaration
  rule: field-no-consumers  at objects[0].fields.budget — `os explain field-no-consumers` for what counts as a consumer

os explain field-no-consumers 把「消费者」定义为「a view column, an inline grid column, a form section, a page binding, a flow node, a dataset or cube member, a dashboard widget, a formula, a validation, a hook or an action that names it」(18.3.0 的原文接着还把声明的字段分组、以及按某字段匹配的种子数据或导入映射算进去),并把它和「载体」区分开——载体「names a field without reading it」,例如「a translation label, a seed value, an import-mapping target, a permission grant」。门禁的这份清单,正是上表的右半栏。这是运行时在启动之前就告诉你:哪些投影是钉在字段上的,而其中还没有任何一个接住这个新字段。

命名测试

为什么七个面跟着变、四个面原地不动?不是因为有的更重要。是因为它们各自写明了什么。

数据表、REST 路由、元数据接口和 MCP 工具都是从对象生成的。SQL 驱动的结构同步遍历 obj.fields,为每一项建一列;数据路由按对象注册,读对象当下有的全部字段;describe_object 把 Object.entries(def.fields) 映射成它的应答;控制台发现 todo_task 没有声明表单视图,就按对象自己的字段排版。这些地方没有一处持有一份字段名清单。它们持有的是对象名,每次调用时向定义要其余的一切——所以字段不经编辑就到达了它们全部,而且是按声明顺序到达的。

列表视图、数据集和翻译包同样是编写出来的元数据,而且每一个都逐字写明字段名:columns: [{ field: 'subject' }, { field: 'status' }, …]、measures: [{ name: 'est_hours', field: 'estimated_hours', … }]、fields: { subject: { label: '主题' }, … }。写明了字段的投影是在对这个字段做一个选择——这一列、这个标签、这个聚合——运行时会原封不动地兑现这个选择。你没要的,它不会替你加。

于是,把测试说给审阅者听:

命名测试。 对这份 diff 可能波及的每一个投影,问一句:它写明的是这个字段,还是只有对象?只写明对象的,是推导出来的:你正在读的 diff 就是全部改动,运行时已经把它传播到位。写明了字段的,是钉死的:它没有动,该不该动,由你判断。

它和定义那篇的删除测试是配套的。删除测试把一份定义归为核心或投影;命名测试把一次核心改动能波及的投影归为推导或钉死。两条合在一起,审阅者光凭 diff 就知道运行时做了什么、还有什么在等一个决定。

这对读 diff 改变了什么

实际后果是一套工作顺序,而且比多数团队现在遵循的那套更短。

第一,定义那几行,就是对所有推导面的审阅。 没有要陪着读的建表迁移,没有序列化器,没有控制器,没有工具清单。在上面的运行里,budget 列存在是因为字段存在;REST 写入接受 budget 是因为字段存在;Agent 的 describe_object 显示它是因为字段存在。字段对,它们就对;字段错,它们就以和字段一模一样的方式错。审五行审掉了七个面——这正是标题承诺的那句话,也正是术语表条目所谓「碰到核心的 diff 改的是权限与事实」的意思。

第二,在钉死的投影里搜这个字段的名字——并且预期什么都搜不到。 新加的字段不会出现在任何视图、数据集或翻译里,因为它们一个都没被编辑过。这不是运行时藏起来的遗漏,而是门禁打印出来的那条警告。审阅者要问的,正是门禁 fix: 那一行提出的问题:这个字段该不该有一个消费者,是哪一个?对 budget 来说,大概率是 All Tasks 上的一列和一个 zh-CN 标签;大概率暂时不是一个仪表盘度量。那是对三个具名文件的三处小而有意的编辑,每一处都和最初那五行一样可审。

第三,把删除当成另一种改动。 上面说的全是加字段。驱动的结构同步只做加法:它建缺失的表、加缺失的列,从不修改或删除已有的列,所以一个被删掉或改名的字段会把它的列留在原地,成为 os migrate 报告出来、由人来对账的漂移。推导面会立刻跟上删除——REST 记录丢掉那个键,describe_object 丢掉那个字段——数据表却不会。而每一个仍然写明被删字段的钉死投影现在都错了,同一道门禁会把它当作悬空绑定报出来。命名测试照样适用;只是要看的清单更长,而且从迁移开始。

这套顺序也补上了四遍审阅清单缺的那一半——它的第四遍是「字段和视图,放最后」。命名测试说的是:哪些视图。写明了这个字段的那些。其余的,在审字段的时候已经审过了。

替钉死说句话

把上表的右半栏读成一种局限很容易,而诚实的读法恰恰相反。

一张会自动把所有新列加进来的网格,会在每次开发者为了让某条流程有地方写而加个字段时,把整个页面重排一遍。一张会把所有数值字段都画出来的仪表盘,会把 recurrence_interval 画出来。一份会自己发明标签的翻译,是一份没人写过的翻译。钉死的投影之所以钉死,是因为有人做过一个呈现上的决定;一个悄悄推翻这个决定的运行时,正是这整套格式要防止的那件事——没有任何人声明过的行为。控制台自己的回退——没声明表单视图时就把所有字段排出来——恰好是证明这条规则的例外:它只在还没有任何人做过决定的地方生效。

这套格式给审阅者的,不是钉死的投影会跟着变,而是它们找得到。它们是同一个仓库里的元数据,它们写明了字段名,门禁能把它们枚举出来。另一种情况——一份手写的代码库——是某个 React 组件里的一份列清单、另一个目录里的一个序列化器、一个 JSON 文件里的一个标签、第三套系统里的一份图表配置,而没有任何东西知道它们说的是同一个字段。

本文不覆盖的

在一个示例上的测量,就是在一个示例上的测量。有四件事留在它之外。

手工调过的视图仍然是手工的。 Todo 示例里每个列表视图都声明了列,CRM 示例里每个视图也是。新字段到达一张钉死的网格,只能靠有人把它加进去——而这个「有人」常常就是加这个字段的那个 Agent,在同一个 PR 里。命名测试告诉你往那里看;它不替你看。

翻译必须有人写。 运行时会回落到声明的标签,所以缺一条 zh-CN 会渲染成 Budget,而不是空白。这是对的默认值,但仍然是中文用户看得见的缺口。翻译是最严格意义上的钉死投影:它里面的每一个字符串都是一个决定。

外部消费者在栈之外。 门禁自己就这么说:「a consumer can legitimately live outside the stack — an API client, a hook or package this stack does not carry, a Studio-authored view」。另一个工具里按名字选取 estimated_hours 的仪表盘,钉在那个字段上,而仓库里没有任何东西知道它。命名测试对它适用;枚举对它不适用。

语义改动不是结构改动。 加字段是最简单的情形,所以才选它来做一次干净的前后对照。换成改一个字段的含义——比如给 status 加一个 cancelled 选项——推导面会同样忠实地跟上,但 Todo 示例的 Overdue Tasks 视图和它的 Overdue Tasks 指标都把「逾期」编码成 status != completed,于是一条截止日期已过的已取消任务,在两处都会被算作逾期。字段本身确实把这个定义声明了一次——due_date 上写着 dueLike: true, settledWhen: P`record.status == 'completed'`(18.3.0 的 examples/app-todo/src/objects/task.object.ts 第 73–74 行)——但 spec 把这两个键都标为只用于显示,由控制台的日期单元格读取,所以视图的过滤条件和那个指标仍然各自把规则写了一遍。这是对视图、仪表盘和对象定义的一次阅读,不是一次测量,而它正是命名测试会把你指过去——三处都写明了 status——却不替你拍板的那类后果。

把你的 Agent 指向它

上面这次运行可以从仓库复现,包装好之后大约一分钟,这意味着它也是你的 Agent 可以替你做的事。把一个编码 Agent 指向一个 ObjectStack 项目——脚手架会写出 AGENTS.md 并安装技能包,运行中的应用在 /api/v1/mcp 提供 MCP,如何配置它覆盖了规则文件、技能和工具服务器——当它开出一个改字段的变更时,向它要它自己这份 diff 的命名测试表:哪些面跟上了,哪些投影写明了这个字段,它打算对每一个怎么办。门禁那行 field-no-consumers 是这张表的第一行,而 Agent 可以比你先读到它。

本文的一切都跑在开源栈上。「为了运行它,ObjectStack 仓库提供 ObjectOS Community —— ghcr.io/objectstack-ai/objectstack 镜像、os start 和 Console,免费且自托管 —— 而 ObjectOS Cloud 是托管版本,ObjectOS Enterprise 是自管理、需许可的版本。」你的本体是普通的 ObjectStack 元数据。从 ObjectOS Cloud 或 ObjectOS Enterprise 导出本体、在开源的 ObjectStack 运行时上运行,目前尚在计划中。命名测试不随宿主而变;它读的那份定义,是同一个文件。