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
33 changes: 33 additions & 0 deletions .changeset/21702-joined-report-block-dataset-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': minor
---

Every block of a `joined` report must bind a `dataset`: a block with none is refused at `blocks[i].dataset`, by name, with the prescription to bind the block to a dataset.

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-report-joined-block-dataset-required -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** A `joined` report carries its data on `blocks`, each an independent query over that block's own `dataset`, and the container selects nothing. `ReportSchema`'s refinement comment and the reports guide both said each block is dataset-bound, but the joined arm only required `blocks` to be non-empty, and a block's `dataset` is optional on its shape. So a block with no `dataset` parsed, `objectstack validate` exited 0 on it, the metadata save door stored it, and the report drew nothing for it: the joined renderer issues no query for an unbound block and draws it as an empty table, a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues no query either, and a dashboard drill-down that opens that report lists the records instead of drawing it.

**What is refused.** On a report whose `type` is `joined`, each block with no `dataset`. The issue's `code` is `custom`, at `blocks.N.dataset`, one per unbound block, and its message names the block: *a `joined` report draws each block from that block's own `dataset`, and block `NAME` binds none, so nothing queries it and it draws no rows. Bind the block to a dataset: set its `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows.* It is an arm of `ReportSchema`'s own refinement, so it reaches `defineReport`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `reports.N.blocks.M.dataset`), `os validate` / `os build`, and the metadata save door (`422 INVALID_METADATA`). A stored report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save.

**What stays accepted, byte for byte.** A `joined` report whose blocks all bind a `dataset`; every non-joined report, including one that carries `blocks` (they are read on a `joined` report only); and `JoinedReportBlockSchema` parsed on its own, where `dataset` stays optional.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| a `joined` report block with no `dataset`, e.g. `{ name: 'open_block', label: 'Open' }` | the same block bound to the dataset it shows: `{ name: 'open_block', label: 'Open', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }` |
| a `joined` report whose blocks all bind a `dataset`, or any non-joined report | unchanged |

**The one-line fix: set each block's `dataset` to the dataset whose measures it shows, or delete a block that has nothing to show (a `joined` report keeps at least one block).** The renderer never drew an unbound block, so binding it is the first time it draws anything.

**Who is affected, measured.** No joined report with an unbound block exists in this repository's `examples/**`, `packages/**`, `skills/**` or `content/docs/**`: the showcase's one joined report, the reports guide's example and every test fixture bind each block, apart from one metadata-door test fixture that left its block unbound on purpose and is bound in this change. The hotcrm application's one joined report binds every block, and the cloud repository has no joined report. Deployed metadata was not measured. Studio's report inspector can still produce one: its `blocks` repeater adds a blank row and requires no column of it, so a block saved with only a name is now refused at save, at `blocks.N.dataset`, where it used to be stored and draw nothing.

### The kit

- **The refusal.** A per-block arm of the joined branch of `ReportSchema`'s refinement (`ui/report.zod.ts`), beside the container refusals for `dataset` / `rows` / `columns` / `values`, `order` and `chart`. The block's `dataset` description now says a joined report refuses a block without one, and the generated reference page carries it.
- **The ledger.** The D3 semantic entry `ui-report-joined-block-dataset-required` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which dataset a block shows is the author's decision, and no rewrite can name it.
4 changes: 2 additions & 2 deletions content/docs/references/ui/report.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const result = JoinedReportBlockSchema.parse(data);
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **description** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021); a joined report refuses a block without one |
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
| **values** | `string[]` | optional | Measure names to show (dataset-bound) |
Expand Down Expand Up @@ -111,7 +111,7 @@ const result = JoinedReportBlockSchema.parse(data);
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **description** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021); a joined report refuses a block without one |
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
| **values** | `string[]` | optional | Measure names to show (dataset-bound) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ interface Row {
const keyOf = (w: Record<string, unknown>) =>
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`;

function makeProtocol() {
function makeProtocol(opts: { datasets?: readonly unknown[] } = {}) {
// ⚠️ Keyed BY TABLE. `find`/`findOne` below answer nothing, so this harness
// cannot serve a `sys_metadata_history` row as a `sys_metadata` row the way
// #16223 measured — but one flat map still made `rows.size` the total of
Expand Down Expand Up @@ -131,7 +131,16 @@ function makeProtocol() {
assertEngineDeleteDispatch(opts);
return { deleted: 0 };
},
registry: { registerItem: () => {}, registerObject: () => {} },
registry: {
registerItem: () => {},
registerObject: () => {},
// `datasets` (section 5 only) is the registered dataset universe the
// author-time gate resolves a report's bindings against; every other
// caller passes none, so the registry lists nothing, as before.
...(opts.datasets
? { listItems: (type: string) => (type === 'dataset' ? [...opts.datasets!] : []) }
: {}),
},
};
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
return { protocol, rows };
Expand Down Expand Up @@ -396,10 +405,18 @@ async function saveReport(protocol: any, item: Record<string, unknown>): Promise
}

describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door', () => {
// No `dataset` on the block: this door also runs the author-time lints, and
// `chart-dataset-unknown` refuses a dataset this stub engine cannot resolve —
// a second refusal the CONTROL below would otherwise be reading instead.
const block = { name: 'open_block', type: 'summary', rows: ['status'], values: ['task_count'] };
// The block binds a dataset the harness registers: a joined report's block
// with no `dataset` is itself refused at `blocks.0.dataset` (#21702), and
// this door also runs the author-time lints, where `chart-dataset-unknown`
// refuses a dataset the stub engine cannot resolve — either would be a
// second refusal the CONTROL below would otherwise be reading instead.
const datasets = [{
name: 'task_metrics',
object: 'task',
dimensions: [{ name: 'status', field: 'status' }],
measures: [{ name: 'task_count', aggregate: 'count' }],
}];
const block = { name: 'open_block', type: 'summary', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] };
const joined = { name: 'task_overview', label: 'Task Overview', type: 'joined', blocks: [block] };
const chart = { type: 'bar', xAxis: 'status', yAxis: 'task_count' };

Expand All @@ -408,7 +425,7 @@ describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door'
['the container', { ...joined, chart }, 'custom', 'chart', 'a `joined` report draws no chart'],
['a block', { ...joined, blocks: [{ ...block, chart }] }, 'unrecognized_keys', 'blocks.0', '`report.blocks[].chart` was removed'],
] as const)('`chart` on %s — 422 INVALID_METADATA, located at the key, nothing stored', async (_where, item, code, path, prescription) => {
const { protocol, rows } = makeProtocol();
const { protocol, rows } = makeProtocol({ datasets });
const err = await saveReport(protocol, item);

expect(err).toBeInstanceOf(Error);
Expand All @@ -421,7 +438,7 @@ describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door'
});

it('CONTROL — the same joined report without a `chart` is stored (the refusal is the key, not the report)', async () => {
const { protocol, rows } = makeProtocol();
const { protocol, rows } = makeProtocol({ datasets });
const result = await saveReport(protocol, joined);

expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// The D3 entry for the joined arm of `ReportSchema`'s refinement refusing a
// block that binds no `dataset`: the enforce arm of ADR-0049 enforce-or-remove,
// applied to the "each block dataset-bound" contract the schema comment and the
// reports guide already stated. It narrows a report's accept set; no key is
// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is
// no D2 conversion either: which dataset a block shows is the author's
// decision, and no rewrite can name it.
export const entry: SemanticMigration = {
id: 'ui-report-joined-block-dataset-required',
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
// inside a code span.
surface: 'reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset '
+ '(the joined arm of the ReportSchema refinement)',
replacement: 'Bind the block to a dataset: set the block\'s `dataset` to the dataset whose measures '
+ '(`values`) and dimensions (`rows`) it shows. A block with nothing to show can be deleted '
+ 'instead, as long as the report keeps at least one block.',
reason:
'ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A `joined` report '
+ 'carries its data on `blocks`, each an independent query over that block\'s own `dataset`, and the '
+ 'container selects nothing: a container `dataset` is refused. `ReportSchema`\'s refinement comment '
+ 'and the reports guide both said each block is dataset-bound, but the joined arm required only that '
+ '`blocks` be non-empty, and a block\'s `dataset` is optional on its shape, so a block with no `dataset` '
+ 'parsed, passed `objectstack validate` and every save door, and drew nothing. Measured at this repo\'s '
+ '`.objectui-sha` pin `ab187972159583b595facdcae3c73b50f6f312e9`: the joined renderer hands each '
+ 'block\'s `dataset` to its table, whose query hook goes idle on an empty name, so an unbound block '
+ 'draws an empty table and issues no query; a report whose blocks all lack one fails the '
+ 'dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query '
+ 'either, and a dashboard drill-down that opens that report lists the records instead of drawing '
+ 'it. Studio\'s report inspector authors blocks through the spec form\'s `blocks` repeater, which '
+ 'adds a blank row and requires no column of it, so a block saved with only a name reached the store '
+ 'with no error. The joined arm now refuses each such block at `blocks[i].dataset`, naming the block, '
+ 'with the prescription to bind it to a dataset. `dataset` stays optional on the block shape itself: '
+ '`blocks` is read only on a `joined` report, and a block on any other report type is ignored, as '
+ 'before. Ships at once, with no deprecation window: there is no window in which an unbound block '
+ 'draws anything, and there is no mechanical rewrite, because only the author knows which dataset '
+ 'the block was meant to show.',
acceptanceCriteria:
'WHICH DOOR: this is the spec schema\'s refusal, so it lands wherever a report is parsed through '
+ '`@objectstack/spec` — `defineReport`, `defineStack`, `os validate` / `os build`, and the metadata '
+ 'save door (the `report` entry of the metadata type registry) — as one `custom` issue per unbound '
+ 'block at `blocks.N.dataset`, naming the block. A stored `sys_metadata` report row is not rewritten: '
+ 'it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each '
+ 'by binding the block to the dataset it is meant to show, or by deleting the block, then check the '
+ 'rendered report: every block queries its dataset and draws its rows. A joined report whose blocks '
+ 'all bind a `dataset` parses byte-identically to before, and every non-joined report is untouched. '
+ 'Census at the time of the change: no joined report with an unbound block in this repository (one '
+ 'example-app report, one docs example and the test fixtures in `packages/lint`, '
+ '`packages/platform-objects` and `packages/spec` all bind every block; one metadata-door test '
+ 'fixture that left its block unbound on purpose was bound in the same change), in the hotcrm '
+ 'application (one joined report, every block bound) or in the cloud repository (no joined report '
+ 'exists).',
};
Loading
Loading