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
14 changes: 14 additions & 0 deletions .changeset/32760-report-drill-caller-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(console, data-objectstack): a report drill that rebuilds its filter reads the dataset as the report's reader (ADR-0150 D3)

Clause-②: yes (widening) — `MetadataGetOptions` (`@object-ui/data-objectstack`) gains an optional `callerPackage`, which `MetadataClient.get` sends as `?callerPackage=`; omitted, every read is sent exactly as before.
Metadata-protocol: none

**The drill.** A report row drills straight to its records when the dataset answer carries the drill metadata. When it does not, the Console reads the dataset definition to rebuild the filter from the clicked group. That is not only an older server's answer: the server names the dataset's object on every answer but sends no drill metadata for a `type: 'date'` dimension with no `dateGranularity`, so a click on a report grouped by one takes this read. The read named no package, so for a report shipped in a package it read the bare name: another package's dataset of that name, or a `409` when two packages hold the name, where the report's own queries read the dataset its package resolves. The read now names the report's package as the reader (`?callerPackage=`), the dataset the report's queries resolve. A report authored in the environment reads the name bare, as before. A refusal opens no drill and is not retried with the bare name.

**The client.** `MetadataClient.get(type, name, { callerPackage })` reads an item as the package that reads it: that package's own item, else the one holder among its declared dependencies plus `sys`, else `409 RESOURCE_CONFLICT` naming the candidates. It is distinct from `packageId` (`?package=`), which names the holder exactly. The server serves the caller arm on the `dataset` read only, and refuses it beside `packageId`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack-ai/objectstack#32760 — the report drill's definition read, on
* the path where no exact drill filter arrives, is made as the report's reader
* (ADR-0150 D3, `callerPackage`), the dataset the report's own queries resolve.
*
* ## Why the path is live
*
* `ReportView.handleDatasetDrill` navigates straight away when the renderer
* hands it an `objectFilter`. Without one it reads the dataset definition and
* rebuilds the filter from the clicked group. That path is not only an older
* server's: the current server names the `object` on every dataset answer but
* sends no drill metadata for a `type: 'date'` dimension with no
* `dateGranularity` (no equality field, no range), so a click on a report
* grouped by one lands there. The legs below drive the REAL report renderer
* with that answer and click the row, so the path is reached the way a user
* reaches it. The definition read used to name no package: for a report in
* package P it read the bare name, which another holder of the name answers,
* or which is refused when two packages hold it.
*
* ## Discrimination
*
* `READS_AS_THE_REPORTS_READER` reds on the bare read the fix replaced.
* `ENVIRONMENT_REPORT_READS_BARE` reds if a package-less report sent one.
* `REFUSAL_IS_NO_DRILL` reds on a bare retry after a refusal.
* `FAST_PATH_CONTROL`: an answer with drill metadata reads no definition.
*/

import '@testing-library/jest-dom/vitest';
// The report body sits behind `ReportView`'s `React.lazy` boundary: import it
// at module scope (same specifier) so the lazy factory resolves at once.
import '@object-ui/plugin-report';
// `ReportView` reaches a dynamic `import('@objectstack/lint')` (through
// `ReportConfigPanel`, statically): pay its load at collection, never inside a
// case's clocked window (`check:test-source-alias`).
import '@objectstack/lint';
import * as React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import type { DataSource } from '@object-ui/types';

vi.mock('@object-ui/plugin-dashboard', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
DrillDownDrawer: ({ open, objectName, filter }: { open: boolean; objectName: string; filter?: unknown }) =>
open ? <div data-testid="drill-target">{JSON.stringify({ objectName, filter })}</div> : null,
}));
vi.mock('./ReportConfigPanel', () => ({ ReportConfigPanel: () => null }));

const meta = vi.hoisted(() => ({ value: null as unknown }));
vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => meta.value }));

const client = vi.hoisted(() => ({ get: undefined as unknown as ReturnType<typeof vi.fn> }));
vi.mock('./metadata-admin/useMetadata', () => ({ useMetadataClient: () => client }));

vi.mock('react-router-dom', () => ({
useParams: () => ({ reportName: 'task_due_report' }),
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: '/report/task_due_report', search: '' }),
}));
vi.mock('./useOpenRecordList', () => ({
useRecordListDrillNavigation: () => ({ openRecordList: vi.fn(), canOpenRecordList: () => true }),
}));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false }),
}));
vi.mock('./runtime-metadata-persistence', () => ({ persistRuntimeMetadata: vi.fn() }));
vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) }));
vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) }));
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
}));

import { ReportView } from './ReportView';

// The report body's own metadata probes (the dimension option read of the
// answer's `object`) answer from this double, never the network. Installed
// once for the whole file and never torn down, so no read that settles after a
// test body returns can reach a real socket. It answers no document, so the
// labels stay raw.
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })));

/** A report grouped by a date dimension with no granularity, served from package `com.acme.app`. */
const PACKAGE_REPORT = {
name: 'task_due_report',
label: 'Tasks by due date',
type: 'summary',
dataset: 'task_metrics',
rows: ['due'],
values: ['task_count'],
_packageId: 'com.acme.app',
};

/** The definition the reader's read resolves: `due` is a `type: 'date'` dimension with no granularity. */
const DEFINITION = {
name: 'task_metrics',
object: 'task',
dimensions: [{ name: 'due', field: 'due_date', type: 'date' }],
measures: [{ name: 'task_count', aggregate: 'count' }],
};

/**
* The current server's answer for that report: the `object` and the measure's
* drill scope, but no `dimensionFields` / `drillRawRows` (a date dimension is
* not an equality drill) and no `drillRanges` (it has no granularity).
*/
const DATE_DIM_ANSWER = {
rows: [{ due: '2026-03-01', task_count: 3 }],
fields: [{ name: 'due', label: 'Due' }, { name: 'task_count', label: 'Tasks' }],
object: 'task',
drillScopes: { task_count: {} },
};

function renderReport(report: Record<string, unknown>, answer: Record<string, unknown>) {
meta.value = {
apps: [],
objects: [],
dashboards: [],
reports: [report],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: vi.fn(async () => null),
getItemsByType: () => [],
getTypeStatus: () => 'ready',
};
const dataSource = {
find: vi.fn(async () => ({ data: [] })),
queryDataset: vi.fn(async () => answer),
} as unknown as DataSource;
return render(<ReportView dataSource={dataSource} />);
}

beforeEach(() => {
client.get = vi.fn(async () => DEFINITION);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

describe('objectstack#32760 — the report drill reads its dataset as the report\'s reader', () => {
it('READS_AS_THE_REPORTS_READER — a click on a date-grouped row reads the definition with callerPackage and drills', async () => {
renderReport(PACKAGE_REPORT, DATE_DIM_ANSWER);
fireEvent.click(await screen.findByTestId('dataset-drill-row'));
const target = await screen.findByTestId('drill-target');
expect(client.get).toHaveBeenCalledTimes(1);
expect(client.get).toHaveBeenCalledWith('dataset', 'task_metrics', { callerPackage: 'com.acme.app' });
expect(JSON.parse(target.textContent ?? '')).toEqual({ objectName: 'task', filter: { due_date: '2026-03-01' } });
});

it('ENVIRONMENT_REPORT_READS_BARE — a report authored in the environment names no package', async () => {
const { _packageId: _omit, ...environmentReport } = PACKAGE_REPORT;
renderReport(environmentReport, DATE_DIM_ANSWER);
fireEvent.click(await screen.findByTestId('dataset-drill-row'));
await screen.findByTestId('drill-target');
expect(client.get).toHaveBeenCalledWith('dataset', 'task_metrics', {});
});

it('REFUSAL_IS_NO_DRILL — a refused read opens nothing and is never retried bare', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
client.get = vi.fn(async () => {
throw Object.assign(new Error('held by 2 packages'), { status: 409, code: 'RESOURCE_CONFLICT' });
});
renderReport(PACKAGE_REPORT, DATE_DIM_ANSWER);
fireEvent.click(await screen.findByTestId('dataset-drill-row'));
await waitFor(() => expect(warn).toHaveBeenCalledWith('ReportView: drill failed', expect.any(Error)));
expect(client.get).toHaveBeenCalledTimes(1);
expect(client.get).toHaveBeenCalledWith('dataset', 'task_metrics', { callerPackage: 'com.acme.app' });
expect(screen.queryByTestId('drill-target')).toBeNull();
});

it('FAST_PATH_CONTROL — an answer with drill metadata drills with no definition read', async () => {
renderReport(
{ ...PACKAGE_REPORT, rows: ['status'] },
{
rows: [{ status: 'Open', task_count: 3 }],
fields: [{ name: 'status', label: 'Status' }, { name: 'task_count', label: 'Tasks' }],
object: 'task',
dimensionFields: { status: 'status' },
drillRawRows: [{ status: 'open' }],
drillScopes: { task_count: {} },
},
);
fireEvent.click(await screen.findByTestId('dataset-drill-row'));
const target = await screen.findByTestId('drill-target');
expect(JSON.parse(target.textContent ?? '')).toEqual({ objectName: 'task', filter: { status: 'open' } });
expect(client.get).not.toHaveBeenCalled();
});
});
22 changes: 17 additions & 5 deletions ui/packages/app-shell/src/views/ReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useAdapter } from '../providers/AdapterProvider.js';
import { useMetadataClient } from './metadata-admin/useMetadata.js';
import { persistRuntimeMetadata } from './runtime-metadata-persistence.js';
import { formatMetadataError } from '@object-ui/data-objectstack';
import { servedPackageId } from '@object-ui/core';
import { toast } from 'sonner';
import { useWorkspaceAdminStatus } from '@object-ui/auth';
import type { DataSource } from '@object-ui/types';
Expand Down Expand Up @@ -101,9 +102,15 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) {
// `objectFilter` (built from the server's drillRawRows), so we navigate
// straight away — correct for select/lookup dims, no metadata round-trip.
//
// Fallback (older server with no drill metadata): only dimension NAMES +
// DISPLAY-label bucket values arrive, so resolve them through the dataset
// definition and reverse-map labels to stored values before filtering:
// Fallback: no `objectFilter` arrived, so only dimension NAMES + DISPLAY-label
// bucket values are known. Not only an older server's answer: the current
// server names the `object` on every dataset answer but sends no drill
// metadata for a `type: 'date'` dimension with no `dateGranularity` (neither
// an equality field nor a range), so a report grouped by one lands here too.
// Resolve the names through the dataset definition, read as the report's
// reader (ADR-0150 D3: `callerPackage`, the report's served package, the
// same dataset the renderer's queries resolve; a refusal is no drill, never
// a bare retry), and reverse-map labels to stored values before filtering:
// - select fields → reverse-map label → option value
// - lookup fields → the label is a record name, not the FK id; skip the
// dim (the drill lands on a superset rather than filtering wrongly)
Expand All @@ -128,7 +135,12 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) {

// Fallback (older server with no drill metadata): resolve the dataset's
// object and reverse-map dimension labels → stored values ourselves.
const def = await metadataClient.get<Record<string, any>>('dataset', dataset);
const callerPackage = servedPackageId(editSchema || reportData);
const def = await metadataClient.get<Record<string, any>>(
'dataset',
dataset,
callerPackage ? { callerPackage } : {},
);
const objectName = typeof def?.object === 'string' ? def.object : undefined;
if (!objectName) return;
const dims: Array<Record<string, any>> = Array.isArray(def?.dimensions) ? def.dimensions : [];
Expand Down Expand Up @@ -165,7 +177,7 @@ export function ReportView({ dataSource }: { dataSource?: DataSource }) {
console.warn('ReportView: drill failed', err);
}
},
[metadataClient, objects, reportData, language],
[metadataClient, objects, reportData, editSchema, language],
);

// Derive available fields from object schema for filter/sort editors
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack-ai/objectstack#32760 — `get()` carries the by-name read's caller
* arm (ADR-0150 D3): `callerPackage`, the READER's package, sent as
* `?callerPackage=`, distinct from `packageId`, the holder (`?package=`, D5).
*
* ## What is pinned
*
* - given a caller package -> `?callerPackage=VALUE`, encoded, beside the
* draft-preview overlay the client adds on its own;
* - none, or an empty one -> the parameter is ABSENT, never sent empty (the
* door refuses a blank caller, and an item authored in the environment
* reads the name bare);
* - it is never spelled as `?package=`: that is the exact qualifier, which
* refuses a dataset only a dependency of the reader holds.
*/

import { describe, it, expect, vi } from 'vitest';
import { MetadataClient } from './metadata-client';

const BASE = 'http://localhost:3000/api/v1/meta';

function record(previewDrafts = false) {
const seen: string[] = [];
const client = new MetadataClient({
baseUrl: 'http://localhost:3000',
previewDrafts,
fetch: vi.fn(async (url: string) => {
seen.push(String(url));
const body = { type: 'dataset', name: 'task_metrics', item: { name: 'task_metrics', object: 'task' }, success: true };
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
}) as unknown as typeof fetch,
});
return { seen, client };
}

describe('MetadataClient.get — the caller arm, `?callerPackage=` (objectstack#32760)', () => {
it('sends the reader\'s package as ?callerPackage=, never as ?package=', async () => {
const { seen, client } = record();
const item = await client.get('dataset', 'task_metrics', { callerPackage: 'com.acme.app' });

expect(seen).toEqual([`${BASE}/dataset/task_metrics?callerPackage=com.acme.app`]);
expect(item).toEqual({ name: 'task_metrics', object: 'task' });
});

it('encodes the caller package and keeps the draft-preview overlay beside it', async () => {
const { seen, client } = record(true);
await client.get('dataset', 'task_metrics', { callerPackage: 'com.acme/app' });

expect(seen).toEqual([`${BASE}/dataset/task_metrics?preview=draft&callerPackage=com.acme%2Fapp`]);
});

it('CONTROL: no caller package, or an empty one, reads the name bare', async () => {
const { seen, client } = record();
await client.get('dataset', 'task_metrics');
await client.get('dataset', 'task_metrics', { callerPackage: '' });

expect(seen).toEqual([`${BASE}/dataset/task_metrics`, `${BASE}/dataset/task_metrics`]);
});
});
13 changes: 13 additions & 0 deletions ui/packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,18 @@ export interface MetadataGetOptions {
* blank selector, and any selector on a type with no members.
*/
member?: string;
/**
* The READER's package (ADR-0150 D3), sent as `?callerPackage=`: the package
* of the item that reads this one (a report, a dashboard, a list view's
* chart). The door resolves the name from it: that package's own item, else
* the one holder in its declared dependency closure plus `sys`, else it
* refuses `409 RESOURCE_CONFLICT` naming the candidates. Distinct from
* {@link packageId}, which names the HOLDER exactly (D5); the door refuses
* the two together, and serves this arm on the `dataset` read only.
* OMITTED, never sent empty, when the reader has no package (an item
* authored in the environment reads the name bare).
*/
callerPackage?: string;
}

// `surface` is a SAVE input (`?surface=` on `PUT`); the delete door reads no
Expand Down Expand Up @@ -1248,6 +1260,7 @@ export class MetadataClient {
else if (this.previewDrafts) params.push('preview=draft');
if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`);
if (options.member) params.push(`member=${encodeURIComponent(options.member)}`);
if (options.callerPackage) params.push(`callerPackage=${encodeURIComponent(options.callerPackage)}`);
const qs = params.length ? `?${params.join('&')}` : '';
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`;
return this.shareRead('readItemResponse', url, async () => {
Expand Down
Loading
Loading