diff --git a/.changeset/21702-joined-report-block-dataset-required.md b/.changeset/21702-joined-report-block-dataset-required.md new file mode 100644 index 00000000000..e0269e1c82d --- /dev/null +++ b/.changeset/21702-joined-report-block-dataset-required.md @@ -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) + + + +**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. diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index 69a5cd57c5e..18edfc2b84b 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -32,7 +32,7 @@ const result = JoinedReportBlockSchema.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string \| Record` | 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) | @@ -111,7 +111,7 @@ const result = JoinedReportBlockSchema.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string \| Record` | 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) | diff --git a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts index 3fe60aae416..f718099c2d3 100644 --- a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts @@ -97,7 +97,7 @@ interface Row { const keyOf = (w: Record) => `${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 @@ -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 }; @@ -396,10 +405,18 @@ async function saveReport(protocol: any, item: Record): 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' }; @@ -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); @@ -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'); diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-block-dataset-required.ts b/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-block-dataset-required.ts new file mode 100644 index 00000000000..2576afd85d1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-block-dataset-required.ts @@ -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).', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 020ac4a9d6d..b5567bba362 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6420,6 +6420,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`defineStack`\'s identity-only check does not reach it. Its D3 record is the semantic entry ' + '`ui-record-line-items-props-closed`.', }, + { + id: 'ui-report-joined-block-dataset-required', + order: 77, + text: + 'It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, ' + + 'enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each ' + + 'block is dataset-bound, but the joined arm of `ReportSchema`\'s refinement required only a non-empty ' + + '`blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, ' + + 'and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one ' + + 'falls through to the pre-9.0 presentation bridge, which issues none either. The 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, which is read only on a `joined` report. No key is ' + + 'removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset ' + + 'a block was meant to show. Its D3 record is the semantic entry ' + + '`ui-report-joined-block-dataset-required`.', + }, { id: 'ui-report-joined-chart-retired', order: 38, @@ -20622,6 +20638,58 @@ const step18: MigrationStep = { + 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the ' + 'rail.', }, + // 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. + { + 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).', + }, // #20161 — the judgement half of `report-joined-chart-removed`, owed under // #17152 ruling B (one D3 entry per retirement family, even when a lossless D2 // exists). The D2 conversion strips a joined report's `chart` mechanically, at diff --git a/packages/spec/src/ui/report-joined-block-dataset.test.ts b/packages/spec/src/ui/report-joined-block-dataset.test.ts new file mode 100644 index 00000000000..71921e4d23f --- /dev/null +++ b/packages/spec/src/ui/report-joined-block-dataset.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21702] Every block of a `joined` report binds a `dataset`. + * + * `ReportSchema`'s refinement comment and `content/docs/ui/reports.mdx` both + * said a joined report's blocks are "each block dataset-bound", while the joined + * arm only required `blocks` to be non-empty and `JoinedReportBlockSchema.dataset` + * is optional — so a block with no `dataset` parsed, passed `objectstack + * validate` and every save door, and drew nothing (the joined renderer queries + * nothing for it; a report whose blocks all lack one falls through to the pre-9.0 + * presentation bridge). The joined arm now refuses each such block at + * `blocks[i].dataset`, naming the block, with the prescription "bind the block to + * a dataset". + * + * Every refusal asserts the envelope a schema door owes — the issue `code` and + * its `path` — plus the message's first sentence and its prescription. + * The preservation pins hold the two real joined reports measured at the + * census: the showcase `TaskOverviewReport` (mirrored below, as the reports + * guide mirrors it) and a fixture shaped like hotcrm's `customer_churn_signals` + * (hotcrm `4054ec26`, `src/sales/reports/churn.report.ts`). Both bind every + * block and parse exactly as before. + */ + +import { describe, expect, it } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; +import { JoinedReportBlockSchema, ReportSchema, defineReport } from './report.zod'; + +const ENTRY_ID = 'ui-report-joined-block-dataset-required'; + +/** The refusal's first sentence for a block named `name`. */ +const FIRST_SENTENCE = (name: string) => + `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.'; +const PRESCRIPTION = 'Bind the block to a dataset:'; + +interface IssueSig { code: string; path: string; message: string } + +const issuesOf = (r: { success: boolean; error?: { issues: ReadonlyArray<{ code: string; path: PropertyKey[]; message: string }> } }): IssueSig[] => + (r.error?.issues ?? []).map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message })); + +/** The triage probe's report: two blocks, each only a name and a label. */ +const PROBE = { + name: 'probe_joined', + label: 'Probe', + type: 'joined', + blocks: [ + { name: 'first_block', label: 'First' }, + { name: 'second_block', label: 'Second' }, + ], +} as const; + +const BOUND = { name: 'open_block', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'] } as const; + +describe('a joined report refuses a block that binds no dataset', () => { + it('the probe report is refused once per block, at blocks.N.dataset, naming each block', () => { + const issues = issuesOf(ReportSchema.safeParse(PROBE)); + expect(issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', 'blocks.0.dataset'], + ['custom', 'blocks.1.dataset'], + ]); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE('first_block')), issues[0]!.message).toBe(true); + expect(issues[1]!.message.startsWith(FIRST_SENTENCE('second_block')), issues[1]!.message).toBe(true); + for (const issue of issues) expect(issue.message).toContain(PRESCRIPTION); + }); + + it('a bound block beside an unbound one: only the unbound block is refused, at its own index', () => { + const issues = issuesOf(ReportSchema.safeParse({ ...PROBE, blocks: [BOUND, { name: 'done_block', label: 'Done' }] })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', 'blocks.1.dataset']]); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE('done_block'))).toBe(true); + }); + + it('a block whose name is empty is named by its position, and both issues are reported', () => { + const issues = issuesOf(ReportSchema.safeParse({ ...PROBE, blocks: [BOUND, { name: '' }] })); + const datasetIssue = issues.find((i) => i.path === 'blocks.1.dataset'); + expect(datasetIssue?.code).toBe('custom'); + expect(datasetIssue!.message).toContain('and `blocks[1]` binds none'); + expect(issues.some((i) => i.path === 'blocks.1.name'), 'the empty name keeps its own issue').toBe(true); + }); + + it('it joins the other joined-arm refusals rather than replacing them', () => { + const issues = issuesOf(ReportSchema.safeParse({ ...PROBE, blocks: [{ name: 'first_block' }], dataset: 'tasks' })); + expect(issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', 'blocks.0.dataset'], + ['custom', 'dataset'], + ]); + }); + + it('taking the advice parses — the same blocks, each bound to a dataset', () => { + const r = ReportSchema.safeParse({ + ...PROBE, + blocks: PROBE.blocks.map((b) => ({ ...b, dataset: 'tasks', values: ['task_count'] })), + }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + it('CONTROL: a block on a NON-joined report is not judged by this arm — `blocks` is read on a joined report only', () => { + const r = ReportSchema.safeParse({ + name: 'hours', label: 'Hours', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'], + blocks: [{ name: 'first_block' }], + }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + it('CONTROL: the block shape alone still parses a block with no dataset — the requirement lives on the joined arm', () => { + const r = JoinedReportBlockSchema.safeParse({ name: 'first_block', label: 'First' }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); +}); + +describe('every door that parses a report refuses it', () => { + const stackWith = (reports: unknown[]) => ({ + manifest: { id: 'com.example.reports', name: 'reports', version: '1.0.0', type: 'app', namespace: 'rjb' }, + reports, + }); + const boundProbe = { ...PROBE, name: 'bound_probe', blocks: [BOUND] }; + + it('defineReport throws the refusal', () => { + expect(() => defineReport(PROBE as never)).toThrow(FIRST_SENTENCE('first_block')); + }); + + it('the registered `report` type schema — what the metadata save door validates against — refuses it', () => { + const saveDoor = getMetadataTypeSchema('report'); + expect(saveDoor, 'the `report` metadata type must resolve a schema').toBeDefined(); + const issues = issuesOf(saveDoor!.safeParse(PROBE) as never); + expect(issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', 'blocks.0.dataset'], + ['custom', 'blocks.1.dataset'], + ]); + // CONTROL: the same door accepts the bound report. + expect(saveDoor!.safeParse(boundProbe).success).toBe(true); + }); + + it('ObjectStackDefinitionSchema — the stack parse `objectstack validate` runs — refuses it at reports.N.blocks.M.dataset', () => { + const refused = ObjectStackDefinitionSchema.safeParse(stackWith([boundProbe, PROBE])); + expect(refused.success).toBe(false); + expect(refused.success ? [] : refused.error.issues.map((i) => i.path.join('.'))).toEqual([ + 'reports.1.blocks.0.dataset', + 'reports.1.blocks.1.dataset', + ]); + // CONTROL: the same parse accepts the bound report alone. + expect(ObjectStackDefinitionSchema.safeParse(stackWith([boundProbe])).success).toBe(true); + }); + + it('defineStack wraps the refusal in its ADR-0112 envelope', () => { + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stackWith([PROBE]) as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the unbound joined report').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'reports.0.blocks.0.dataset', code: 'custom' }, + { path: 'reports.0.blocks.1.dataset', code: 'custom' }, + ]); + // CONTROL: the bound report is accepted at the same door. + expect(() => defineStack(stackWith([boundProbe]) as never)).not.toThrow(); + }); +}); + +describe('the joined reports measured at the census parse unchanged', () => { + /** `examples/app-showcase/src/ui/reports/index.ts` `TaskOverviewReport`, byte for byte. */ + const TASK_OVERVIEW = { + name: 'showcase_task_overview', + label: 'Task Overview (Joined)', + description: 'Multiple task sub-reports stacked into one joined view.', + type: 'joined', + drilldown: true, + blocks: [ + { + name: 'open_block', + label: 'Open Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + runtimeFilter: { done: false }, + }, + { + name: 'done_block', + label: 'Completed Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['task_count'], + runtimeFilter: { done: true }, + }, + ], + } as const; + + /** Shaped like hotcrm `customer_churn_signals`: four summary blocks over two datasets, no container scope. */ + const CHURN_SIGNALS = { + name: 'customer_churn_signals', + label: 'Customer Churn Signals', + description: 'Three-panel early-warning view: at-risk customers, silent high-value accounts, and recently-lost opportunities.', + type: 'joined', + blocks: [ + { + name: 'csm_flagged_accounts', + label: 'CSM-Flagged Accounts', + description: 'Accounts a CSM has hand-flagged as at-risk or churning, grouped by type.', + type: 'summary', + dataset: 'account_metrics', rows: ['type'], values: ['account_count'], + runtimeFilter: { is_active: true, health_score: { $in: ['at_risk', 'churning'] } }, + }, + { + name: 'at_risk_accounts', + label: 'At-Risk Accounts', + type: 'summary', + dataset: 'account_metrics', rows: ['industry'], values: ['account_count'], + runtimeFilter: { is_active: true, last_activity_date: { $lt: '{60_days_ago}' } }, + }, + { + name: 'silent_high_value', + label: 'Silent High-Value Accounts', + type: 'summary', + dataset: 'account_metrics', rows: ['type'], values: ['account_count'], + runtimeFilter: { is_active: true, tier: { $in: ['strategic', 'enterprise'] }, last_activity_date: { $lt: '{90_days_ago}' } }, + }, + { + name: 'recently_closed_lost', + label: 'Recently Lost Opportunities', + type: 'summary', + dataset: 'opportunity_metrics', rows: ['owner'], values: ['total_amount', 'opp_count'], + runtimeFilter: { stage: 'closed_lost', close_date: { $gte: '{30_days_ago}' } }, + }, + ], + } as const; + + it('the showcase TaskOverviewReport parses, and the parse is the input itself', () => { + const r = ReportSchema.safeParse(TASK_OVERVIEW); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(r.data).toEqual(TASK_OVERVIEW); + }); + + it('the hotcrm-shaped customer_churn_signals parses, gaining only the `drilldown` default', () => { + const r = ReportSchema.safeParse(CHURN_SIGNALS); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(r.data).toEqual({ ...CHURN_SIGNALS, drilldown: true }); + }); +}); + +describe('the ADR-0087 ledger', () => { + it('registers one D3 entry at protocol 18, with no D2 conversion, prescribing the binding', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((e) => e.id === ENTRY_ID); + expect(entries, 'the narrowing needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.conversionIds ?? []).toEqual([]); + expect(entry!.replacement).toContain(PRESCRIPTION); + expect(entry!.acceptanceCriteria).toContain('blocks.N.dataset'); + }); + + it('step 18\'s rationale names the entry', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.rationale).toContain(`Its D3 record is the semantic entry \`${ENTRY_ID}\`.`); + }); + + it('registers no tombstone: `dataset` stays declared on the block', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => /JoinedReportBlock:dataset$/.test(k))).toEqual([]); + // CONTROL: the flattened table is the real one — it carries a known step-18 tombstone. + expect(all).toContain('api/RestApiEndpoint:timeout'); + }); +}); diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 57047176685..fadd5361652 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -181,6 +181,31 @@ const JOINED_CONTAINER_CHART_REFUSED = + 'non-joined report of its own with that `chart`. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; +/** + * The refusal a `joined` report's block with no `dataset` earns, at + * `blocks[i].dataset` (#21702; ADR-0021 single-form, ADR-0049 enforce-or-remove, + * the enforce arm). A block is an independent dataset query, and nothing else + * feeds it: the container selects nothing (its `dataset` is refused below). + * Measured at this repo's `.objectui-sha` pin + * `ab187972159583b595facdcae3c73b50f6f312e9`: `DatasetReportRenderer`'s joined + * branch hands each block's `dataset` to its table as + * `String(block.dataset ?? '')`, and the table's query hook goes idle on an + * empty name, so an unbound block draws an empty table and queries nothing; a + * report whose blocks ALL lack one fails `isDatasetReport` and falls through to + * the pre-9.0 presentation bridge, which queries nothing either. + * + * The key stays `.optional()` on `JoinedReportBlockSchema` itself: `blocks` is + * read only on a `joined` report, so the requirement lives on the arm of + * `ReportSchema`'s refinement that reads it. `block` is how the refusal names + * the block — by its `name`, or by its `blocks[i]` position when the name is + * empty (an empty name is its own issue, and does not stop this one). + */ +function joinedBlockDatasetRequired(block: string): string { + return `a \`joined\` report draws each block from that block's own \`dataset\`, and ${block} 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.'; +} + /** * Joined Report Block Schema * @@ -279,8 +304,13 @@ export const JoinedReportBlockSchema = lazySchema(() => strictObject({ * ADR-0021 — the dataset this block binds to (single-form). The block selects * the dataset's measures by name; the legacy inline `objectName` + `columns` + * `groupings` query was removed in the cutover. + * + * Every block of a `joined` report binds one: `ReportSchema`'s refinement + * refuses a block without it at `blocks[i].dataset` (#21702), because nothing + * else would feed the block's query. It stays optional on this shape only + * because `blocks` is read on a `joined` report alone. */ - dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)').meta({ title: 'Dataset' }), + dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021); a joined report refuses a block without one').meta({ title: 'Dataset' }), /** Dimension names (from the dataset) to group rows by. Dataset-bound only. */ rows: z.array(z.string()).optional().describe('Dimension names down (dataset-bound)').meta({ title: 'Rows' }), /** Dimension names across — matrix blocks pivot rows × columns (ADR-0021 D2). */ @@ -506,6 +536,15 @@ export const ReportSchema = lazySchema(() => strictObject({ if (!r.blocks || r.blocks.length === 0) { ctx.addIssue({ code: 'custom', message: 'a `joined` report needs `blocks`.', path: ['blocks'] }); } + // #21702 — "each block dataset-bound" is enforced here, not only stated: a + // block with no `dataset` is refused at its own `dataset` path, by name + // (see `joinedBlockDatasetRequired`). Until this arm it parsed, passed + // `objectstack validate` and every save door, and drew nothing. + r.blocks?.forEach((block, i) => { + if (!block || typeof block !== 'object' || block.dataset !== undefined) return; + const name = typeof block.name === 'string' && block.name.length > 0 ? `block \`${block.name}\`` : `\`blocks[${i}]\``; + ctx.addIssue({ code: 'custom', message: joinedBlockDatasetRequired(name), path: ['blocks', i, 'dataset'] }); + }); } else if (!r.dataset || !r.values || r.values.length === 0) { ctx.addIssue({ code: 'custom',