Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/26847-list-view-chart-binding-publish-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/lint': minor
---

fix(lint)!: the view and object publish doors refuse a list-view chart whose dataset, dimension or measure does not resolve

Clause-②: no (narrowing) — a list view whose `chart.dataset` names no declared dataset, or whose `chart.dimensions` / `chart.values` names a dimension or measure its dataset does not declare, is refused at publish (`PUT /api/v1/meta/view`, `PUT /api/v1/meta/object`); it published `200` before. `os validate` / `os build` already refused it on the shapes they walked, and still do; they now also report it where they skipped it: an `objects[].listViews` chart in a stack that declares no datasets, reports, views or pages, and a `views[]` entry in the flattened list overlay or ViewItem `config` shape. Nothing is widened.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: an existing author-time rule now also runs at the view and object publish doors. There is no mechanical rewrite to prescribe, because only the author knows which declared dataset or measure the chart meant. -->

**BREAKING** accept-set narrowing at the runtime publish door: `validateChartBindings` (`@objectstack/lint`) joins the reference-integrity suite's `view` and `object` writes.

- **Why.** A Studio or MCP author who mistyped a dataset name, or named a measure the dataset lacks, got a saved view whose chart rendered empty or broken, with nothing saying why, while the CLI refused the same file.
- **Before.** Measured on the real `PUT /api/v1/meta/view` route over a real `sys_metadata` store: a chart list view naming `no_such_dataset`, or a `values` entry naming no measure of its dataset, answered `200` and was stored, as a view item (`config`), as a flattened list overlay and as a container `listViews` member alike. `os validate` / `os build` skipped the same two cases the door now reports: an object's `listViews` chart in a stack with no datasets, reports, views or pages, and the flattened overlay and ViewItem `config` shapes.
- **Now.** Each is refused `422 INVALID_METADATA` with the verdict `os validate` gives: `chart-dataset-unknown` at `chart.dataset`, `chart-measure-unknown` at `chart.values[i]`, and `chart-dimension-unknown` at `chart.dimensions[i]`. The rule's list-view walk now opens the two standalone view shapes the door carries (the flattened list overlay and the ViewItem `config`), and an object's own `listViews` is judged on an object write. A draft save is not gated, as for every author-time rule.
- **Who moves.** No view or object in `examples/**` or `packages/platform-objects` is refused: measured at the door's own snapshot shape, the two chart list views in the showcase app bind declared datasets and measures.

**The remedy.** Bind a dataset the stack or package declares, and name its declared dimensions and measures (a measure name such as `amount_sum`, never the base field `amount`), or declare the missing dimension or measure on the dataset.
7 changes: 7 additions & 0 deletions content/docs/data-modeling/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,13 @@ and list-view charts to it **by name**. Presentations reference a dataset by its
`name` string — the shipped presentation schemas carry no inline/anonymous
dataset shape, so even a one-off single-object KPI is a small named dataset.

A list-view chart's binding must resolve. A `dataset` no package declares, or a
`dimensions` or `values` entry the dataset does not declare, is refused at
publish, for a view and for an object's own `listViews` alike, and fails
`os validate` (`chart-dataset-unknown`, `chart-dimension-unknown`,
`chart-measure-unknown`). A `values` entry names a dataset **measure**, never
the base field it aggregates.

A list-view chart draws the rows of its dataset's base `object`, never the
list's own records: the list's filter does not reach the dataset query. So a
list view's chart must bind a dataset whose `object` is the object the list
Expand Down
45 changes: 29 additions & 16 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,23 +422,36 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// every page-door binding as unresolvable. Its page-typed sibling above
// takes the default for the same reason.
{ name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings },
// [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling, and ONLY
// `report`. On a report write this member is the subject-reader: it opens
// with `recordsOf(stack.reports)` and resolves that report's own `dataset` /
// `rows` / `columns` / `values` and its chart's axes against `stack.datasets`
// — the one collection this resolution needs and the one the per-write
// snapshot already carries (#7529), so it has no missing-collection
// false-positive channel. ⛔ NOT `dashboard` or `view`: on those writes the
// report/board surfaces the rule walks are the collections the snapshot does
// NOT carry, which is the `dataset` door's own DARK pin one file over.
// [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling. On a
// report write this member is the subject-reader: it opens with
// `recordsOf(stack.reports)` and resolves that report's own `dataset` /
// `rows` / `columns` / `values` and its chart's axes against
// `stack.datasets` — the one collection this resolution needs and the one
// the per-write snapshot already carries (#7529), so it has no
// missing-collection false-positive channel.
//
// ⚠️ A REFUSAL widening, like #15254's and #19143's: a report republished
// with a dangling dataset binding or a dimension the dataset does not
// declare is now refused (422) rather than stored silently. MEASURED over
// the shipped report corpus at the door's own snapshot shape before
// crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset
// with `rows`/`values` — 0 findings, with a lit synthetic probe refused.
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings },
// [#26847] `view` and `object` join for the same reason. A list-view chart
// (`views[].list` / `listViews`, the flattened overlay, the ViewItem
// `config`, and an object's own `listViews`) binds `dataset` + `dimensions`
// + `values` and resolves against `stack.datasets` ALONE — the collection
// the snapshot carries — so the door refuses an unknown dataset
// (`chart-dataset-unknown`), dimension or measure (`chart-dimension-unknown`
// / `chart-measure-unknown`) the way `os validate` already does. The walk's
// report, page and dashboard surfaces read collections a view or object
// snapshot does not carry, so on those writes they find nothing: they are
// empty there, never dangling. ⛔ Still NOT `dashboard` or `page`: on those
// writes the surfaces the rule walks are the subject, and their sibling
// rules own them.
//
// ⚠️ REFUSAL widenings, like #15254's and #19143's: a report, view or object
// published with a dangling chart binding is now refused (422) rather than
// stored silently. Each was MEASURED over the shipped corpus at the door's
// own snapshot shape before crossing — `report`: 9 reports (showcase 4,
// todo 5), 0 findings; `view` / `object`: every view and every object with
// `listViews` in the four example apps and platform-objects, of which two
// list views chart (showcase `task` and `project`), 0 findings. A lit
// synthetic probe is refused at each door.
{ name: 'validateChartBindings', runtimeTypes: ['flow', 'report', 'view', 'object'], run: validateChartBindings },
// [#26548] The same report, one key on: `dateRange.dimension` resolved
// against the bound dataset's `type: 'date'` dimensions (named, or the ONE
// candidate when omitted). Directly after its sibling because the two read
Expand Down
6 changes: 6 additions & 0 deletions packages/lint/src/runtime-gate.object-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,16 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
// Each was measured over the shipped object corpus before crossing (116
// objects, 0 findings, precision 1.0); the comments on the members carry
// the populations, and the four controls below are the non-vacuity half.
// [#26847] `validateChartBindings` is the fourth, for an object's built-in
// `listViews` charts: resolved against `stack.datasets`, a collection every
// per-write snapshot carries. Measured over the shipped object corpus (no
// object list view charts, 0 findings); its door pins live in
// `validate-chart-bindings.test.ts`.
expect(crossed).toEqual([
'validateSearchableFields',
'validateListViewFieldRefs',
'validateObjectFieldRefs',
'validateChartBindings',
]);
// `validateSortableFields` measured equally clean but was NOT crossed:
// that is its own adjudication, and this pin is where it has to be made.
Expand Down
9 changes: 9 additions & 0 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
// [#26847] The fifth crossing, a different collection on purpose: a list
// view's chart binding (`dataset` / `dimensions` / `values`), resolved
// against `stack.datasets`, which every per-write snapshot also carries.
// The rule's list-view walk opens the flattened overlay and the ViewItem
// `config` for it; its report and page surfaces find nothing on a view
// snapshot. Measured over the shipped view corpus before crossing (two
// chart list views, 0 findings); the door pins live in
// `validate-chart-bindings.test.ts`.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
'validateChartBindings',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
// not narrowed by the member axis existing.
Expand Down
146 changes: 146 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
CHART_AXIS_NOT_SELECTED,
} from './validate-chart-bindings.js';
import { explainRule } from './rule-explanations.js';
import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';

// [#22161] Each finding of the two converted ids is one verdict sentence; the
// reasoning it used to carry is the id's `os explain` entry. Every call below
Expand Down Expand Up @@ -851,6 +853,150 @@ describe('validateChartBindings — list-view charts', () => {
});
});

// [#26847] The two standalone `views[]` shapes `PUT /api/v1/meta/view` carries,
// and the publish door itself. Measured on main before the change: a chart list
// view naming no declared dataset, or a measure its dataset lacks, published
// 200 through that door as a ViewItem record, a flattened overlay and a
// container `listViews` member alike, while `os validate` refused the file.
describe('[#26847] list-view charts at the view and object publish doors', () => {
const chart = (over: Record<string, unknown> = {}) => ({
chartType: 'bar',
dataset: 'task_metrics',
dimensions: ['status'],
values: ['est_hours'],
...over,
});
const listView = (over: Record<string, unknown> = {}) => ({
type: 'chart',
label: 'Hours by status',
data: { provider: 'object', object: 'showcase_task' },
columns: ['status'],
chart: chart(over),
});
const SHAPES: Array<[string, (over?: Record<string, unknown>) => Record<string, unknown>, string]> = [
[
'a ViewItem record',
(over) => ({ name: 'showcase_task.hours', object: 'showcase_task', viewKind: 'list', label: 'Hours', config: listView(over) }),
'views[0].config.chart',
],
[
'a flattened list overlay',
(over) => ({ name: 'showcase_task.hours', object: 'showcase_task', viewKind: 'list', ...listView(over) }),
'views[0].chart',
],
[
'a container listViews member',
(over) => ({ name: 'showcase_task', object: 'showcase_task', listViews: { hours: listView(over) } }),
'views[0].listViews.hours.chart',
],
];

describe.each(SHAPES)('the rule walks %s', (_label, view, at) => {
it('chart-dataset-unknown at `chart.dataset`', () => {
const findings = validateChartBindings({ ...baseStack(), views: [view({ dataset: 'no_such_dataset' })] });
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[CHART_DATASET_UNKNOWN, 'error', `${at}.dataset`]]);
});

it('chart-measure-unknown at `chart.values[i]`', () => {
const findings = validateChartBindings({ ...baseStack(), views: [view({ values: ['est_hours', 'ghost_measure'] })] });
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[CHART_MEASURE_UNKNOWN, 'error', `${at}.values[1]`]]);
});

it('CONTROL — a fully resolved chart reports nothing', () => {
expect(validateChartBindings({ ...baseStack(), views: [view()] })).toEqual([]);
});
});

it('a ViewItem record is judged at `config` only — its envelope carries no chart to walk twice', () => {
const item = { ...SHAPES[0][1]({ dataset: 'no_such_dataset' }), chart: chart({ dataset: 'no_such_dataset' }) };
expect(validateChartBindings({ ...baseStack(), views: [item] }).map((f) => f.path)).toEqual([
'views[0].config.chart.dataset',
]);
});

it('an object list view names a dataset in a stack that declares none: refused, not skipped', () => {
const findings = validateChartBindings({
objects: [{ name: 'showcase_task', fields: {}, listViews: { hours: listView() } }],
});
expect(findings.map((f) => [f.rule, f.path])).toEqual([
[CHART_DATASET_UNKNOWN, 'objects[0].listViews.hours.chart.dataset'],
]);
});

describe('at the runtime publish door', () => {
const { datasets } = baseStack();
const objects = [
{
name: 'showcase_task',
label: 'Task',
sharingModel: 'private',
fields: {
status: { type: 'text', label: 'Status' },
estimate_hours: { type: 'number', label: 'Estimate' },
},
},
];
const chartRules = (fs: Array<{ rule: string; path: string }>) =>
fs.filter((f) => f.rule.startsWith('chart-')).map((f) => [f.rule, f.path]);

it('`validateChartBindings` reaches `view` and `object` writes', () => {
for (const type of ['view', 'object']) {
expect(
REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateChartBindings')?.runtimeTypes,
type,
).toContain(type);
}
});

describe.each(SHAPES)('a view written as %s', (_label, view, at) => {
const write = (over?: Record<string, unknown>) =>
runRuntimeAuthoringRules({ type: 'view', item: view(over), context: { objects, datasets } });

it('⭐ LIT — a chart naming no declared dataset is REFUSED at `chart.dataset`', () => {
const result = write({ dataset: 'no_such_dataset' });
expect(chartRules(result.errors)).toEqual([[CHART_DATASET_UNKNOWN, `${at}.dataset`]]);
});

it('⭐ LIT — a chart whose `values` names no measure of its dataset is REFUSED at `chart.values[i]`', () => {
const result = write({ values: ['ghost_measure'] });
expect(chartRules(result.errors)).toEqual([[CHART_MEASURE_UNKNOWN, `${at}.values[0]`]]);
});

it('⭐ CONTROL — the same view bound to a declared dataset and measure publishes clean', () => {
const result = write();
expect(result.rulesRun).toContain('validateReferenceIntegrity');
expect(result.errors).toEqual([]);
});
});

describe('an object write carrying a built-in chart list view', () => {
const write = (over?: Record<string, unknown>) =>
runRuntimeAuthoringRules({
type: 'object',
item: { ...objects[0], listViews: { hours: { ...listView(over), data: undefined } } },
context: { objects, datasets },
});

it('⭐ LIT — an unknown dataset is REFUSED', () => {
// The door names an object entry by name rather than position.
expect(chartRules(write({ dataset: 'no_such_dataset' }).errors)).toEqual([
[CHART_DATASET_UNKNOWN, 'objects.showcase_task.listViews.hours.chart.dataset'],
]);
});

it('⭐ LIT — an unknown measure is REFUSED', () => {
expect(chartRules(write({ values: ['ghost_measure'] }).errors)).toEqual([
[CHART_MEASURE_UNKNOWN, 'objects.showcase_task.listViews.hours.chart.values[0]'],
]);
});

it('⭐ CONTROL — a resolved chart publishes clean', () => {
expect(write().errors).toEqual([]);
});
});
});
});

describe('validateChartBindings — dataset-bound page chart components', () => {
it('checks dimensions/values and a ChartConfig-style yAxis', () => {
const findings = validateChartBindings({
Expand Down
20 changes: 18 additions & 2 deletions packages/lint/src/validate-chart-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@
* `rows` / `columns` / `values`) is still checked, below.
* 2. **List-view charts** — `ListChartConfigSchema` (`dataset` +
* `dimensions` + `values`), reachable through `views[].list`,
* `views[].listViews.<key>`, and `objects[].listViews.<key>`.
* `views[].listViews.<key>`, `objects[].listViews.<key>`, and the two
* standalone `views[]` shapes `PUT /api/v1/meta/view` carries: the
* flattened list overlay (`viewKind: 'list'`, the chart at the TOP
* level) and the ViewItem record (`viewKind: 'list'`, one level down
* inside `config`). Without those two rungs a view written through the
* publish door is a shape this walk never opens, so crossing the rule
* onto `view` writes would have been a no-op that reads as coverage.
* 3. **Dataset-bound page chart components** — a `PageComponent` whose
* `properties` carry a `dataset` (the `object-chart` component). Same
* binding shape as a list chart, but it arrives through the untyped
Expand Down Expand Up @@ -363,7 +369,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
if (!stack || typeof stack !== 'object') return findings;

const datasets = indexDatasets(stack);
if (datasets.size === 0 && !stack.reports && !stack.views && !stack.pages) return findings;
if (datasets.size === 0 && !stack.reports && !stack.views && !stack.objects && !stack.pages) return findings;

/**
* Resolve one bound dataset NAME, reporting `chart-dataset-unknown` when it
Expand Down Expand Up @@ -624,6 +630,16 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
const view = views[vi];
if (!isRec(view)) continue;
const viewName = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`;
// The two standalone shapes, recognised as `validateListViewFieldRefs`
// recognises them: a flattened overlay carries its chart on the view
// itself, a ViewItem record inside `config`.
if (view.viewKind === 'list') {
if (isRec(view.config)) {
checkListChart(view.config, `view "${viewName}" (ViewItem record) · chart`, `views[${vi}].config`);
} else {
checkListChart(view, `view "${viewName}" (flattened list overlay) · chart`, `views[${vi}]`);
}
}
checkListChart(view.list, `view "${viewName}" · list chart`, `views[${vi}].list`);
if (isRec(view.listViews)) {
for (const [key, lv] of Object.entries(view.listViews)) {
Expand Down
Loading
Loading