From 7b069d35ffd8a3d9e3ae3c3856b6f1d92a9864a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:14:48 +0000 Subject: [PATCH 1/2] fix(data-objectstack): a 404 on a non-platform object rejects instead of reading as an empty list The plain find path degraded every 404 to an empty result and memoised the resource. The empty answer and the memo now apply only to names in the platform registry (isPlatformProvidedObjectName); any other object's 404 is rethrown with its code and sentence and not memoised. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUqz12Aiuvrkaw5SJa --- .changeset/29782-missing-object-list-read.md | 12 ++ .../src/apiAccessDenied.test.ts | 5 +- .../src/findMissingObject-29782.test.ts | 164 ++++++++++++++++++ ui/packages/data-objectstack/src/index.ts | 35 +++- 4 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 .changeset/29782-missing-object-list-read.md create mode 100644 ui/packages/data-objectstack/src/findMissingObject-29782.test.ts diff --git a/.changeset/29782-missing-object-list-read.md b/.changeset/29782-missing-object-list-read.md new file mode 100644 index 00000000000..fbb0e98e91c --- /dev/null +++ b/.changeset/29782-missing-object-list-read.md @@ -0,0 +1,12 @@ +--- +'@object-ui/data-objectstack': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +`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". + +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. diff --git a/ui/packages/data-objectstack/src/apiAccessDenied.test.ts b/ui/packages/data-objectstack/src/apiAccessDenied.test.ts index 7fe69a8fcf0..6f68e83fb76 100644 --- a/ui/packages/data-objectstack/src/apiAccessDenied.test.ts +++ b/ui/packages/data-objectstack/src/apiAccessDenied.test.ts @@ -140,10 +140,13 @@ describe('ObjectStackDataSource.find — enable-block denials are surfaced, not }); it('CONTROL: a 404 RECORD_NOT_FOUND is not an enable-block denial', async () => { + // On a platform collection, so the 404 takes the optional-collection + // branch. A non-registry object's 404 is rethrown instead + // (findMissingObject-29782.test.ts). const find = vi.fn().mockRejectedValue(clientError('No such record', 404, 'RECORD_NOT_FOUND')); const ds = makeDS(find); - await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 }); + await expect(ds.find('sys_comment')).resolves.toMatchObject({ data: [], total: 0 }); }); it('CONTROL: a 2xx with zero rows resolves as an ordinary empty result', async () => { diff --git a/ui/packages/data-objectstack/src/findMissingObject-29782.test.ts b/ui/packages/data-objectstack/src/findMissingObject-29782.test.ts new file mode 100644 index 00000000000..6c9c5a736ac --- /dev/null +++ b/ui/packages/data-objectstack/src/findMissingObject-29782.test.ts @@ -0,0 +1,164 @@ +/** + * 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. + */ + +/** + * A plain list read of an object the server does not know must reach the + * surface as a failure, not as "no data yet". + * + * `find()`'s SDK path (no `$expand`, no `$search`) degraded every 404 to + * `{ data: [], total: 0 }` and memoised the resource in `missingResources`. + * That memo exists for OPTIONAL platform collections (`sys_presence`, + * `sys_activity`, …), whose absence means "this deployment does not install + * the feature". It also swallowed the 404 of a list's OWN object: an object + * that failed to register answered 404 `OBJECT_NOT_FOUND`, the list showed its + * empty state, and every later read of that object short-circuited to empty + * without asking again. + * + * The error code cannot split the two cases: an uninstalled `sys_presence` and + * an unregistered `crm_invoice` both answer 404 `OBJECT_NOT_FOUND`. The split + * is by name, against the platform's curated registry + * (`isPlatformProvidedObjectName`, @objectstack/spec): a registry name keeps + * the empty answer and the memo; any other object's 404 is rethrown and not + * memoised. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { isPlatformProvidedObjectName } from '@objectstack/spec/system'; +import { ObjectStackAdapter } from './index'; + +/** An error shaped the way `@objectstack/client`'s fetch wrapper throws one. */ +function clientError(message: string, httpStatus: number, code?: string) { + const err = new Error(message) as Error & { httpStatus: number; code?: string }; + err.httpStatus = httpStatus; + if (code) err.code = code; + return err; +} + +function makeDS(find: any) { + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { data: { find } }; + return ds; +} + +const MISSING = 'crm_invoice'; +const NOT_FOUND_SENTENCE = "Object 'crm_invoice' is not registered"; + +describe('find — a 404 on a non-platform object is rethrown, not emptied', () => { + it('the names these tests lean on sit on the right side of the registry', () => { + expect(isPlatformProvidedObjectName(MISSING)).toBe(false); + expect(isPlatformProvidedObjectName('sys_presence')).toBe(true); + expect(isPlatformProvidedObjectName('sys_activity')).toBe(true); + }); + + it('REJECTS a plain find answered 404 OBJECT_NOT_FOUND, with the code, status and sentence', async () => { + const find = vi.fn().mockRejectedValue(clientError(NOT_FOUND_SENTENCE, 404, 'OBJECT_NOT_FOUND')); + const ds = makeDS(find); + + await expect(ds.find(MISSING, { $top: 0 })).rejects.toMatchObject({ + code: 'OBJECT_NOT_FOUND', + httpStatus: 404, + message: NOT_FOUND_SENTENCE, + }); + }); + + it('does not memoise it: a second find asks the server again and rejects again', async () => { + const find = vi.fn().mockRejectedValue(clientError(NOT_FOUND_SENTENCE, 404, 'OBJECT_NOT_FOUND')); + const ds = makeDS(find); + + await expect(ds.find(MISSING)).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' }); + await expect(ds.find(MISSING, { $top: 25 })).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('a bare 404 with no code on a non-platform object is rethrown too', async () => { + const find = vi.fn().mockRejectedValue(clientError('Not found', 404)); + const ds = makeDS(find); + + await expect(ds.find(MISSING)).rejects.toMatchObject({ httpStatus: 404 }); + await expect(ds.find(MISSING)).rejects.toMatchObject({ httpStatus: 404 }); + expect(find).toHaveBeenCalledTimes(2); + }); + + it('over the real client: the wrapped 404 envelope reaches the caller as code + status', async () => { + const dataUrls: string[] = []; + const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { + const href = String(url); + if (href.includes('/data/')) { + dataUrls.push(href); + return new Response( + JSON.stringify({ success: false, error: { code: 'OBJECT_NOT_FOUND', message: NOT_FOUND_SENTENCE } }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl }); + ds.connected = true; + ds.connectionState = 'connected'; + + await expect(ds.find(MISSING, { $top: 0 })).rejects.toMatchObject({ + code: 'OBJECT_NOT_FOUND', + httpStatus: 404, + }); + await expect(ds.find(MISSING, { $top: 0 })).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' }); + expect(dataUrls).toHaveLength(2); + expect(dataUrls.every((u) => u.includes(`/data/${MISSING}`))).toBe(true); + }); +}); + +describe('find — CONTROLS: the platform optional-collection half is unchanged', () => { + it('CONTROL: a registry name answered 404 OBJECT_NOT_FOUND degrades to empty and is memoised', async () => { + const find = vi.fn().mockRejectedValue(clientError("Object 'sys_presence' not found", 404, 'OBJECT_NOT_FOUND')); + const ds = makeDS(find); + + await expect(ds.find('sys_presence')).resolves.toMatchObject({ data: [], total: 0 }); + // Short-circuited: the second call never reaches the server. + await expect(ds.find('sys_presence', { $top: 5 })).resolves.toMatchObject({ data: [], total: 0 }); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: the memo is per resource: an emptied platform name does not empty a missing object', async () => { + const find = vi.fn(async (resource: string) => { + throw clientError(`Object '${resource}' not found`, 404, 'OBJECT_NOT_FOUND'); + }); + const ds = makeDS(find); + + await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 }); + await expect(ds.find(MISSING)).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND' }); + }); + + it('CONTROL: an enable-block denial still rethrows, on a platform name and on any other', async () => { + const find = vi.fn().mockRejectedValue(clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED')); + const ds = makeDS(find); + + await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED', httpStatus: 404 }); + await expect(ds.find(MISSING)).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED', httpStatus: 404 }); + // Neither is memoised: the platform name is asked again. + await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED' }); + expect(find).toHaveBeenCalledTimes(3); + }); + + it('CONTROL: a 2xx with zero rows on a non-platform object is still an ordinary empty result', async () => { + const find = vi.fn().mockResolvedValue({ records: [], total: 0 }); + const ds = makeDS(find); + + await expect(ds.find(MISSING)).resolves.toMatchObject({ data: [], total: 0 }); + }); +}); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 7ddcc01acce..f82eb0b7a2a 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -39,6 +39,7 @@ import { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack import type { ApiError, BatchUpdateResponse, ValidateDataIssue } from '@objectstack/spec/api'; import { ValidateDataIssueSchema } from '@objectstack/spec/api'; import { stripReadDecorations } from '@objectstack/spec/kernel'; +import { isPlatformProvidedObjectName } from '@objectstack/spec/system'; // #4237 — the metadata save door's advisory reader, shared with `MetadataClient` // rather than forked. ONE reader, two call sites: the other client class calls it // from `MetadataClient.save` (#4133/#4236), this one from the interceptor below. @@ -3448,10 +3449,13 @@ export class ObjectStackAdapter implements DataSource { private inflightFieldAccess = new Map>(); private unansweredFieldAccess = new Set(); private fieldAccessUnsupported = false; - // Resources that have responded 404 at least once (collection not installed - // on this backend). Subsequent find() calls short-circuit to an empty result - // so optional collections like sys_presence don't hammer the server with - // failing requests on every record open / panel render. + // Platform collections that have responded 404 at least once (the package + // that registers them is not installed on this backend). Subsequent find() + // calls short-circuit to an empty result so optional collections like + // sys_presence don't hammer the server with failing requests on every record + // open / panel render. Only names in the platform registry + // (`isPlatformProvidedObjectName`, @objectstack/spec) ever enter it: a 404 + // on any other object is the surface's own object missing, and is rethrown. private missingResources = new Set(); // Set once the server has told us it can't do a cross-object transactional // batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After @@ -3812,9 +3816,10 @@ export class ObjectStackAdapter implements DataSource { * Converts OData-style params to ObjectStack query options. */ async find(resource: string, params?: QueryParams): Promise> { - // Short-circuit when this resource has previously responded 404 — the - // collection isn't installed on this backend. Callers (AppHeader, - // RecordDetailView, …) treat empty data as "feature unavailable". + // Short-circuit when this platform collection has previously responded + // 404 — the package that registers it isn't installed on this backend. + // Callers (AppHeader, RecordDetailView, …) treat empty data as "feature + // unavailable". Only registry names are ever memoised (see the catch). if (this.missingResources.has(resource)) { return { data: [], total: 0 } as QueryResult; } @@ -3849,7 +3854,21 @@ export class ObjectStackAdapter implements DataSource { // (objectui#4408). Rethrow so the surface can say what happened; the // `missingResources` memo must not absorb it either, or the very first // denial would silently pin every later call to empty. - if (!isApiAccessDeniedError(err) && is404Error(err)) { + // + // A 404 degrades to empty ONLY for a platform-provided collection + // (the curated registry in @objectstack/spec): there, absence means + // "this deployment does not install the feature", and the optional + // probes read empty as exactly that. The error code cannot make this + // split: an uninstalled sys_presence and a list's own unregistered + // object both answer 404 `OBJECT_NOT_FOUND`. Any other object is the + // surface's own, so its 404 is rethrown, unmemoised, and the surface's + // load-failure state shows the server's code and sentence instead of + // "no data yet" (an object that failed to register is not empty). + if ( + !isApiAccessDeniedError(err) + && is404Error(err) + && isPlatformProvidedObjectName(resource) + ) { // Mark the resource so subsequent calls don't repeat the 404. this.missingResources.add(resource); return { data: [], total: 0 } as QueryResult; From a9f52f3ef684e8d53614bf7c0f7c77b4f94a9960 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:15:35 +0000 Subject: [PATCH 2/2] test(app-shell): a plain-path 404 OBJECT_NOT_FOUND shows the list's error panel over the real adapter Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUqz12Aiuvrkaw5SJa --- .../views/objectListApiDisabled-4408.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/ui/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx b/ui/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx index d14c45b7914..d4fa3bbe5c3 100644 --- a/ui/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx +++ b/ui/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx @@ -66,6 +66,7 @@ function makeAdapter(status: number, body: unknown) { ); }); const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl }); + ds.__fetch = fetchImpl; return ds; } @@ -154,3 +155,53 @@ describe('object list · an OBJECT_API_DISABLED 404 is not an empty list (#4408) expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull(); }); }); + +/** + * objectstack#29782 — the same masking one code over, on the list's plain + * read path. An object that failed to register answers 404 + * `OBJECT_NOT_FOUND`. The adapter's plain `find()` (no `$expand`, no + * `$search`) degraded that to zero rows and memoised it, so the list said + * "no data yet" about an object that does not exist, and #29704's panel + * (the server's sentence and code) was reachable only on the raw path. The + * empty answer now belongs to platform collections alone + * (`isPlatformProvidedObjectName`); `sys_jwks` above is one, which is why the + * bare-404 control there still reads empty. + */ +describe('object list · a 404 OBJECT_NOT_FOUND on the plain read path is not an empty list (objectstack#29782)', () => { + const missingSchema: ListViewSchema = { + type: 'list-view', + objectName: 'crm_invoice', + fields: ['name'], + } as ListViewSchema; + + it("shows the server's sentence and code, not the empty state", async () => { + const ds = makeAdapter(404, { + success: false, + error: { code: 'OBJECT_NOT_FOUND', message: "Object 'crm_invoice' is not registered" }, + }); + const { container } = render( + + + , + ); + + const panel = await waitFor(() => { + const el = container.querySelector('[data-testid="list-error-state"]'); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + + expect(panel.textContent).toContain("Object 'crm_invoice' is not registered"); + expect(panel.querySelector('[data-testid="list-error-code"]')?.textContent).toBe('OBJECT_NOT_FOUND'); + expect(panel.textContent).not.toMatch(/connection/i); + expect(container.querySelector('[data-testid="empty-state"]')).toBeNull(); + + // Every data read the list made took the plain path: the masking lived + // there, so a read that went raw would make this pass for the wrong reason. + const dataCalls = (ds.__fetch.mock.calls as unknown[][]) + .map(([url]) => String(url)) + .filter((u) => u.includes('/data/')); + expect(dataCalls.length).toBeGreaterThan(0); + expect(dataCalls.every((u) => !/populate=|[$]?search=/.test(decodeURIComponent(u)))).toBe(true); + }); +});