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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(components,plugin-detail,plugin-view): read find() answers as QueryResult declares them — remove the seven surviving `records` arms by os-sales · Pull Request #6841 · objectstack-ai/objectui · GitHub
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
63 changes: 63 additions & 0 deletions .changeset/6726-find-envelope-records-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-view': minor
---

Seven more `find()` readers now read exactly what `QueryResult` declares — the
`records` arm is removed from each (objectui#6726, following objectui#5945).

`QueryResult` (`@object-ui/types`) declares exactly one rows member — `data` —
alongside `total`, `page`, `pageSize`, `hasMore`, `cursor` and `metadata`.
`records` is not a member of it. It is the spelling the server envelope and the
client SDK use, which `ObjectStackAdapter.normalizeQueryResult` maps to `data`
before returning — a *below*-the-adapter spelling that had leaked into
above-the-adapter consumers. objectui#5945 removed it from two app-shell
readers; these are the seven the same producer sweep turned up and that card did
not name:

| module | what it does |
| --- | --- |
| `components/src/hooks/related-count-store.ts` | related-list tab badge count |
| `components/src/renderers/basic/data-list.tsx` | `element:repeater` rows |
| `components/src/renderers/basic/elements.tsx` | `element:number` client-side aggregate |
| `components/src/renderers/basic/record-picker.tsx` | `element:record_picker` options |
| `plugin-detail/src/renderers/record-activity.tsx` | `record:activity` self-fetch |
| `plugin-detail/src/renderers/record-history.tsx` | `record:history` self-fetch |
| `plugin-view/src/ObjectView.tsx` | non-grid (kanban / calendar / gallery / timeline) fetch |

**One of them was actively wrong, six were dead.** `related-count-store.ts`
read `records` *ahead of* `data` — the precedence inversion objectui#5945 was
filed about — so a `find()` answer carrying both would have been counted from
the key the contract does not declare. The other six read `data` first, so their
`records` arm could never be reached by a conforming producer. A dead tolerant
arm is not harmless: it is where a non-conforming producer keeps working
unrejected, and hardens into a second de-facto contract nobody is checking
(AGENTS.md #0.1).

**What stops being accepted.** A `find()` answer shaped `{ records: [...] }`
now reads as **no rows** at these seams instead of silently resolving. Every
call site degrades rather than throws: the tab badge counts 0, the repeater and
the picker render their empty state, `element:number` reports 0, the activity
and history feeds render empty, and the non-grid views paint no rows.

**Nothing produces that shape at this seam today**, which is why this is a
removal rather than a migration. Measured repo-wide over every tracked file:
`ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
`find()` implementation in the repo (`ApiDataSource`, `ValueDataSource`, the
runner and example mocks, the `@object-ui/types` REST example) returns `data`
or a bare array. The `records` producers that DO exist are on other seams and
are untouched: `ViewDataProvider`'s own `ResolvedData` interface, which declares
`records` legitimately; the raw Cloud HTTP payloads `marketplaceApi.ts` and
`packagedActions.ts` read; and the client-SDK doubles that sit *below*
`normalizeQueryResult`.

**The bare-array arm is kept** wherever it existed, because it is live: fakes at
these seams answer with a plain array. Each module carries its own pin —
`*.contractEnvelope-6726.*` — asserting the contract read, the live arms, and
the refusal of `records`, so the live and the dead shapes cannot drift into each
other.

`QueryResult` is **not** widened to bless `records`; that would be a
published-type change and a maintainer decision.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
/**
* 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.
*/

/**
* `RelatedCountStore` reads a `find()` answer as `QueryResult` DECLARES it —
* and does NOT read `records` (objectui#6726, following objectui#5945).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* Before this pin the store's row-count fallback read
*
* Array.isArray(res?.records) ? res.records.length
* : Array.isArray(res?.data) ? res.data.length : ...
*
* — `records` FIRST, ahead of the contract's `data`. That is the same
* precedence inversion objectui#5945 was filed about, and this module is where
* it actually decides a rendered number: the tab-strip badge on a record detail
* ("Contacts (12)").
*
* MEASURED on this tree, no producer emits `records` at the `DataSource.find()`
* seam this store's `probe` is bound to (`containers.tsx` hands it
* `(object, query) => ds.find(object, query)`):
* `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK `records`
* envelope and returns `{ data, total, page, pageSize, hasMore }`; every other
* `find()` implementation in the repo returns `data` too. `ProbeFn` itself
* never declared `records` either — only the `res: any` cast let it through.
*
* The two live arms are pinned here as well, because live and dead is the whole
* distinction: `total` (what `$count: true` asks the server for) and the bare
* array (what fakes at this seam really answer with).
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RelatedCountStore } from '../hooks/related-count-store';

/** Three rows, wrapped by whichever envelope the case is measuring. */
const ROWS = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];

beforeEach(() => {
RelatedCountStore._reset();
});

describe('RelatedCountStore — the find() envelope it counts from (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const probe = vi.fn(async () => ({ data: ROWS }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('still prefers the server-side `total` — the reason `$count: true` is sent', async () => {
const probe = vi.fn(async () => ({ total: 42, data: [{ id: 'c1' }] }));
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(42);
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const probe = vi.fn(async () => ROWS);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(3);
});

it('does NOT count `records` — not a QueryResult member', async () => {
const probe = vi.fn(async () => ({ records: ROWS }) as never);
// Before the fix this returned 3. The badge now reports the honest "no
// countable answer" 0 rather than legitimising a second de-facto contract.
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(0);
});

it('does NOT let `records` OUTRANK `data` — the precedence inversion itself', async () => {
// The sharp end of objectui#5945: both members present and disagreeing.
// `data` is the contract's, so 1 is the only correct answer; the pre-fix
// order answered 3.
const probe = vi.fn(async () => ({ records: ROWS, data: [{ id: 'only' }] }) as never);
expect(await RelatedCountStore.fetch(probe, 'contact', 'account_id', 'A1')).toBe(1);
});
});
25 changes: 17 additions & 8 deletions packages/components/src/hooks/related-count-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,21 +99,30 @@ async function fetchCount(
try {
// Request the server-side count instead of relying on the page length.
// Without `$count: true` most adapters omit `total`, and we'd fall
// back to `records.length` which is capped to `$top: 1` → badge
// back to `data.length` which is capped to `$top: 1` → badge
// shows "1" no matter how many rows exist.
//
// The rows fallback reads `data` — the ONE rows member `QueryResult`
// (`@object-ui/types`) declares — and nothing else. It used to try
// `records` FIRST, ahead of `data`: a spelling no producer emits at the
// `DataSource.find()` seam, because `ObjectStackAdapter.normalizeQueryResult`
// maps the server/SDK `records` envelope to `data` before returning
// (objectui#5945, objectui#6726). `ProbeFn` above never declared it
// either — only the `any` here let it through. Pinned by
// `related-count-store.contractEnvelope-6726.test.ts`; ⛔ do not
// re-add a tolerant arm, and ⛔ do not widen `QueryResult` to bless
// `records` (a published-type change, maintainer's call).
const res: any = await probe(objectName, { $filter, $top: 1, $count: true });
const total =
typeof res?.total === 'number'
? res.total
: typeof res?.count === 'number'
? res.count
: Array.isArray(res?.records)
? res.records.length
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
: Array.isArray(res?.data)
? res.data.length
: Array.isArray(res)
? res.length
: 0;
const n = typeof total === 'number' ? total : 0;
setCount(objectName, relField, parentId, n);
return n;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
/**
* 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.
*/

/**
* `element:repeater` (`data-list.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* `QueryResult` (`@object-ui/types`) declares exactly one rows member: `data`.
* The renderer's read was `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])`
* — `records` sat between the contract's member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`, and every other `find()` in the repo
* returns `data` (or a bare array). So the arm was dead — but a dead arm still
* costs the contract its authority: a raw SDK client handed in where a
* `DataSource` belongs would have kept working, unnoticed and unrejected
* (AGENTS.md #0.1).
*
* Both live arms are pinned alongside it. That is what makes the `records` zero
* a reading rather than a broken harness: the `data` leg and the bare-array leg
* answer with the SAME rows through the SAME mount, so a leg that renders
* nothing rendered nothing because the envelope was refused.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx } from '@object-ui/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1', name: 'Ada' }, { id: 'r2', name: 'Grace' }];

/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:repeater',
id: 'rep',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', fields: ['name'] },
} as never
}
/>
</AdapterCtx.Provider>,
);
return adapter;
}

/** The rows the block actually painted. */
const painted = () =>
Array.from(screen.queryByTestId('repeater')?.querySelectorAll('li') ?? []).map(
(li) => li.textContent ?? '',
);

describe('element:repeater — the find() envelope it reads (objectui#6726)', () => {
it("reads the contract's `data` member", async () => {
const adapter = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const adapter = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId('repeater')).toBeTruthy());
expect(painted().join('|')).toContain('Ada');
});

it('does NOT read `records` — not a QueryResult member', async () => {
const adapter = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// The empty-state paragraph, not the list: before the fix this painted the
// two rows above.
await waitFor(() => expect(screen.getByText('No records')).toBeTruthy());
expect(screen.queryByTestId('repeater')).toBeNull();
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
/**
* 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.
*/

/**
* `element:number` (`elements.tsx`) reads a `find()` answer as `QueryResult`
* DECLARES it — and does NOT read `records` (objectui#6726).
*
* This is the block's client-side aggregate fallback: reached when the adapter
* has no `aggregate()`, it pulls rows through `find()` and counts/sums them
* locally. Its read was
* `res?.data ?? res?.records ?? (Array.isArray(res) ? res : [])` — `records`
* between the contract's one rows member and the bare-array arm.
*
* MEASURED on this tree, no producer emits `records` at this `DataSource.find()`
* seam: `ObjectStackAdapter.normalizeQueryResult` CONSUMES the server/SDK
* `records` envelope and returns `data`; every other `find()` in the repo
* returns `data` or a bare array.
*
* What makes the `records` zero a reading and not a broken harness: the `data`
* leg and the bare-array leg push the SAME three rows through the SAME mount
* and both paint `3`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import * as React from 'react';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers every `element:*` renderer at module scope, not in a hook
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../../../renderers';

afterEach(cleanup);

const ROWS = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }];

type Envelope = (rows: unknown[]) => unknown;

const asData: Envelope = (rows) => ({ data: rows, total: rows.length });
const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });

/**
* No `aggregate()` on this adapter ON PURPOSE — that is the branch under test.
* An adapter carrying one would answer from the server and never reach the
* envelope read at all.
*/
function mount(envelope: Envelope) {
const adapter = { find: vi.fn(async () => envelope(ROWS)) };
const view = render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer
schema={
{
type: 'element:number',
id: 'metric',
// Element config lives in the `properties` bag (`readProps`), not
// on the node — the same door an authored page writes through.
properties: { object: 'contact', aggregate: 'count' },
} as never
}
/>
</AdapterCtx.Provider>,
);
return { adapter, view };
}

/** The number the block painted (the '…' placeholder while loading). */
const painted = (view: ReturnType<typeof render>) =>
view.container.querySelector('.tabular-nums')?.textContent ?? '';

describe('element:number — the find() envelope its client-side count reads (objectui#6726)', () => {
it("counts the contract's `data` member", async () => {
const { adapter, view } = mount(asData);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('still counts a bare array — the live non-envelope shape fakes answer with', async () => {
const { adapter, view } = mount(asBareArray);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await waitFor(() => expect(painted(view)).toBe('3'));
});

it('does NOT count `records` — not a QueryResult member', async () => {
const { adapter, view } = mount(asRecords);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
// Zero countable rows, not three: before the fix this metric painted `3`
// off a key the contract does not declare.
await waitFor(() => expect(painted(view)).toBe('0'));
});
});
Loading
Loading