diff --git a/.changeset/13057-settled-view-list.md b/.changeset/13057-settled-view-list.md new file mode 100644 index 0000000000..ed8b20d2b8 --- /dev/null +++ b/.changeset/13057-settled-view-list.md @@ -0,0 +1,6 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/data-objectstack": patch +--- + +One object-page open reads the published view list (`GET /meta/view`) once (objectui#13057). The adapter now stores the list its client read and answers `listViews` and `listViewOverrides` from it, so the provider's eager view read serves the page's two readers. The client read itself still always asks the server. Every path that should see a fresh list drops it first: a refresh of the object page, every view write (through the adapter, its client, or the console's `MetadataClient`, whose view saves, publishes, resets, rollbacks and package publishes now drop it), and an organization or session switch. The package sheet's *Publish app* now announces the publish like every other publish path, so open views, forms and navigation re-read what went live. The draft-preview list read is unchanged and never fills the stored list. diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx index 5257151adb..7d38c61cc5 100644 --- a/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx @@ -19,12 +19,16 @@ * (a) the provider's by-name object read and `getObjectSchema` ask * `/meta/object/NAME` once, in either order; * (b) the eager view read, `listViews` and `listViewOverrides` share a - * `/meta/view` request that is in flight, and nothing is stored: the - * console order costs two requests where it cost three (ruling - * 6268839381 kept the list uncached); - * (c) the controls: an invalidation still refetches, a later list read and - * a view write still ask again, the preview path still goes to the - * preview client, and each side's readers see the shape they saw before. + * `/meta/view` request that is in flight, and (objectui#13057) the + * adapter's two readers read the list the eager read stored: the console + * order costs ONE request where it cost three, then two; + * (c) the controls: an invalidation still refetches, a provider re-read and + * a view write still ask again, the stored list is dropped by every + * write and refresh path (the console's own drops are pinned in + * `viewListDrop-13057.test.tsx` and + * `ObjectView.refreshRereadsViews-13057.test.tsx`), the preview path + * still goes to the preview client and never fills the stored list, and + * each side's readers see the shape they saw before. */ import { act, cleanup, render, waitFor } from '@testing-library/react'; @@ -81,6 +85,7 @@ function makeServer(): { server: Server; adapter: ObjectStackAdapter } { const answer = (path: string): Response => { if (path.endsWith('/api/v1/discovery')) return json(200, { success: true, data: {} }); if (path === '/api/v1/meta/view') return json(200, { success: true, data: { type: 'view', items: structuredClone(VIEWS) } }); + if (path === '/api/v1/meta/view?preview=draft') return json(200, { success: true, data: { type: 'view', items: [] } }); if (path.startsWith('/api/v1/meta/view/')) return json(200, { success: true }); const byName = /^\/api\/v1\/meta\/object\/([^/?]+)$/.exec(path); if (byName) { @@ -199,7 +204,7 @@ describe('one request per object document across both caches (objectui#13044)', }); }); -describe('the published view list: one request in flight, nothing stored (objectui#13044)', () => { +describe('the published view list: one request per open (objectui#13044, objectui#13057)', () => { it('console order: the eager read lands, then the object page reads the list and the overrides together', async () => { const { server, adapter } = makeServer(); const ctx = mount(adapter); @@ -211,9 +216,10 @@ describe('the published view list: one request in flight, nothing stored (object await adapter.listViewOverrides('account'); expect(views.map((v: any) => v.name).sort()).toEqual(['account.all', 'account.mine']); expect(Object.keys(overrides).sort()).toEqual(['account.all', 'account.mine']); - // Was 3 (one per reader). The eager read is answered before the page asks, - // and no answer is stored, so the page's two readers share one more. - expect(count(server.calls, VIEW_URL)).toBe(2); + // Was 3 (one per reader), then 2 (objectui#13044: the page's two readers + // shared one more request in flight). objectui#13057: the eager read + // stored its answer, and both readers read it, so the open costs one. + expect(count(server.calls, VIEW_URL)).toBe(1); }); it('a reader that asks while the eager read is on the wire joins it', async () => { @@ -260,46 +266,102 @@ describe('controls (objectui#13044)', () => { expect(count(server.calls, OBJECT_URL)).toBe(3); }); - it('every list read after a landing asks again: a refresh (refreshKey) re-reads the views', async () => { + it('the list is settled within an open, and a provider re-read asks again and is what the next list read sees (objectui#13057)', async () => { const { server, adapter } = makeServer(); const ctx = mount(adapter); await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); await adapter.listViews('account'); await adapter.listViews('account'); - expect(count(server.calls, VIEW_URL)).toBe(3); + // Answered from the list the eager read stored. + expect(count(server.calls, VIEW_URL)).toBe(1); + // The provider's re-read is a client read: it never reads the stored list. await act(async () => { await ctx().refresh('view'); }); - expect(count(server.calls, VIEW_URL)).toBe(4); + expect(count(server.calls, VIEW_URL)).toBe(2); + // ...and what it was answered is what the adapter's readers read next. + VIEWS.push({ name: 'account.new', object: 'account', viewKind: 'list', label: 'New', config: { type: 'grid', columns: ['name'] } }); + try { + expect((await adapter.listViews('account')).map((v: any) => v.name).sort()).toEqual(['account.all', 'account.mine']); + expect(count(server.calls, VIEW_URL)).toBe(2); + await act(async () => { + await ctx().refresh('view'); + }); + expect((await adapter.listViews('account')).map((v: any) => v.name).sort()).toEqual([ + 'account.all', + 'account.mine', + 'account.new', + ]); + expect(count(server.calls, VIEW_URL)).toBe(3); + } finally { + VIEWS.pop(); + } + }); + + it('a view write drops the stored list: the next list read asks again (objectui#13057)', async () => { + for (const write of [ + (adapter: ObjectStackAdapter) => adapter.invalidateViewKeys('account', 'account.mine'), + (adapter: ObjectStackAdapter) => + adapter.getClient().meta.saveItem('view', 'account.mine', { name: 'account.mine', object: 'account' }), + ]) { + const { server, adapter } = makeServer(); + const ctx = mount(adapter); + await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); + await adapter.listViews('account'); + expect(count(server.calls, VIEW_URL)).toBe(1); + await write(adapter); + await adapter.listViews('account'); + // An object whose override map was never read: derived from the stored list. + await adapter.listViewOverrides('contact'); + // The read after the write asked; the one after it reads what it stored. + expect(count(server.calls, VIEW_URL)).toBe(2); + cleanup(); + } }); it('a view write detaches the list request in flight: a read after it asks again', async () => { const { server, adapter } = makeServer(); - const ctx = mount(adapter); - await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); server.hold.add(VIEW_URL); + const ctx = mount(adapter); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); + // Joins the eager read on the wire (after its own discovery read). const before = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); adapter.invalidateViewKeys('account', 'account.mine'); const after = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(3)); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); server.release(); await Promise.all([before, after]); + await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); }); it('a view saved straight through the client detaches the list request in flight too', async () => { const { server, adapter } = makeServer(); - const ctx = mount(adapter); - await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); server.hold.add(VIEW_URL); + const ctx = mount(adapter); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); const before = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); // The shape a page uses when it writes a view without the adapter's seam. await adapter.getClient().meta.saveItem('view', 'account.mine', { name: 'account.mine', object: 'account' }); const after = adapter.listViews('account'); - await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(3)); + await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(2)); server.release(); await Promise.all([before, after]); + await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); + }); + + it('a draft-preview list read never fills the stored list (objectui#13057)', async () => { + const { server, adapter } = makeServer(); + // The adapter's draft-overlaid read is its own request, on its preview client... + await adapter.listViews('account', { previewDrafts: true }); + expect(count(server.calls, `${VIEW_URL}?preview=draft`)).toBe(1); + expect(count(server.calls, VIEW_URL)).toBe(0); + // ...and stores nothing a published read could be answered from. + expect(adapter.getCached('list:view')).toBeUndefined(); + const published = await adapter.listViews('account'); + expect(published.map((v: any) => v.name).sort()).toEqual(['account.all', 'account.mine']); + expect(count(server.calls, VIEW_URL)).toBe(1); }); it('the preview path still reads through the preview client, never the adapter', async () => { diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx index e613b56cef..b34660e1f7 100644 --- a/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx @@ -127,6 +127,38 @@ describe('a switch drops the adapter metadata the provider read through it (obje await views; }); + it('an organization or session switch drops the stored view list: the next list read asks again (objectui#13057)', async () => { + for (const change of [ + () => { + authState.activeOrganization = { id: 'org_b' }; + }, + () => { + authState.user = { id: 'user_b' }; + }, + ]) { + const { adapter, calls } = makeServer(); + authState.activeOrganization = { id: 'org_a' }; + authState.user = { id: 'user_a' }; + const m = mount(adapter); + await waitFor(() => expect(count(calls, VIEW_URL)).toBe(1)); + await waitFor(() => expect(m.ctx().getTypeStatus!('view')).toBe('ready')); + // Settled within the open: the eager read's answer serves the list read. + await adapter.listViews('account'); + expect(count(calls, VIEW_URL)).toBe(1); + + change(); + m.rerender(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await adapter.listViews('account'); + expect(count(calls, VIEW_URL)).toBe(2); + cleanup(); + authState.activeOrganization = null; + authState.user = null; + } + }); + it('a session switch (another user) drops it the same way', async () => { const { adapter, calls } = makeServer(); authState.user = { id: 'user_a' }; diff --git a/packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx b/packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx new file mode 100644 index 0000000000..d5967919f3 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.refreshRereadsViews-13057.test.tsx @@ -0,0 +1,192 @@ +/** + * 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 refresh of the object page re-reads its saved views from the server + * (objectui#13057, objectui#13044 ruling 6268839381 (a)). + * + * The adapter answers `listViews` from the published view list it stored for + * the open, so one open reads `GET /api/v1/meta/view` once. A user refresh + * (`refreshData`: a realtime change, a server action, an import, the page's + * refresh action) must not be answered by that stored list: it drops the list, + * and the saved-views read the refresh re-runs asks the server again. + * + * Real: the page, `plugin-view`'s `ObjectView`, `plugin-list`'s `ListView`, and + * an `ObjectStackAdapter` (its SDK client and `MetadataCache` included) behind + * a fake transport, so the drop is measured against the adapter's own key. Its + * record queries (`find`) are a stand-in: the rows are not under test. The + * refresh is delivered on the page's realtime channel, the harness of + * `ObjectView.refreshInPlace-10035.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities: `ListView` names `perms` in its fetch dependency list. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +/** The page's realtime channel; a case sets it and re-renders to deliver one. */ +const realtime = vi.hoisted(() => ({ + lastMessage: null as unknown, + conflicts: { hasConflicts: false, resolveAllConflicts: () => {} }, +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: realtime.lastMessage }), + useConflictResolution: () => realtime.conflicts, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; +const VIEW_URL = '/api/v1/meta/view'; +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + listViews: { all: { label: 'All', type: 'grid', columns: ['name'] } }, + }, +]; +const SAVED = [ + { name: `${OBJECT_NAME}.mine`, object: OBJECT_NAME, viewKind: 'list', label: 'Mine', config: { type: 'grid', columns: ['name'] } }, +]; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +let seq = 0; + +function makeAdapter() { + const calls: string[] = []; + const adapter = new ObjectStackAdapter({ + baseUrl: `http://refresh-views-${(seq += 1)}.test`, + autoReconnect: false, + fetch: (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/discovery')) return json({ success: true, data: {} }); + calls.push(url.pathname); + if (url.pathname === VIEW_URL) return json({ success: true, data: { type: 'view', items: structuredClone(SAVED) } }); + return json({ success: true, data: {} }); + }) as typeof fetch, + }); + // The record rows are not under test; the page's list query is answered here. + (adapter as unknown as { find: unknown }).find = vi.fn(async () => ({ data: [{ id: 't1', name: 'Task 1' }], total: 1 })); + return { adapter, viewReads: () => calls.filter((c) => c === VIEW_URL).length }; +} + +/** A window long enough for every effect a step schedules to have fired. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 400))); + +async function mountPage(adapter: ObjectStackAdapter): Promise<() => Promise> { + let rerender: () => void = () => {}; + function Harness() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + rerender = () => setTick((n) => n + 1); + }, []); + return ( + + + + {}} />} + /> + + + + ); + } + render(); + await settle(); + return async () => { + await act(async () => { + realtime.lastMessage = { type: 'update', objectName: OBJECT_NAME, at: Date.now() }; + rerender(); + }); + await settle(); + }; +} + +beforeEach(() => { + cleanup(); + realtime.lastMessage = null; + vi.stubGlobal('fetch', vi.fn(async () => json({ data: [] }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('a refresh of the object page re-reads its saved views (objectui#13057)', () => { + it('the open reads the view list once, and refreshData asks the server again', async () => { + const { adapter, viewReads } = makeAdapter(); + const listViews = vi.spyOn(adapter, 'listViews'); + const refresh = await mountPage(adapter); + // One open: `listViews` and `listViewOverrides` share one request. + expect(viewReads()).toBe(1); + const readsBefore = listViews.mock.calls.length; + + await refresh(); + + // The refresh re-ran the saved-views read, and it was not answered by the + // list stored for the open. + expect(listViews.mock.calls.length).toBeGreaterThan(readsBefore); + expect(viewReads()).toBe(2); + }); + + it('CONTROL: with no refresh, a re-render reads nothing again', async () => { + const { adapter, viewReads } = makeAdapter(); + await mountPage(adapter); + await settle(); + expect(viewReads()).toBe(1); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index ccb1c142a8..6cc2e5cc98 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -63,6 +63,7 @@ import { detectStatusField, isSystemManagedField } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; +import { dropPublishedViewList } from './metadata-admin/viewListDrop.js'; import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope, type ViewEnvelope } from './runtime-metadata-persistence.js'; import { buildNewViewSpec } from './newViewSpec.js'; import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator, VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui'; @@ -1694,11 +1695,17 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co * View CRUD (rename, pin, reorder, config save) keeps bumping the counter * alone: it changes the view's metadata, not the object's data, and the new * view config reaches every renderer as props. + * + * objectui#13057 — a refresh also re-reads the saved views from the server. + * The adapter answers `listViews` from the published view list it stored + * for this open, so the refresh drops that list first; the counter bump + * then re-runs the saved-views read below, which asks the server again. */ const refreshData = useCallback(() => { + dropPublishedViewList(dataSource); setRefreshKey((k) => k + 1); if (objectDef.name) notifyDataChanged({ objectName: objectDef.name }); - }, [objectDef.name]); + }, [dataSource, objectDef.name]); // Shared console action runtime: confirm/param/result dialogs, the // authenticated api/flow/server-action handlers, SPA navigation, and the diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx new file mode 100644 index 0000000000..acb997e152 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.publishPulse-13057.test.tsx @@ -0,0 +1,152 @@ +/** + * 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. + */ + +/** + * The package sheet's *Publish app* announces the publish, as every other + * publish path does (objectui#13057). + * + * `publishDrafts` promotes a package's drafts, views among them, and used to + * emit no metadata-refresh pulse, so the console kept serving what it had read + * before the publish: no `MetadataProvider` re-read its types or dropped the + * adapter's caches. It now emits the pulse Studio's publish-all, Home's and the + * chat bar's emit. And the read after it asks the server: the adapter's stored + * published view list is dropped by the console client's package door + * (`viewListDrop.ts`). + * + * Real: the sheet, `useMetadataClient` and its client, the assistant bus, and + * an `ObjectStackAdapter` behind a fake transport. Stubbed: `globalThis.fetch` + * (the console client's server), as `PackagesPage.publishRefusal-11922.test.tsx` + * stubs it. + */ + +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AdapterCtx } from '@object-ui/react'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +import { PackageDetailSheet, type InstalledPackageRow } from './PackagesPage'; +import { t, tFormat } from './i18n'; +import { subscribeMetadataRefresh } from '../../assistant/assistantBus'; + +const PKG_ID = 'com.acme.crm'; +const PKG: InstalledPackageRow = { + manifest: { id: PKG_ID, name: 'Acme CRM', version: '1.0.0', type: 'app' }, + enabled: true, + status: 'installed', + source: 'authored', + writable: true, +}; +const DRAFTS = [{ type: 'view', name: 'account.mine' }]; +const VIEW_URL = '/api/v1/meta/view'; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + +let batchBody: unknown; +const posts: string[] = []; + +beforeEach(() => { + posts.length = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + if (url.endsWith('/publish-drafts')) { + posts.push(url); + return json(batchBody); + } + if (url.startsWith('/api/v1/meta/_drafts')) return json({ success: true, data: { drafts: DRAFTS } }); + return json({ success: true, data: {} }); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function makeAdapter() { + const calls: string[] = []; + const adapter = new ObjectStackAdapter({ + baseUrl: 'http://publish-pulse.test', + autoReconnect: false, + fetch: (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/discovery')) return json({ success: true, data: {} }); + calls.push(url.pathname); + return json({ success: true, data: { type: 'view', items: [{ name: 'account.all', object: 'account' }] } }); + }) as typeof fetch, + }); + return { adapter, viewReads: () => calls.filter((c) => c === VIEW_URL).length }; +} + +async function publishApp(adapter: ObjectStackAdapter): Promise { + let pulses = 0; + const off = subscribeMetadataRefresh(() => { + pulses += 1; + }); + try { + render( + + + + + , + ); + fireEvent.click( + await screen.findByRole('button', { + name: tFormat('engine.packages.detail.publishApp', 'en', { count: DRAFTS.length }), + }), + ); + await waitFor(() => expect(posts).toHaveLength(1)); + await screen.findByText((_, node) => !!node && node.childElementCount === 0 && /\S/.test(node.textContent ?? '') && ( + node.textContent === t('engine.packages.detail.publishDraftsOk', 'en') || + (node.textContent ?? '').startsWith(tFormat('engine.packages.detail.publishDraftsPartial', 'en', { published: 0, failed: 1 })) + )); + return pulses; + } finally { + off(); + } +} + +describe('Publish app announces the publish, and the next view read asks again (objectui#13057)', () => { + it('a published batch emits the metadata-refresh pulse once', async () => { + batchBody = { + success: true, + data: { success: true, outcome: 'published', publishedCount: 1, failedCount: 0, published: DRAFTS, failed: [] }, + }; + const { adapter, viewReads } = makeAdapter(); + await adapter.getClient().meta.getItems('view'); + await adapter.listViews('account'); + expect(viewReads()).toBe(1); + + expect(await publishApp(adapter)).toBe(1); + + await adapter.listViews('account'); + expect(viewReads()).toBe(2); + }); + + it('a batch answered with failed[] still announces it, as Studio\'s publish-all does (a pre-15.1 server published the rest)', async () => { + batchBody = { + success: true, + data: { + success: false, + outcome: 'refused', + publishedCount: 0, + failedCount: 1, + published: [], + failed: [{ type: 'view', name: 'account.mine', error: 'bad', code: 'X' }], + }, + }; + const { adapter } = makeAdapter(); + expect(await publishApp(adapter)).toBe(1); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index 8e07ea217d..98ee995396 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -81,6 +81,7 @@ import { PageHeader } from '../../layout/PageHeader.js'; import { errorCodeIs } from '@object-ui/types'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; import { refusedItemsText } from '../../preview/publishRefusal.js'; +import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; import { duplicatePackage, isSpecPackageId } from '../studio-design/packages-io.js'; import { PackageIdInput } from '../studio-design/PackageIdInput.js'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; @@ -574,6 +575,14 @@ export function PackageDetailSheet({ t('engine.packages.detail.actionFailed', locale), ); } + // objectui#13057 — announce the publish, as every other publish path + // does (Studio's publish-all, Home's and the chat bar's): each mounted + // `MetadataProvider` drops the adapter's caches and re-reads its loaded + // types, so the views, forms and nav this tree already read show what + // just went live, and the pending-drafts surfaces converge. Sent before + // the `failed[]` verdict below, as Studio sends it: a pre-15.1 server's + // partial publish still made some drafts live. + emitMetadataRefresh(); try { const fresh = await apiJson<{ drafts?: Array<{ type: string; name: string }> }>( `/api/v1/meta/_drafts?packageId=${encodeURIComponent(id)}`, diff --git a/packages/app-shell/src/views/metadata-admin/useMetadata.ts b/packages/app-shell/src/views/metadata-admin/useMetadata.ts index 6c19b20f33..b506b259d9 100644 --- a/packages/app-shell/src/views/metadata-admin/useMetadata.ts +++ b/packages/app-shell/src/views/metadata-admin/useMetadata.ts @@ -18,9 +18,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type MetadataClient, type MetadataDiagnosticsSummary, type MetadataDiagnosticsEntry, type MetadataSaveAdvisoryEvent } from '@object-ui/data-objectstack'; import { useObjectTranslation } from '@object-ui/i18n'; +import { useAdapter } from '@object-ui/react'; import { toast } from 'sonner'; import { usePreviewDrafts } from '../../preview/PreviewModeContext.js'; import { createConsoleMetadataClient } from './metadataClientFactory.js'; +import { dropPublishedViewList, installViewListDrop } from './viewListDrop.js'; import { emitSaveAdvisories, type TranslateFn } from '../../providers/saveAdvisoryToast.js'; import type { FormViewSpec } from './form-spec.js'; @@ -157,8 +159,26 @@ export function useMetadataClient(environmentId?: string): MetadataClient { emitSaveAdvisories(ev, tRef.current as TranslateFn, toast); }, []); + // objectui#13057 — the adapter answers its view-list readers from the list + // it stored, and this client writes views over a transport the adapter never + // sees. Each write of this client that can change a published view drops + // that list (see `viewListDrop.ts`), here for the same reason the advisory + // sink is here: every app-shell write path takes its client from this hook. + // The adapter is read through a ref, as `t` is, so an adapter swap does not + // re-mint the client and a write always drops from the current adapter. + const adapter = useAdapter(); + const adapterRef = useRef(adapter); + adapterRef.current = adapter; + return useMemo( - () => createConsoleMetadataClient({ previewDrafts, environmentId, onSaveAdvisory }), + () => + installViewListDrop(createConsoleMetadataClient({ previewDrafts, environmentId, onSaveAdvisory }), () => { + try { + dropPublishedViewList(adapterRef.current); + } catch { + /* a drop must never turn a settled write into a thrown error */ + } + }), [environmentId, previewDrafts, onSaveAdvisory], ); } diff --git a/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx b/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx new file mode 100644 index 0000000000..15029de61f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/viewListDrop-13057.test.tsx @@ -0,0 +1,214 @@ +/** + * 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. + */ + +/** + * Every view write of the console's `MetadataClient` drops the adapter's stored + * published view list (objectui#13057, completing objectui#13044 ruling + * 6268839381 (b)). + * + * The adapter answers `listViews` / `listViewOverrides` from the list it stored + * for the open. The console's metadata pages (`metadata-admin/**`) and Studio + * (`studio-design/**`) write views through `MetadataClient`, whose transport the + * adapter never sees, so `useMetadataClient` drops the list after each write + * door that can change a published view (`viewListDrop.ts`). + * + * Real: `ObjectStackAdapter` (its SDK client and `MetadataCache` included, + * driven through a fake `fetch`), `useMetadataClient` and the client it builds. + * Stubbed: the console client's transport (`globalThis.fetch`). + * + * The writers are enumerated from the client, not listed by hand: every door of + * `MetadataClient` that sends a write is one of `ITEM_WRITE_DOORS` / + * `PACKAGE_WRITE_DOORS`, and every console client comes from the hook. So a new + * write door, or a page that builds its own client, turns a pin here red + * instead of writing views silently past the stored list. + */ + +import * as React from 'react'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { AdapterCtx } from '@object-ui/react'; +import { MetadataClient, ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { useMetadataClient } from './useMetadata'; +import { ITEM_WRITE_DOORS, PACKAGE_WRITE_DOORS } from './viewListDrop'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped. */ +const mask: (source: string) => string = maskComments; + +const VIEW_URL = '/api/v1/meta/view'; +const VIEWS = [{ name: 'account.all', object: 'account', viewKind: 'list', label: 'All', config: { type: 'grid' } }]; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +let seq = 0; + +/** A real adapter whose own transport is a fake server that counts by URL. */ +function makeAdapter() { + const calls: string[] = []; + const adapter = new ObjectStackAdapter({ + baseUrl: `http://view-list-drop-${(seq += 1)}.test`, + autoReconnect: false, + fetch: (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/discovery')) return json(200, { success: true, data: {} }); + calls.push(url.pathname + url.search); + if (url.pathname === VIEW_URL) return json(200, { success: true, data: { type: 'view', items: structuredClone(VIEWS) } }); + return json(200, { success: true, data: {} }); + }) as typeof fetch, + }); + return { adapter, viewReads: () => calls.filter((c) => c === VIEW_URL).length }; +} + +/** The console client, as a page gets it, under `adapter`. */ +function consoleClient(adapter: ObjectStackAdapter): MetadataClient { + let client: MetadataClient | null = null; + function Probe() { + client = useMetadataClient(); + return null; + } + render( + + + , + ); + return client as unknown as MetadataClient; +} + +/** What the console client's server answers: every write lands, unless refused. */ +let refuse = false; +beforeEach(() => { + refuse = false; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + if (refuse) return json(409, { success: false, error: { code: 'CONFLICT', message: 'refused' } }); + const url = String(input); + if (url.includes('/publish-drafts')) { + return json(200, { success: true, data: { success: true, outcome: 'published', published: [], failed: [] } }); + } + return json(200, { success: true, data: { success: true, version: 2 } }); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** + * One write of each class, each addressed to a view (the package-wide door + * names a package, and a package can carry views). + */ +const VIEW_WRITES: Array<[string, (client: MetadataClient) => Promise]> = [ + ['save (draft)', (c) => c.save('view', 'account.mine', { name: 'account.mine', object: 'account' }, { mode: 'draft' })], + ['save (live)', (c) => c.save('view', 'account.mine', { name: 'account.mine', object: 'account' })], + ['publish', (c) => c.publish('view', 'account.mine')], + ['publishDraft', (c) => c.publishDraft('view', 'account.mine')], + ['reset', (c) => c.reset('view', 'account.mine')], + ['rollback', (c) => c.rollback('view', 'account.mine', 1)], + ['publishPackageDrafts', (c) => c.publishPackageDrafts('com.acme.crm')], +]; + +/** Store the list the way the console's eager read does, and read it once. */ +async function settleList(adapter: ObjectStackAdapter, viewReads: () => number) { + await adapter.getClient().meta.getItems('view'); + await adapter.listViews('account'); + expect(viewReads()).toBe(1); +} + +describe('each console view write drops the stored list: the next read asks again (objectui#13057)', () => { + it.each(VIEW_WRITES)('%s', async (_name, write) => { + const { adapter, viewReads } = makeAdapter(); + const client = consoleClient(adapter); + await settleList(adapter, viewReads); + await write(client); + await adapter.listViews('account'); + await adapter.listViewOverrides('account'); + // One more read, then served from what it stored. + expect(viewReads()).toBe(2); + }); + + it('a refused view write drops it too: what landed before it is not known', async () => { + const { adapter, viewReads } = makeAdapter(); + const client = consoleClient(adapter); + await settleList(adapter, viewReads); + refuse = true; + await expect(client.save('view', 'account.mine', { name: 'account.mine', object: 'account' })).rejects.toMatchObject({ + status: 409, + }); + await adapter.listViews('account'); + expect(viewReads()).toBe(2); + }); +}); + +describe('controls (objectui#13057)', () => { + it('a write of another type keeps the stored list', async () => { + const { adapter, viewReads } = makeAdapter(); + const client = consoleClient(adapter); + await settleList(adapter, viewReads); + await client.save('object', 'lead', { name: 'lead', fields: {} }, { mode: 'draft' }); + await client.reset('flow', 'lead_flow'); + await adapter.listViews('account'); + expect(viewReads()).toBe(1); + }); + + it('a write hands back its own answer, untouched by the drop', async () => { + const { adapter } = makeAdapter(); + const client = consoleClient(adapter); + await expect(client.publish('view', 'account.mine')).resolves.toMatchObject({ data: { version: 2 } }); + }); + + it('with no adapter in context the writes still land', async () => { + let client: MetadataClient | null = null; + function Probe() { + client = useMetadataClient(); + return null; + } + render(); + await expect((client as unknown as MetadataClient).publish('view', 'account.mine')).resolves.toMatchObject({ + data: { version: 2 }, + }); + }); +}); + +describe('the writers are enumerated, so a new one cannot be silent (objectui#13057)', () => { + it('every MetadataClient door that sends a write is one the console client drops after', () => { + const proto = MetadataClient.prototype as unknown as Record; + const members = Object.getOwnPropertyNames(proto).filter( + (name) => name !== 'constructor' && typeof proto[name] === 'function', + ); + // Positive control: the write funnel the scan keys on is still there. + expect(members).toContain('sendWrite'); + const writeDoors = members + .filter((name) => name !== 'sendWrite') + .filter((name) => /\bthis\.sendWrite\(/.test(mask(String(proto[name])))) + .sort(); + expect(writeDoors.length).toBeGreaterThan(0); + expect(writeDoors).toEqual([...ITEM_WRITE_DOORS, ...PACKAGE_WRITE_DOORS].sort()); + }); + + it('every console MetadataClient is built by useMetadataClient (the provider builds a read-only one)', () => { + const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const builders: string[] = []; + for (const entry of readdirSync(srcRoot, { recursive: true, encoding: 'utf8' })) { + if (!/\.(ts|tsx)$/.test(entry) || /\.test\.(ts|tsx)$/.test(entry) || entry.includes('__tests__')) continue; + const code = mask(readFileSync(path.join(srcRoot, entry), 'utf8')); + if (/(? void): MetadataClient { + const doors = client as unknown as Record Promise>; + for (const door of ITEM_WRITE_DOORS) { + const write = doors[door].bind(client); + doors[door] = (...args: unknown[]) => (args[0] === 'view' ? write(...args).finally(drop) : write(...args)); + } + for (const door of PACKAGE_WRITE_DOORS) { + const write = doors[door].bind(client); + doors[door] = (...args: unknown[]) => write(...args).finally(drop); + } + return client; +} diff --git a/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts b/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts index 7f801a177b..307707c140 100644 --- a/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts +++ b/packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts @@ -13,8 +13,9 @@ import { MetadataCache } from './MetadataCache'; * `share` and the in-flight half of `invalidate` (objectui#13044). * * `share` is the door for a caller whose contract is "ask the server": it - * joins a request already in flight, and neither reads nor writes a settled - * entry. `invalidate` detaches the request in flight, so a + * joins a request already in flight and is never answered from a settled + * entry. Since objectui#13057 its landing is stored, so a later `get` may be + * answered from it. `invalidate` detaches the request in flight, so a * request sent before an invalidation neither answers a later caller nor * overwrites a newer answer. */ @@ -41,17 +42,30 @@ describe('MetadataCache.share (objectui#13044)', () => { expect(calls).toBe(1); }); - it('is never answered from the settled entry, and stores nothing', async () => { + it('is never answered from the settled entry, and stores what it was answered (objectui#13057)', async () => { const cache = new MetadataCache(); let calls = 0; const fetcher = async () => `answer ${(calls += 1)}`; expect(await cache.get('k', fetcher)).toBe('answer 1'); + // A settled entry is there; `share` asks the server anyway. expect(await cache.share('k', fetcher)).toBe('answer 2'); - expect(await cache.get('k', fetcher)).toBe('answer 1'); + // ...and what it was answered is what a later `get` is answered from. + expect(await cache.get('k', fetcher)).toBe('answer 2'); expect(await cache.share('fresh', fetcher)).toBe('answer 3'); - expect(cache.has('fresh')).toBe(false); + expect(cache.has('fresh')).toBe(true); + expect(await cache.get('fresh', fetcher)).toBe('answer 3'); expect(calls).toBe(3); }); + + it('a request detached by an invalidation stores nothing (objectui#13057)', async () => { + const cache = new MetadataCache(); + const wire = deferred(); + const shared = cache.share('k', () => wire.promise); + cache.invalidate('k'); + wire.resolve('before the invalidation'); + expect(await shared).toBe('before the invalidation'); + expect(cache.has('k')).toBe(false); + }); }); describe('MetadataCache.invalidate detaches the read in flight (objectui#13044)', () => { diff --git a/packages/data-objectstack/src/cache/MetadataCache.ts b/packages/data-objectstack/src/cache/MetadataCache.ts index a45419d8cb..ef17fde15e 100644 --- a/packages/data-objectstack/src/cache/MetadataCache.ts +++ b/packages/data-objectstack/src/cache/MetadataCache.ts @@ -135,34 +135,38 @@ export class MetadataCache { } } - return this.load(key, fetcher, true); + return this.load(key, fetcher); } /** - * Join the read in flight for `key`, or start one. Nothing is read from or - * written to the settled entries: a caller of this always gets an answer - * from a request that was on the wire when it asked, and its answer is - * never served to anyone who asks after it lands. + * Join the read in flight for `key`, or start one, and never answer from the + * settled entry: a caller of this always gets an answer from a request that + * was on the wire when it asked. * * objectui#13044. This is the door for a caller whose own contract is "ask * the server", which should still not put a second identical request on the * wire while one is already there. + * + * objectui#13057. The landing is stored, exactly as one {@link get} started + * would store it, so a later {@link get} for `key` may be answered from it. + * The caller asked the server; a reader that may be served what the cache + * already holds is served the answer that caller just received. */ share(key: string, fetcher: () => Promise): Promise { - return this.load(key, fetcher, false); + return this.load(key, fetcher); } /** * The in-flight half of {@link get} and {@link share}: join the request in * flight for `key`, or start one. * - * A landing is stored (when `store`), and its in-flight entry cleared, only + * A landing is stored, and its in-flight entry cleared, only * while it is still the request registered for `key` (objectui#13044). {@link invalidate} * and {@link clear} detach the request in flight, so a request sent before an * invalidation can neither answer a caller who asks after it nor write its * answer over a newer one. */ - private load(key: string, fetcher: () => Promise, store: boolean): Promise { + private load(key: string, fetcher: () => Promise): Promise { const existing = this.inflight.get(key); if (existing) { this.stats.coalesced++; @@ -176,7 +180,7 @@ export class MetadataCache { const run = async (): Promise => { try { const data = await fetcher(); - if (store && this.inflight.get(key) === self.promise) this.set(key, data); + if (this.inflight.get(key) === self.promise) this.set(key, data); return data; } finally { if (this.inflight.get(key) === self.promise) this.inflight.delete(key); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 8ef74198e0..9ef6559106 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -908,13 +908,25 @@ export function serializeOrderBy(orderby: QueryParams['$orderby']): string | und * `MetadataProvider`'s eager `view` read (through {@link ObjectStackAdapter.getClient}), * {@link ObjectStackAdapter.listViews} and * {@link ObjectStackAdapter.listViewOverrides}. They share the request IN - * FLIGHT under this key: whoever asks while one is on the wire joins it. No - * answer is stored under it, so a reader that asks after a request landed - * asks the server again, exactly as before (objectui#13044 ruling 6268839381: - * a settled list would have needed every refresh, view write and organization - * switch in the console to drop it, and several of those paths write through - * a client this adapter never sees). The draft-overlaid list (`listViews` - * with `previewDrafts`) is a different request on a different client. + * FLIGHT under this key: whoever asks while one is on the wire joins it. + * + * The answer is also stored under it (objectui#13057, completing objectui#13044 + * ruling 6268839381 (a)-(c)). The client read still always asks the server; the + * adapter's own two readers are answered from what it stored, so the console's + * order (the eager read lands, then the object page reads the list and the + * overrides) costs one request. The settled entry is dropped by every path that + * should see a fresh list: + * + * - every view write: {@link ObjectStackAdapter.invalidateViewKeys}, the client + * write wrap in `installViewListShare`, and app-shell's console + * `MetadataClient` (`useMetadataClient`), which drops it after each of its + * view-capable write doors; + * - a user refresh (app-shell `ObjectView`'s `refreshData`); + * - an organization or session switch, and the publish pulse, which clear the + * whole cache (app-shell `MetadataProvider`). + * + * The draft-overlaid list (`listViews` with `previewDrafts`) is a different + * request on a different client, and never fills this key. */ const VIEW_LIST_KEY = 'list:view'; @@ -4204,12 +4216,13 @@ export class ObjectStackAdapter implements DataSource { * read the same `GET /api/v1/meta/view`. Each used to put its own request on * the wire, so one list-page open asked the server three times for one URL. * - * A caller of the client keeps its contract, "ask the server": nothing is - * answered from, or written to, a settled entry. What changes is only that - * it joins a request already in flight for the same URL instead of sending - * a second one (see {@link MetadataCache.share}). Only the bare call is - * routed: any argument after the type changes the URL, so such a call goes - * straight to the SDK. + * A caller of the client keeps its contract, "ask the server": it is never + * answered from a settled entry. It joins a request already in flight for + * the same URL instead of sending a second one, and its answer is stored + * under {@link VIEW_LIST_KEY} (see {@link MetadataCache.share}), which is + * what {@link readViewList} answers this adapter's own readers from + * (objectui#13057). Only the bare call is routed: any argument after the + * type changes the URL, so such a call goes straight to the SDK. * * Every caller gets its own copy of the answer. Before this, every reader * got its own parsed response, so a reader that changed what it was handed @@ -4230,9 +4243,9 @@ export class ObjectStackAdapter implements DataSource { // A write to a `view` row through this client, from any caller (a page // that saves a view with `getClient().meta.saveItem` directly, not only - // through {@link invalidateViewKeys}), detaches the list request in - // flight once it settles, so a read asked after the write never joins a - // request sent before it. + // through {@link invalidateViewKeys}), drops the stored list and detaches + // the list request in flight once it settles, so a read asked after the + // write is never answered by a list read before it. for (const door of ['saveItem', 'deleteItem', 'publishItem', 'rollbackItem'] as const) { const write = meta[door] as ((...args: any[]) => Promise) | undefined; if (typeof write !== 'function') continue; @@ -4245,14 +4258,17 @@ export class ObjectStackAdapter implements DataSource { } /** - * The published view list for this adapter's own readers: the client's - * read, which joins the request in flight for {@link VIEW_LIST_KEY} or - * starts one (objectui#13044). Never a stored answer; always a copy the - * caller may keep. A write to a view row detaches the request in flight - * through {@link invalidateViewKeys}, so a read asked after the write never - * joins one sent before it. + * The published view list for this adapter's own readers + * ({@link listViews}, {@link listViewOverrides}): the answer stored under + * {@link VIEW_LIST_KEY} while it holds one (objectui#13057), else the + * client's read, which joins the request in flight for that key or starts + * one, and stores its answer (objectui#13044). Always a copy the caller may + * keep. Every path that should see a fresh list drops the key first; the + * list is in {@link VIEW_LIST_KEY}'s doc. */ private readViewList(): Promise { + const settled = this.metadataCache.getCachedSync(VIEW_LIST_KEY); + if (settled !== undefined) return Promise.resolve(structuredClone(settled)); return this.client.meta.getItems('view'); } @@ -5544,8 +5560,9 @@ export class ObjectStackAdapter implements DataSource { // suite records, so a reordering is a visible decision rather than drift. this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`); this.metadataCache.invalidate?.(`view-overrides:${objectName}`); - // The list both readers derive from (objectui#13044): a read asked after - // this write must not join a list request sent before it. + // The list both readers derive from (objectui#13044, objectui#13057): a + // read asked after this write is answered neither by the stored list nor + // by a list request sent before it. this.metadataCache.invalidate?.(VIEW_LIST_KEY); } diff --git a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts index fd44f2d786..b522f8f970 100644 --- a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts +++ b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts @@ -19,17 +19,20 @@ import { ObjectStackAdapter } from './index'; * | `getView` | `view:{object}:{viewId}` | * | `listViewOverrides` | `view-overrides:{object}` | * - * `listViews` has no key of its own. Five write paths used to invalidate a + * `listViews` has no per-object key of its own. Five write paths used to invalidate a * `views:{object}` key that no read path has ever populated — five permanent * no-ops. They are gone (#3778); the surviving invalidations still name the * keys that DO have readers, so the deletion cannot be mistaken for "cache * invalidation was dropped". * - * `listViews` stays uncached. objectui#13044 lets it, `listViewOverrides` and - * the console's eager view read JOIN one `GET /meta/view` already in flight, - * under the key `list:view`, and stores nothing there; every write path drops - * that key through the seam with the two keys above, so a read asked after a - * write never joins a request sent before it. + * objectui#13044 lets `listViews`, `listViewOverrides` and the console's eager + * view read JOIN one `GET /meta/view` already in flight, under the key + * `list:view`. objectui#13057 stores that answer there, and `listViews` and + * `listViewOverrides` read the stored list while it is held (settled within an + * open), so every write path drops that key through the seam with the two keys + * above: a read asked after a write is answered neither by the stored list nor + * by a request sent before it. The console's own drops of it (a user refresh, + * its `MetadataClient` view writes) are pinned in app-shell, where they live. * * ## objectui#4363 — every write path names BOTH keys * @@ -159,23 +162,31 @@ const VIEW = { }; describe('view metadata cache — invalidation names only keys with readers (#3778)', () => { - it('listViews reads the transport on every call and consults no cache key', async () => { + it('listViews reads the stored list:view while one is held, else the transport, and no other key (objectui#13057)', async () => { const { ds, getItems, cacheReads } = makeDS({ items: [VIEW] }); const probed: string[] = []; + const cache: { held?: unknown } = {}; ds.metadataCache.getCachedSync = (key: string) => { probed.push(key); - return undefined; + return cache.held; }; + // Nothing held: each call goes to the transport. Storing the answer is + // the real client's (its `getItems` wrap); this stand-in client has none. await ds.listViews('account'); await ds.listViews('account'); - - // Uncached: two calls, two round trips. objectui#13044 shares only a - // request already in flight (on the real client); nothing is stored. expect(getItems).toHaveBeenCalledTimes(2); expect(getItems).toHaveBeenCalledWith('view'); + + // A list held under `list:view` answers the read: no third round trip. + cache.held = { items: [VIEW] }; + const views = await ds.listViews('account'); + expect(views.map((v: any) => v.name)).toEqual(['account.all']); + expect(getItems).toHaveBeenCalledTimes(2); + + // The one key it consults is the list; no per-object key is read. expect(cacheReads).toEqual([]); - expect(probed).toEqual([]); + expect(probed).toEqual(['list:view', 'list:view', 'list:view']); }); it('listViewOverrides reads back under the key the write paths invalidate', async () => {