Skip to content

Commit 68e26c4

Browse files
fix(data-objectstack): a plain list read of a missing object shows the server's 404 code and sentence, not an empty list (#29839)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb1YbxypyJLh3aLs7r71Q2kq)_ Fixes #29782 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:对象列表的普通读取(没有展开查找字段、也没有搜索)如果被服务器回复 404,而这个对象不在平台对象清单里,现在会把错误抛给页面,页面显示服务器的原句和错误码(如 `OBJECT_NOT_FOUND`),不再显示「这里还没有数据」。平台自带的可选集合(如 `sys_presence`、`sys_activity`)在未安装时仍然安静地当作空集,并且只请求一次。 - 为什么改:对象注册失败时(#29673),服务器已经明确说了「对象不存在」,列表却说「这里还没有数据」,让搭建者以为对象只是空的。#29704 只修好了带展开或搜索的读取路径,普通路径仍然把 404 吞掉并记住,之后每次都直接返回空。 - 风险与回滚:影响面比单个列表宽。凡是读取「自己那个对象」的界面,在对象缺失时都会从空状态变成加载失败状态:列表、看板、日历、甘特、画廊、时间线、地图、图表、仪表板组件、相关列表及其计数、查找与收件人选择器、报表等。仪表板指标在对象缺失时不再显示 0,而是显示错误。平台可选集合的行为不变。只改界面适配器,不改接口和元数据协议。回滚只需还原本 PR。 - 先看:`ui/packages/data-objectstack/src/index.ts` 中 `find` 的 404 分支(新增 `isPlatformProvidedObjectName(resource)` 条件),以及新测试 `ui/packages/data-objectstack/src/findMissingObject-29782.test.ts`。 - 两分钟验证:中文界面下打开「功能展示」应用的「客户」列表,用浏览器开发者工具把 `/api/v1/data/showcase_account` 的请求改为返回 404 `{"success":false,"error":{"code":"OBJECT_NOT_FOUND","message":"Object 'showcase_account' is not registered"}}`,应看到「无法加载记录」、服务器原句、`OBJECT_NOT_FOUND` 和「重试」,而不是「这里还没有数据」。 - 需要你做的:无,席位落地。 ## What changes - `ObjectStackAdapter.find`, plain SDK path: a 404 that is not an `enable`-block denial now degrades to `{ data: [], total: 0 }` and enters the `missingResources` memo **only** when `isPlatformProvidedObjectName(resource)` (`@objectstack/spec/system`, the curated platform registry) is true. Any other object's 404 is rethrown as the client threw it (`httpStatus`, `code`, message) and is not memoised, so the next read asks the server again. - The error code cannot make this split: an uninstalled optional collection such as `sys_presence` and a list's own unregistered object both answer 404 `OBJECT_NOT_FOUND`. The split is by name, against the registry, never a prefix guess. The short-circuit at the top of `find` can now only ever hold platform names. - `enable`-block denials (`OBJECT_API_DISABLED`, `OBJECT_API_METHOD_NOT_ALLOWED`) are rethrown and unmemoised as before. The raw `$expand` / `$search` path is unchanged. - No surface code changes: the list already renders an answered failure with the server's sentence and code since #29704 (`answeredLoadFailure`). ## Why The memo exists for optional platform collections, whose absence means "this deployment does not install the feature". It also swallowed the 404 of a list's own object, so an object that failed to register (#29673) read as 这里还没有数据, and only the list's expand read reached the error panel. **Caller census (ui src at the base, tests excluded).** Every literal or constant `find` target in `ui/` is in the registry (`sys_activity`, `sys_presence`, `sys_comment`, `sys_comment_reaction`, `sys_audit_log`, `sys_attachment`, `sys_user`, `sys_user_position`, `sys_user_permission_set`, `sys_user_preference`, `sys_member`, `sys_personal_view`, `sys_approval_action`, `sys_notification_receipt`, `sys_inbox_message`, `sys_metadata`, `sys_environment`). The non-registry `sys_` literals (`sys_favorite`, `sys_claim`, `sys_view`, `sys_dashboard`, …) are action names or update targets, never `find` targets. Every non-platform caller that relied on the empty answer reads its own surface's object, and now shows its existing load-failure state on a missing object. ## Tests - New `ui/packages/data-objectstack/src/findMissingObject-29782.test.ts`: - a plain `find` on a non-registry object answered 404 `OBJECT_NOT_FOUND` rejects with that code, `httpStatus` 404 and the server sentence; - a second `find` issues a second request (no memo); - a bare 404 with no code on a non-registry object is rethrown too; - over the real `@objectstack/client`: the wrapped `{ success: false, error: { code, message } }` 404 reaches the caller as `code` + `httpStatus`, twice, two requests; - CONTROL: a registry name (`sys_presence`) answered 404 `OBJECT_NOT_FOUND` answers empty and the second call is short-circuited (one request); - CONTROL: the memo is per resource; - CONTROL: an `enable`-block denial still rethrows, on a platform name and on any other, and is not memoised; - CONTROL: a 2xx with zero rows on a non-platform object is still an ordinary empty result. - `ui/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx` (the shared "real `ObjectStackAdapter` feeding the real `ListView`" harness): a new top-level `describe` for #29782. A list over `crm_invoice` answered 404 `OBJECT_NOT_FOUND` shows the server sentence and the `list-error-code` chip, with no empty state and no network sentence, and every data read it made took the plain path. The harness's `makeAdapter` keeps a handle on its fetch stub (`ds.__fetch`) for that last check. Its existing bare-404 control on `sys_jwks` stays green, since `sys_jwks` is a registry name. **Moved pin** - `ui/packages/data-objectstack/src/apiAccessDenied.test.ts`, "CONTROL: a 404 RECORD_NOT_FOUND is not an enable-block denial": its resource moves from `account` to `sys_comment`. Its point is that `RECORD_NOT_FOUND` is not an enable-block denial, so it still takes the optional-collection branch. On `account`, a non-registry object, the 404 is now rethrown by design. That case is pinned in the new file. **Ablation (one-shot, not kept):** `scripts/ablation-replace.mjs` deleted the `&& isPlatformProvidedObjectName(resource)` line (anchor 1 to 0, blob `f82eb0b7` to `8dd44be3`). The new adapter file and the new ListView describe went red: 6 tests failed, 8 passed, including "REJECTS a plain find answered 404 OBJECT_NOT_FOUND…". The file was restored to the HEAD blob `f82eb0b7`, and `git diff HEAD` came back empty. The ui vitest aliases `@object-ui/data-objectstack` to `src`, so no rebuild leg was owed. ## Acceptance notes - The analytics fallback `aggregateViaFind` reads through `find`, so a dashboard figure over a missing object now fails instead of reading 0. This is the intended direction, and it is named in 风险与回滚. - The live run saw no `sys_presence` read on the screens it visited (`RecordDetailView`'s comment records that its presence probe was retired). So the shell's boot-time plain reads of `sys_activity` and `sys_inbox_message` served as the live optional-collection control. ## Verification All readings below were taken at head `6834710a` (this branch merged with `origin/main` b0ef25f) unless a different head is named. <details><summary>Unit and consumer tests (locked, maxWorkers=2)</summary> - `packages/data-objectstack/` (114 files), the plugin-list `ListView.loadErrorKind`, and the 22 test files outside the package that build a real `ObjectStackAdapter` and answer 404 (including `objectListApiDisabled-4408`): **137 files, 1530 tests passed** at `6834710a`. - Before the merge of main, at `a9f52f3e`: the four touched files gave 50/50, and the same consumer set gave 135/135. - `pnpm exec turbo run type-check --filter=@object-ui/data-objectstack --filter=@object-ui/app-shell`: 66/66 tasks. data-objectstack's program includes its tests, and app-shell runs `tsconfig.test.json`. - `eslint` on the four touched ui files: 0 errors. </details> <details><summary>Derived gate union (dispatch-gates --commands, 36 families)</summary> - At `6834710a`: every one of the 36 exited 0, including `node scripts/check-sdui-manifest.mjs` (and `--self-test`), `pnpm --dir ui exec vitest run scripts/` (170 files passed, 2 skipped), `pnpm check:undeclared-dep-imports`, `pnpm check:cross-package-test-inputs`, `pnpm check:test-source-alias` and `pnpm check:query-options-erasure`. `dispatch-gates --ran`: 36 derived, 36 run, 0 NOT-MEASURED. - The same 36 also exited 0 at `a9f52f3e`, before the merge. - Tier: no path-derived mandate. Changed lines are under the 3000 threshold. </details> <details><summary>Live run (showcase backend + Console Vite, zh-CN, Playwright)</summary> - Script: `node shots.cjs before|after` against `pnpm dev -- --fresh` and the Console Vite proxied to it. Every `/api/v1/data/showcase_account` read is fulfilled with 404 `{ success: false, error: { code: 'OBJECT_NOT_FOUND', message: "Object 'showcase_account' is not registered" } }`. This list reads only on the plain path: `?top=0` and `?top=50&select=…`, with 0 raw reads. - **Before** (adapter swapped to the `origin/main` copy, blob `7ddcc01a`): no error state. The list shows **这里还没有数据** and a `· 0` count. - **After** (this branch): the error state reads **无法加载记录 · Object 'showcase_account' is not registered · `OBJECT_NOT_FOUND` · 重试**, with no 这里还没有数据. It was re-probed after the source was restored (blob back to HEAD `f82eb0b7`), with the same reading. - **CONTROL** (both legs): `sys_activity`, `sys_inbox_message` and `sys_notification_receipt` answer 404 `OBJECT_NOT_FOUND`. After boot and four SPA navigations (home, contacts, home, products, home), the Console made exactly **1** request each for `sys_activity` and `sys_inbox_message` (`sys_notification_receipt` was not read on this path), and showed **0** error panels or alerts. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset (`.changeset/29782-missing-object-list-read.md`, patch), and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a, not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the pm-dispatch house section). --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUqz12Aiuvrkaw5SJa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1dc6029 commit 68e26c4

5 files changed

Lines changed: 258 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
---
4+
5+
fix(data-objectstack): a plain list read of an object the server does not know rejects with its 404 code and sentence instead of reading as an empty list
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`ObjectStackAdapter.find` on its plain path (no `$expand`, no `$search`) used to turn every 404 into `{ data: [], total: 0 }` and remember the resource, so every later read of it answered empty without asking the server. That memo exists for optional platform collections such as `sys_presence` and `sys_activity`, whose absence means the deployment does not install the feature. It also swallowed the 404 `OBJECT_NOT_FOUND` of a list's own object, so an object that failed to register read as "no data yet".
11+
12+
The empty answer and the memo now apply only to names in the platform registry (`isPlatformProvidedObjectName` from `@objectstack/spec`). A 404 on any other object is rethrown with its code, status and message, and is not remembered, so lists, boards, widgets and pickers over a missing object show their load-failure state. A dashboard figure over a missing object no longer reads 0. Enable-block denials (`OBJECT_API_DISABLED`, `OBJECT_API_METHOD_NOT_ALLOWED`) are rethrown as before.

‎ui/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function makeAdapter(status: number, body: unknown) {
6666
);
6767
});
6868
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
69+
ds.__fetch = fetchImpl;
6970
return ds;
7071
}
7172

@@ -154,3 +155,53 @@ describe('object list · an OBJECT_API_DISABLED 404 is not an empty list (#4408)
154155
expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull();
155156
});
156157
});
158+
159+
/**
160+
* objectstack#29782 — the same masking one code over, on the list's plain
161+
* read path. An object that failed to register answers 404
162+
* `OBJECT_NOT_FOUND`. The adapter's plain `find()` (no `$expand`, no
163+
* `$search`) degraded that to zero rows and memoised it, so the list said
164+
* "no data yet" about an object that does not exist, and #29704's panel
165+
* (the server's sentence and code) was reachable only on the raw path. The
166+
* empty answer now belongs to platform collections alone
167+
* (`isPlatformProvidedObjectName`); `sys_jwks` above is one, which is why the
168+
* bare-404 control there still reads empty.
169+
*/
170+
describe('object list · a 404 OBJECT_NOT_FOUND on the plain read path is not an empty list (objectstack#29782)', () => {
171+
const missingSchema: ListViewSchema = {
172+
type: 'list-view',
173+
objectName: 'crm_invoice',
174+
fields: ['name'],
175+
} as ListViewSchema;
176+
177+
it("shows the server's sentence and code, not the empty state", async () => {
178+
const ds = makeAdapter(404, {
179+
success: false,
180+
error: { code: 'OBJECT_NOT_FOUND', message: "Object 'crm_invoice' is not registered" },
181+
});
182+
const { container } = render(
183+
<SchemaRendererProvider dataSource={ds}>
184+
<ListView schema={missingSchema} dataSource={ds} />
185+
</SchemaRendererProvider>,
186+
);
187+
188+
const panel = await waitFor(() => {
189+
const el = container.querySelector('[data-testid="list-error-state"]');
190+
expect(el).not.toBeNull();
191+
return el as HTMLElement;
192+
});
193+
194+
expect(panel.textContent).toContain("Object 'crm_invoice' is not registered");
195+
expect(panel.querySelector('[data-testid="list-error-code"]')?.textContent).toBe('OBJECT_NOT_FOUND');
196+
expect(panel.textContent).not.toMatch(/connection/i);
197+
expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
198+
199+
// Every data read the list made took the plain path: the masking lived
200+
// there, so a read that went raw would make this pass for the wrong reason.
201+
const dataCalls = (ds.__fetch.mock.calls as unknown[][])
202+
.map(([url]) => String(url))
203+
.filter((u) => u.includes('/data/'));
204+
expect(dataCalls.length).toBeGreaterThan(0);
205+
expect(dataCalls.every((u) => !/populate=|[$]?search=/.test(decodeURIComponent(u)))).toBe(true);
206+
});
207+
});

‎ui/packages/data-objectstack/src/apiAccessDenied.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,13 @@ describe('ObjectStackDataSource.find — enable-block denials are surfaced, not
140140
});
141141

142142
it('CONTROL: a 404 RECORD_NOT_FOUND is not an enable-block denial', async () => {
143+
// On a platform collection, so the 404 takes the optional-collection
144+
// branch. A non-registry object's 404 is rethrown instead
145+
// (findMissingObject-29782.test.ts).
143146
const find = vi.fn().mockRejectedValue(clientError('No such record', 404, 'RECORD_NOT_FOUND'));
144147
const ds = makeDS(find);
145148

146-
await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 });
149+
await expect(ds.find('sys_comment')).resolves.toMatchObject({ data: [], total: 0 });
147150
});
148151

149152
it('CONTROL: a 2xx with zero rows resolves as an ordinary empty result', async () => {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* A plain list read of an object the server does not know must reach the
11+
* surface as a failure, not as "no data yet".
12+
*
13+
* `find()`'s SDK path (no `$expand`, no `$search`) degraded every 404 to
14+
* `{ data: [], total: 0 }` and memoised the resource in `missingResources`.
15+
* That memo exists for OPTIONAL platform collections (`sys_presence`,
16+
* `sys_activity`, …), whose absence means "this deployment does not install
17+
* the feature". It also swallowed the 404 of a list's OWN object: an object
18+
* that failed to register answered 404 `OBJECT_NOT_FOUND`, the list showed its
19+
* empty state, and every later read of that object short-circuited to empty
20+
* without asking again.
21+
*
22+
* The error code cannot split the two cases: an uninstalled `sys_presence` and
23+
* an unregistered `crm_invoice` both answer 404 `OBJECT_NOT_FOUND`. The split
24+
* is by name, against the platform's curated registry
25+
* (`isPlatformProvidedObjectName`, @objectstack/spec): a registry name keeps
26+
* the empty answer and the memo; any other object's 404 is rethrown and not
27+
* memoised.
28+
*/
29+
30+
import { describe, it, expect, vi } from 'vitest';
31+
import { isPlatformProvidedObjectName } from '@objectstack/spec/system';
32+
import { ObjectStackAdapter } from './index';
33+
34+
/** An error shaped the way `@objectstack/client`'s fetch wrapper throws one. */
35+
function clientError(message: string, httpStatus: number, code?: string) {
36+
const err = new Error(message) as Error & { httpStatus: number; code?: string };
37+
err.httpStatus = httpStatus;
38+
if (code) err.code = code;
39+
return err;
40+
}
41+
42+
function makeDS(find: any) {
43+
const ds: any = new ObjectStackAdapter({
44+
baseUrl: 'http://test.local',
45+
fetch: vi.fn(async () =>
46+
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
47+
status: 200,
48+
headers: { 'Content-Type': 'application/json' },
49+
})),
50+
});
51+
ds.connected = true;
52+
ds.connectionState = 'connected';
53+
ds.client = { data: { find } };
54+
return ds;
55+
}
56+
57+
const MISSING = 'crm_invoice';
58+
const NOT_FOUND_SENTENCE = "Object 'crm_invoice' is not registered";
59+
60+
describe('find — a 404 on a non-platform object is rethrown, not emptied', () => {
61+
it('the names these tests lean on sit on the right side of the registry', () => {
62+
expect(isPlatformProvidedObjectName(MISSING)).toBe(false);
63+
expect(isPlatformProvidedObjectName('sys_presence')).toBe(true);
64+
expect(isPlatformProvidedObjectName('sys_activity')).toBe(true);
65+
});
66+
67+
it('REJECTS a plain find answered 404 OBJECT_NOT_FOUND, with the code, status and sentence', async () => {
68+
const find = vi.fn().mockRejectedValue(clientError(NOT_FOUND_SENTENCE, 404, 'OBJECT_NOT_FOUND'));
69+
const ds = makeDS(find);
70+
71+
await expect(ds.find(MISSING, { $top: 0 })).rejects.toMatchObject({
72+
code: 'OBJECT_NOT_FOUND',
73+
httpStatus: 404,
74+
message: NOT_FOUND_SENTENCE,
75+
});
76+
});
77+
78+
it('does not memoise it: a second find asks the server again and rejects again', async () => {
79+
const find = vi.fn().mockRejectedValue(clientError(NOT_FOUND_SENTENCE, 404, 'OBJECT_NOT_FOUND'));
80+
const ds = makeDS(find);
81+
82+
await expect(ds.find(MISSING)).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' });
83+
await expect(ds.find(MISSING, { $top: 25 })).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' });
84+
expect(find).toHaveBeenCalledTimes(2);
85+
});
86+
87+
it('a bare 404 with no code on a non-platform object is rethrown too', async () => {
88+
const find = vi.fn().mockRejectedValue(clientError('Not found', 404));
89+
const ds = makeDS(find);
90+
91+
await expect(ds.find(MISSING)).rejects.toMatchObject({ httpStatus: 404 });
92+
await expect(ds.find(MISSING)).rejects.toMatchObject({ httpStatus: 404 });
93+
expect(find).toHaveBeenCalledTimes(2);
94+
});
95+
96+
it('over the real client: the wrapped 404 envelope reaches the caller as code + status', async () => {
97+
const dataUrls: string[] = [];
98+
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
99+
const href = String(url);
100+
if (href.includes('/data/')) {
101+
dataUrls.push(href);
102+
return new Response(
103+
JSON.stringify({ success: false, error: { code: 'OBJECT_NOT_FOUND', message: NOT_FOUND_SENTENCE } }),
104+
{ status: 404, headers: { 'Content-Type': 'application/json' } },
105+
);
106+
}
107+
return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
108+
status: 200,
109+
headers: { 'Content-Type': 'application/json' },
110+
});
111+
});
112+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
113+
ds.connected = true;
114+
ds.connectionState = 'connected';
115+
116+
await expect(ds.find(MISSING, { $top: 0 })).rejects.toMatchObject({
117+
code: 'OBJECT_NOT_FOUND',
118+
httpStatus: 404,
119+
});
120+
await expect(ds.find(MISSING, { $top: 0 })).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' });
121+
expect(dataUrls).toHaveLength(2);
122+
expect(dataUrls.every((u) => u.includes(`/data/${MISSING}`))).toBe(true);
123+
});
124+
});
125+
126+
describe('find — CONTROLS: the platform optional-collection half is unchanged', () => {
127+
it('CONTROL: a registry name answered 404 OBJECT_NOT_FOUND degrades to empty and is memoised', async () => {
128+
const find = vi.fn().mockRejectedValue(clientError("Object 'sys_presence' not found", 404, 'OBJECT_NOT_FOUND'));
129+
const ds = makeDS(find);
130+
131+
await expect(ds.find('sys_presence')).resolves.toMatchObject({ data: [], total: 0 });
132+
// Short-circuited: the second call never reaches the server.
133+
await expect(ds.find('sys_presence', { $top: 5 })).resolves.toMatchObject({ data: [], total: 0 });
134+
expect(find).toHaveBeenCalledTimes(1);
135+
});
136+
137+
it('CONTROL: the memo is per resource: an emptied platform name does not empty a missing object', async () => {
138+
const find = vi.fn(async (resource: string) => {
139+
throw clientError(`Object '${resource}' not found`, 404, 'OBJECT_NOT_FOUND');
140+
});
141+
const ds = makeDS(find);
142+
143+
await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 });
144+
await expect(ds.find(MISSING)).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' });
145+
});
146+
147+
it('CONTROL: an enable-block denial still rethrows, on a platform name and on any other', async () => {
148+
const find = vi.fn().mockRejectedValue(clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'));
149+
const ds = makeDS(find);
150+
151+
await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED', httpStatus: 404 });
152+
await expect(ds.find(MISSING)).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED', httpStatus: 404 });
153+
// Neither is memoised: the platform name is asked again.
154+
await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED' });
155+
expect(find).toHaveBeenCalledTimes(3);
156+
});
157+
158+
it('CONTROL: a 2xx with zero rows on a non-platform object is still an ordinary empty result', async () => {
159+
const find = vi.fn().mockResolvedValue({ records: [], total: 0 });
160+
const ds = makeDS(find);
161+
162+
await expect(ds.find(MISSING)).resolves.toMatchObject({ data: [], total: 0 });
163+
});
164+
});

‎ui/packages/data-objectstack/src/index.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack
3939
import type { ApiError, BatchUpdateResponse, ValidateDataIssue } from '@objectstack/spec/api';
4040
import { ValidateDataIssueSchema } from '@objectstack/spec/api';
4141
import { stripReadDecorations } from '@objectstack/spec/kernel';
42+
import { isPlatformProvidedObjectName } from '@objectstack/spec/system';
4243
// #4237 — the metadata save door's advisory reader, shared with `MetadataClient`
4344
// rather than forked. ONE reader, two call sites: the other client class calls it
4445
// from `MetadataClient.save` (#4133/#4236), this one from the interceptor below.
@@ -3448,10 +3449,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
34483449
private inflightFieldAccess = new Map<string, Promise<RecordFieldAccess | null>>();
34493450
private unansweredFieldAccess = new Set<string>();
34503451
private fieldAccessUnsupported = false;
3451-
// Resources that have responded 404 at least once (collection not installed
3452-
// on this backend). Subsequent find() calls short-circuit to an empty result
3453-
// so optional collections like sys_presence don't hammer the server with
3454-
// failing requests on every record open / panel render.
3452+
// Platform collections that have responded 404 at least once (the package
3453+
// that registers them is not installed on this backend). Subsequent find()
3454+
// calls short-circuit to an empty result so optional collections like
3455+
// sys_presence don't hammer the server with failing requests on every record
3456+
// open / panel render. Only names in the platform registry
3457+
// (`isPlatformProvidedObjectName`, @objectstack/spec) ever enter it: a 404
3458+
// on any other object is the surface's own object missing, and is rethrown.
34553459
private missingResources = new Set<string>();
34563460
// Set once the server has told us it can't do a cross-object transactional
34573461
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
@@ -3812,9 +3816,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
38123816
* Converts OData-style params to ObjectStack query options.
38133817
*/
38143818
async find(resource: string, params?: QueryParams): Promise<QueryResult<T>> {
3815-
// Short-circuit when this resource has previously responded 404 — the
3816-
// collection isn't installed on this backend. Callers (AppHeader,
3817-
// RecordDetailView, …) treat empty data as "feature unavailable".
3819+
// Short-circuit when this platform collection has previously responded
3820+
// 404 — the package that registers it isn't installed on this backend.
3821+
// Callers (AppHeader, RecordDetailView, …) treat empty data as "feature
3822+
// unavailable". Only registry names are ever memoised (see the catch).
38183823
if (this.missingResources.has(resource)) {
38193824
return { data: [], total: 0 } as QueryResult<T>;
38203825
}
@@ -3849,7 +3854,21 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
38493854
// (objectui#4408). Rethrow so the surface can say what happened; the
38503855
// `missingResources` memo must not absorb it either, or the very first
38513856
// denial would silently pin every later call to empty.
3852-
if (!isApiAccessDeniedError(err) && is404Error(err)) {
3857+
//
3858+
// A 404 degrades to empty ONLY for a platform-provided collection
3859+
// (the curated registry in @objectstack/spec): there, absence means
3860+
// "this deployment does not install the feature", and the optional
3861+
// probes read empty as exactly that. The error code cannot make this
3862+
// split: an uninstalled sys_presence and a list's own unregistered
3863+
// object both answer 404 `OBJECT_NOT_FOUND`. Any other object is the
3864+
// surface's own, so its 404 is rethrown, unmemoised, and the surface's
3865+
// load-failure state shows the server's code and sentence instead of
3866+
// "no data yet" (an object that failed to register is not empty).
3867+
if (
3868+
!isApiAccessDeniedError(err)
3869+
&& is404Error(err)
3870+
&& isPlatformProvidedObjectName(resource)
3871+
) {
38533872
// Mark the resource so subsequent calls don't repeat the 404.
38543873
this.missingResources.add(resource);
38553874
return { data: [], total: 0 } as QueryResult<T>;

0 commit comments

Comments
 (0)