来自下游 templates 仓库的反馈。手动修复见 objectstack-ai/templates#27。
Problem
ADR-0021 单一形态切换后,DashboardWidget 是 analytics-only:绑定一个 dataset,选取 values(measures)+ 可选 dimensions。当一个 widget 的绑定只解析出 count 类型的 measure、且没有任何 dimensions 时,它向分析服务请求的是「一行汇总」——一个数字。
这正是 type:'metric' widget 想要的形态,却是 type:'table'(或 type:'pivot')widget 的错误形态——后者作者几乎总是想列出逐行的记录。表格照常渲染,但塌缩成一行(里面装着那个 count),作者想要的逐行明细悄悄消失了。
v9 release notes 已经写明:"a flat record listing is not an analytics dataset — model it as an object-bound ListView (ADR-0017)." 但 objectstack build 对这种配置既不报错也不告警,于是错误一直隐形,直到页面在浏览器里渲染才暴露。
Impact
- 在一个下游模板仓库里,这一类问题命中了 11 个 dashboard 上的 19 个
type:'table' widget——每一个都是记录列表,在 ADR-0021 切换后静默塌缩成单个 count。见 objectstack-ai/templates#27:它移除了这些 widget,并手工用 ADR-0017 的对象绑定 ListView 重建了这些列表。
- 出问题期间 build 始终是绿的——
zod schema 校验、tsc、marketplace compose 全部通过,没有任何东西标记它。这个回归只能靠人工逐页检查才发现。
- 这是一个可预测的迁移陷阱,不是偶发的笔误:任何 v9 之前用
table widget 当记录列表的 dashboard,在单一形态切换时都会精确地变成这个坏形态。每个迁移到 v9 的团队都会暴露在这个风险下。
Minimal reproduction
一个 table widget,绑定到只选取 count measure、且不分组(无 dimension)的绑定:
```ts
// dataset —— 有一个 count measure(widget 唯一选取的那个)
export const ExpenseReportDataset = defineDataset({
name: 'expense_report_metrics',
object: 'expense_report',
measures: [
{ name: 'report_count', label: 'report_count', aggregate: 'count' },
// (还存在一个 sum measure,但下面的 widget 并没有选它)
],
dimensions: [/* ... */],
});
// widget —— 本意是逐行列表,却 count-only 绑定且无 dimensions
{
id: 'pending_reports_table',
type: 'table',
dataset: 'expense_report_metrics',
values: ['report_count'], // ← 解析为单个 count measure
// (没有 dimensions) // ← 不分组 → 只有一行
filter: { status: 'submitted' },
options: {
columns: ['title', 'requester', 'total_amount', 'cost_center', 'submitted_at'],
pageSize: 10,
sort: [{ field: 'total_amount', order: 'desc' }],
},
}
```
现象: objectstack build 成功,无任何诊断。运行时表格显示一行(装着 count 值),而不是 submitted 报销单的列表。
关于判定 heuristic: 信号在 widget 的绑定上,而不只在 dataset 上。这里 dataset 确实有一个 sum measure 和一个 dimension,但 widget 仍然是坏的——因为它只选了 report_count 且没声明 dimensions。一条只看「dataset 的 measures 全是 count」的规则会漏掉这个 case。请把检查挂在 widget 实际请求的内容上。
Expected behavior
当某个单独的 DashboardWidget 满足以下全部条件时,objectstack build 应发出告警(非致命,不破坏现有 build):
type 为 'table' 或 'pivot',且
- 它选取的
values 解析出的 measures 全是 aggregate: 'count',且
- widget 没有声明
dimensions(不分组)。
建议文案:
```
warning dashboard "expenses_overview_dashboard" › widget "pending_reports_table":
a 'table' widget bound to dataset "expense_report_metrics" selects only
count measure(s) (report_count) and no dimensions, so it renders a single
summary row — not a per-record list.
A flat record listing is not an analytics dataset. Model it as an
object-bound ListView (ADR-0017) surfaced through app navigation, and use
a 'metric' widget here if you only need the count.
If a single-row table is intentional, add an explicit dimension or
suppress with: // objectstack-disable-next-line table-count-only
```
发成告警(而非 error)保证非破坏性、并允许压制有意为之的情况,同时让陷阱在 CI 日志和本地构建里可见。想让它致命的团队可以在 lint 配置里把它提升为 error。
Implementation notes
- 把检查放在构建/校验流水线里、在 dataset 引用被解析之后的那一步——也就是每个 widget 的
dataset 名已链接到其 defineDataset、且 values 的每一项都已解析为带已知 aggregate 的具体 measure 之后。该检查需要解析后的 measure aggregate 类型,所以无法在纯 zod schema 校验阶段对原始 widget 字面量运行;它属于语义/交叉引用校验阶段(和现有的「widget 必须有 dataset + values」analytics-only 检查放在一起),不属于 schema parsing。
- 这是一次对已加载 metadata 的纯静态分析,不需要运行时查询。每个 widget:解析
values → measures,检查 every(m => m.aggregate === 'count'),并检查 !widget.dimensions?.length,前置门槛是 type ∈ {'table','pivot'}。
- 建议给它一个 registry 条目(如规则 id
table-count-only),以便和其他 build 诊断一致地配置/压制,也让文案里的压制注释是真实可用的。
- 一个更粗的 dataset 级变体(「dataset 只有 count measures 且无可用 dimensions,却被一个 table widget 绑定」)更易计算但更不精确——它既会漏掉上面 repro 这种(dataset 还有别的 measure),也更容易误报。优先选 widget-binding 级的规则。
Downstream reference
objectstack-ai/templates#27 — "fix(ui): migrate dashboard record-listing tables to ListViews (ADR-0017)" — 是下游的手动修复:移除了全部 19 个塌缩的 table widget,并把真正的记录列表重建为对象绑定的 ListView。本 issue 提议的构建期告警,本可以在迁移时就抓出这全部 19 个,而不是等到 render 时。
Problem
ADR-0021 单一形态切换后,
DashboardWidget是 analytics-only:绑定一个dataset,选取values(measures)+ 可选dimensions。当一个 widget 的绑定只解析出 count 类型的 measure、且没有任何dimensions时,它向分析服务请求的是「一行汇总」——一个数字。这正是
type:'metric'widget 想要的形态,却是type:'table'(或type:'pivot')widget 的错误形态——后者作者几乎总是想列出逐行的记录。表格照常渲染,但塌缩成一行(里面装着那个 count),作者想要的逐行明细悄悄消失了。v9 release notes 已经写明:"a flat record listing is not an analytics dataset — model it as an object-bound ListView (ADR-0017)." 但
objectstack build对这种配置既不报错也不告警,于是错误一直隐形,直到页面在浏览器里渲染才暴露。Impact
type:'table'widget——每一个都是记录列表,在 ADR-0021 切换后静默塌缩成单个 count。见 objectstack-ai/templates#27:它移除了这些 widget,并手工用 ADR-0017 的对象绑定 ListView 重建了这些列表。zodschema 校验、tsc、marketplace compose 全部通过,没有任何东西标记它。这个回归只能靠人工逐页检查才发现。tablewidget 当记录列表的 dashboard,在单一形态切换时都会精确地变成这个坏形态。每个迁移到 v9 的团队都会暴露在这个风险下。Minimal reproduction
一个
tablewidget,绑定到只选取countmeasure、且不分组(无 dimension)的绑定:```ts
// dataset —— 有一个 count measure(widget 唯一选取的那个)
export const ExpenseReportDataset = defineDataset({
name: 'expense_report_metrics',
object: 'expense_report',
measures: [
{ name: 'report_count', label: 'report_count', aggregate: 'count' },
// (还存在一个 sum measure,但下面的 widget 并没有选它)
],
dimensions: [/* ... */],
});
// widget —— 本意是逐行列表,却 count-only 绑定且无 dimensions
{
id: 'pending_reports_table',
type: 'table',
dataset: 'expense_report_metrics',
values: ['report_count'], // ← 解析为单个 count measure
// (没有 dimensions) // ← 不分组 → 只有一行
filter: { status: 'submitted' },
options: {
columns: ['title', 'requester', 'total_amount', 'cost_center', 'submitted_at'],
pageSize: 10,
sort: [{ field: 'total_amount', order: 'desc' }],
},
}
```
现象:
objectstack build成功,无任何诊断。运行时表格显示一行(装着 count 值),而不是 submitted 报销单的列表。关于判定 heuristic: 信号在 widget 的绑定上,而不只在 dataset 上。这里 dataset 确实有一个
summeasure 和一个 dimension,但 widget 仍然是坏的——因为它只选了report_count且没声明dimensions。一条只看「dataset 的 measures 全是 count」的规则会漏掉这个 case。请把检查挂在 widget 实际请求的内容上。Expected behavior
当某个单独的
DashboardWidget满足以下全部条件时,objectstack build应发出告警(非致命,不破坏现有 build):type为'table'或'pivot',且values解析出的 measures 全是aggregate: 'count',且dimensions(不分组)。建议文案:
```
warning dashboard "expenses_overview_dashboard" › widget "pending_reports_table":
a 'table' widget bound to dataset "expense_report_metrics" selects only
count measure(s) (report_count) and no dimensions, so it renders a single
summary row — not a per-record list.
```
发成告警(而非 error)保证非破坏性、并允许压制有意为之的情况,同时让陷阱在 CI 日志和本地构建里可见。想让它致命的团队可以在 lint 配置里把它提升为 error。
Implementation notes
dataset名已链接到其defineDataset、且values的每一项都已解析为带已知aggregate的具体 measure 之后。该检查需要解析后的 measureaggregate类型,所以无法在纯zodschema 校验阶段对原始 widget 字面量运行;它属于语义/交叉引用校验阶段(和现有的「widget 必须有dataset+values」analytics-only 检查放在一起),不属于 schema parsing。values→ measures,检查every(m => m.aggregate === 'count'),并检查!widget.dimensions?.length,前置门槛是type ∈ {'table','pivot'}。table-count-only),以便和其他 build 诊断一致地配置/压制,也让文案里的压制注释是真实可用的。Downstream reference
objectstack-ai/templates#27 — "fix(ui): migrate dashboard record-listing tables to ListViews (ADR-0017)" — 是下游的手动修复:移除了全部 19 个塌缩的 table widget,并把真正的记录列表重建为对象绑定的 ListView。本 issue 提议的构建期告警,本可以在迁移时就抓出这全部 19 个,而不是等到 render 时。