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
55 changes: 55 additions & 0 deletions .changeset/6917-queryresult-value-count-arms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/app-shell': minor
'@object-ui/console': minor
---

Read `count` / `value` answers as the contract declares them at six more seams
(objectui#6917, following objectui#5945 / #6726 / #6840 / #6839).

**One precedence inversion, repaired without deleting the arm.**
`@object-ui/fields`' lookup chip resolved fetch-on-demand rows with
`result?.value || result?.data || []` — `value` AHEAD of `data`, the one rows
member `QueryResult` (`@object-ui/types`) declares. A producer emitting both was
resolved to the undeclared key. It now reads through `@object-ui/core`'s
`extractRecords`, whose accepted set is identical (bare array, `data`, `value`)
and whose order is the contract's. The `value` arm is **kept**: its own producer
census measured eight live `find()` doubles emitting `{ value: [...] }` at this
seam (3 plugin-kanban, 3 plugin-calendar, 2 plugin-grid), so deleting it would
break them. Only the RANK was wrong.

**Five dead arms deleted, each on its own measured zero.** Every module got its
own census with the control sitting on the producer→consumer join, because
objectui#6840's zero is seam-local and is not transferable — the same sweep read
0 producers for `value` at one seam and 5 at another in a single pass.

- `count` at the `DataSource.find()` seam — `plugin-detail`'s reference rail and
`plugin-list`'s ListView. 0 of 592 `find()` producers emit `count`; controls
`data` (312) and `total` (150) lit on the same pass. Both adapters'
`normalizeQueryResult` already fold `count` into `total` below every consumer.
- `value` at the `client.meta.getItems()` seam — `app-shell`'s help menu and the
console's Public Forms and Flow Runs pages. **These three do not sit on the
`DataSource.find()` seam at all**, so they were measured on their own join: 0
of 28 `meta.getItems` producers emit `value`; control `items` (18) lit. The
canonical readers of that envelope (`MetadataProvider.extractItems`,
`MetadataService.getItems`) have never had a `value` arm either.

No producer changes behaviour, because at these five sites there is no producer;
what changes is that a non-conforming one is refused rather than silently
absorbed (AGENTS.md #0.1).

`QueryResult` is **not** widened to bless `count` or `value` — a published-type
change and the maintainer's call, the floor objectui#6726, #6840 and #6839 all
held. A producer that really speaks either belongs behind an adapter that folds
it, which is what both adapters already do.

One refusal pin per module, each keeping the live arms green beside the deleted
one, and — where an inversion actually existed — a case feeding both members with
different contents, the only input that can tell the two orders apart.

Also repaired: two `plugin-grid` test doubles answered `{ value: [],
'@odata.count': 0 }` while `ObjectGrid` reads `result.data` / `result.total`.
Inert only while the arrays were empty; the first row put in one would have been
silently dropped. Test-only.
7 changes: 4 additions & 3 deletions apps/console/src/pages/developer/FlowRunsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,14 @@ export function FlowRunsPage() {
setFlowsError(null);
try {
const result: any = await client.meta.getItems('flow');
// `meta.getItems` answers `{ type, items: [...] }`, or a bare array on
// the ADR-0037 preview path. The trailing `value` arm is gone on a
// measured zero at this seam (objectui#6917).
const items: any[] = Array.isArray(result)
? result
: Array.isArray(result?.items)
? result.items
: Array.isArray(result?.value)
? result.value
: [];
: [];
const normalized: FlowItem[] = items.map(it => ({
name: it?.spec?.name ?? it?.name,
label: it?.spec?.label,
Expand Down
7 changes: 4 additions & 3 deletions apps/console/src/pages/developer/PublicFormsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,14 @@ export function PublicFormsPage() {
setError(null);
try {
const result: any = await client.meta.getItems('view');
// `meta.getItems` answers `{ type, items: [...] }`, or a bare array on
// the ADR-0037 preview path. The trailing `value` arm is gone on a
// measured zero at this seam (objectui#6917).
const items: any[] = Array.isArray(result)
? result
: Array.isArray(result?.items)
? result.items
: Array.isArray(result?.value)
? result.value
: [];
: [];
const forms: PublicFormRow[] = [];
const candidates: PublishableFormRow[] = [];
for (const it of items) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The two developer pages read a `client.meta.getItems()` answer as the
* metadata API DECLARES it — and do NOT read `value` (objectui#6917 arm B).
*
* ── The seam is NOT `DataSource.find()`, and that changed the measurement ──
* objectui#6917 filed these two sites as "the same arm objectui#6840 removed",
* i.e. a non-`QueryResult` key at the `DataSource.find()` seam. They are not.
* `QueryResult` (`@object-ui/types`) never reaches here: these pages call
* `client.meta.getItems(type)`, whose envelope is `{ type, items: [...] }`, or
* a bare array on the ADR-0037 preview path. `data` is not one of its members
* either — `MetadataProvider.extractItems` pins `{ data: [...] }` to `[]`.
*
* That is objectui#6917's own central rule turned on the card: a zero measured
* at one seam says nothing about another. So the census below was run on the
* `meta.getItems` JOIN, not on the `find()` join the card named:
*
* CELL every `meta.getItems` producer body in the repo .... 28 producers
* CONTROL `items` emitted as an envelope member ............. 18 producers
* SUBJECT `value` emitted as an envelope member ............ 0 producers
*
* Superset sweep, so a shape assembled outside a producer body still surfaces:
* of the 25 files holding a producer, 6 contain the token `value:` anywhere,
* and all 6 are filter-condition values, select options, a DOM helper
* parameter or a storage shim — none an envelope member. The control sits on
* the JOIN (same cell, same pass, same extraction), so the zero is a reading.
*
* The two canonical in-repo readers of this envelope agree and neither has ever
* had a `value` arm: `MetadataProvider.extractItems` (bare array | `items`) and
* `MetadataService.getItems` (`items` only).
*
* ⛔ The fix is the deletion, NOT widening any published type to bless `value`
* — the floor objectui#6726, #6840 and #6839 all held.
*
* The live arms are pinned beside the dead one, because live-versus-dead is the
* whole distinction. No precedence case appears here: `value` sat LAST, behind
* both live arms, so no ordering was ever observable at these two sites. (The
* one site on this card that DID invert is `packages/fields`, pinned there.)
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';

/**
* One mutable answer, read by BOTH pages' `meta.getItems`. A stable singleton
* adapter: a fresh object per render loops the pages' load effects.
*/
const { ADAPTER, state } = vi.hoisted(() => {
const state: { answer: unknown } = { answer: [] };
const ADAPTER = {
getClient: () => ({
meta: {
getItems: async () => state.answer,
saveItem: vi.fn(async () => ({ ok: true })),
},
automation: { execute: vi.fn(), listRuns: async () => ({ runs: [] }) },
}),
};
return { ADAPTER, state };
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
useMetadata: () => ({ objects: [] }),
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

// Imported AFTER the mocks so the pages pick them up.
import { FlowRunsPage } from './FlowRunsPage';
import { PublicFormsPage } from './PublicFormsPage';

/** A flow definition, as `meta.getItems('flow')` rows carry it. */
const FLOW = {
spec: {
name: 'reassign_wizard',
label: 'Reassign',
variables: [{ name: 'recordId', type: 'text', isInput: true }],
},
};

/** A published public form — the page lists a row only when both
* `sharing.allowAnonymous` and a parseable `publicLink` are present. */
const FORM = {
spec: {
name: 'showcase_task.public',
label: 'Log Time',
object: 'showcase_task',
type: 'simple',
sections: [{ label: 'Task', fields: ['title'] }],
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/log-time' },
},
};

beforeEach(() => {
state.answer = [];
});
afterEach(cleanup);

describe('FlowRunsPage — meta.getItems envelope (objectui#6917)', () => {
it("still reads the envelope's `items` member", async () => {
state.answer = { items: [FLOW] };
render(<FlowRunsPage />);
await waitFor(() => {
expect(screen.getByText('Reassign (reassign_wizard)')).toBeInTheDocument();
});
});

it('still reads a bare array — the ADR-0037 preview path answers with one', async () => {
state.answer = [FLOW];
render(<FlowRunsPage />);
await waitFor(() => {
expect(screen.getByText('Reassign (reassign_wizard)')).toBeInTheDocument();
});
});

it('does NOT read `value` — not a member of this envelope', async () => {
// Before the fix this listed the flow. The page now reports the honest
// "nothing here" rather than legitimising a second de-facto contract.
state.answer = { value: [FLOW] };
render(<FlowRunsPage />);
await waitFor(() => {
expect(screen.getByText('No flow definitions found.')).toBeInTheDocument();
});
expect(screen.queryByText('Reassign (reassign_wizard)')).not.toBeInTheDocument();
});

it('does NOT read `data` either — `extractItems` pins that to empty as well', async () => {
// The caricature guard: a reader that returned the first array it could
// find in the envelope, whatever the key, would list the flow here.
state.answer = { data: [FLOW] };
render(<FlowRunsPage />);
await waitFor(() => {
expect(screen.getByText('No flow definitions found.')).toBeInTheDocument();
});
});
});

describe('PublicFormsPage — meta.getItems envelope (objectui#6917)', () => {
it("still reads the envelope's `items` member", async () => {
state.answer = { items: [FORM] };
render(<PublicFormsPage />);
await waitFor(() => {
expect(screen.getByText('Log Time')).toBeInTheDocument();
});
});

it('still reads a bare array', async () => {
state.answer = [FORM];
render(<PublicFormsPage />);
await waitFor(() => {
expect(screen.getByText('Log Time')).toBeInTheDocument();
});
});

it('does NOT read `value` — not a member of this envelope', async () => {
state.answer = { value: [FORM] };
render(<PublicFormsPage />);
await waitFor(() => {
expect(screen.getByText('No public forms yet')).toBeInTheDocument();
});
expect(screen.queryByText('Log Time')).not.toBeInTheDocument();
});
});
9 changes: 6 additions & 3 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,16 @@ export function AppHeader({
return;
}
const result: any = await client.meta.getItems('doc');
// `meta.getItems` answers the `{ type, items: [...] }` envelope (or a
// bare array from the ADR-0037 preview path) — the two shapes
// `MetadataProvider`'s `extractItems` accepts. A `value` arm used to
// trail them; measured zero producers emit `value` at THIS seam
// (objectui#6917), so it is gone (AGENTS.md #0.1).
const items: any[] = Array.isArray(result)
? result
: Array.isArray(result?.items)
? result.items
: Array.isArray(result?.value)
? result.value
: [];
: [];
setHelpDocs(
items
.map((it) => ({ name: it?.name, label: it?.label, _packageId: it?._packageId }))
Expand Down
Loading
Loading