diff --git a/.changeset/11799-studio-expected-misses.md b/.changeset/11799-studio-expected-misses.md new file mode 100644 index 0000000000..0bfa431697 --- /dev/null +++ b/.changeset/11799-studio-expected-misses.md @@ -0,0 +1,12 @@ +--- +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +--- + +Studio stops asking questions whose expected answer is an error, so the browser console no longer fills with red lines that hide real failures (objectui#11799). + +- **`MetadataClient.getDraft()` reads the drafts ledger first.** It reads `GET /meta/_drafts` through the client's shared read, and sends `GET /meta/:type/:name?state=draft` only when the ledger lists the name. An item with no draft no longer logs a 404. The method still resolves `null` when there is no draft and the draft envelope when there is one. A draft saved a moment ago is found, because a write drops the ledger read that was pending when it landed. A ledger the caller cannot read (403, 501, a network fault) counts as unknown, and the item read is sent as before. +- **Studio reads an item's published baseline only when it uses it.** The Interfaces pillar's leaves and the Automations pillar's flows read `GET /meta/:type/:name/layers` only for an item with no pending draft, and the Interfaces pillar reads none for an app found only in the drafts ledger. An item that was never saved always has a draft, so it no longer logs a 404 for `/layers`. Opening a leaf or a flow now waits for the ledger before it reads the item, one round trip more than the parallel reads it replaces. The Data pillar still reads `/layers` for an object that was never published: that answer is how it tells it has no records table yet. +- **The AI usage indicator asks nothing while AI is off.** It reads `GET /ai/usage` only while the agent catalog at its base lists an agent, the signal the console gates every AI entry point on. On an open-edition server, Studio's copilot dock no longer logs a 501 for it each time it mounts. + +No REST answer, export, prop or type member changes. diff --git a/packages/app-shell/src/layout/AiUsageIndicator.tsx b/packages/app-shell/src/layout/AiUsageIndicator.tsx index 07e4dbb171..b9c2d78729 100644 --- a/packages/app-shell/src/layout/AiUsageIndicator.tsx +++ b/packages/app-shell/src/layout/AiUsageIndicator.tsx @@ -25,10 +25,19 @@ * (endpoint absent on an older backend, OSS, no seat) or the pool is unmetered, the * whole indicator renders nothing — a missing endpoint degrades to no widget, never * a broken one. + * + * objectui#11799 — and with AI off it asks nothing. AI is on for this viewer when + * the access-filtered agent catalog at the same base (`GET {apiBase}/agents`) + * lists an agent: the signal `useAiSurfaceEnabled` gates every AI entry point + * on, whose header says why it is not discovery's `services.ai`. An open-edition + * server answers that catalog with its empty-list courtesy and every other + * `/ai/*` route with 501, so the Studio dock — drawn while the catalog loads — + * used to log a 501 for the usage read on every mount. */ import * as React from 'react'; import { cn, Button, Popover, PopoverTrigger, PopoverContent } from '@object-ui/components'; import { formatNumber, useObjectTranslation } from '@object-ui/i18n'; +import { useAgents } from '@object-ui/plugin-chatbot'; import { useAiUsage, type AiMeterUsage } from '../hooks/useAiUsage.js'; import { cloudConsoleUrl } from '../console/marketplace/marketplaceApi.js'; @@ -130,7 +139,11 @@ export interface AiUsageIndicatorProps { */ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsageIndicatorProps) { const { t, language } = useObjectTranslation(); - const { usage } = useAiUsage({ apiBase, enabled }); + // objectui#11799 — no usage read until the catalog lists an agent (see the + // file header). The catalog read is shared with the dock's own: `useAgents` + // keeps one request per base in flight and its answer for a short while. + const { agents } = useAgents({ apiBase, enabled }); + const { usage } = useAiUsage({ apiBase, enabled: enabled && agents.length > 0 }); // "Now", read OUTSIDE render (react-hooks/purity forbids `Date.now()` in the // render body — it is non-deterministic and the compiler assumes render can diff --git a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.aiOff-11799.test.tsx b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.aiOff-11799.test.tsx new file mode 100644 index 0000000000..8a55a13f1a --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.aiOff-11799.test.tsx @@ -0,0 +1,121 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#11799 — with AI off, the usage indicator never asks for usage. + * + * An open-edition server answers `GET /api/v1/ai/agents` with its empty-list + * courtesy and every other `/ai/*` route with 501. The Studio dock is drawn + * while the agent catalog loads, so its indicator mounted, read + * `GET /api/v1/ai/usage` and logged a 501 on every mount. The indicator now + * reads usage only while the catalog at its own base lists an agent, the signal + * `useAiSurfaceEnabled` gates every AI entry point on. + * + * The REAL `useAgents` and the REAL `useAiUsage`, over a stubbed fetch that + * records every URL. Each case uses its own base, because `useAgents` keeps a + * catalog answer per base for a short while. + */ +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { AI_USAGE_REFRESH_EVENT } from '@object-ui/plugin-chatbot'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }), +})); +vi.mock('../../console/marketplace/marketplaceApi', () => ({ + cloudConsoleUrl: () => 'https://cloud.example', +})); + +import { AiUsageIndicator } from '../AiUsageIndicator'; + +const POOL = { + planType: 'free', + fraction: 0.3, + unmetered: false, + resetKind: 'daily', + resetsAt: null, + upgrade: false, + topUp: false, +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** A server whose catalog lists `agents`, recording every URL it is asked. */ +function serverWith(agents: Array<{ name: string }>, hold?: Promise) { + const urls: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url.endsWith('/agents')) { + if (hold) await hold; + return json(200, { success: true, data: { agents } }); + } + if (url.endsWith('/usage')) return agents.length ? json(200, { pool: POOL }) : json(501, { error: { code: 'NOT_IMPLEMENTED' } }); + return json(404, {}); + }), + ); + return { usageReads: () => urls.filter((u) => u.endsWith('/usage')).length, urls }; +} + +async function settle() { + await act(async () => { + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +/** What the indicator re-reads on: the chat engine's nudge, and tab re-focus. */ +async function nudge() { + await act(async () => { + window.dispatchEvent(new CustomEvent(AI_USAGE_REFRESH_EVENT)); + document.dispatchEvent(new Event('visibilitychange')); + }); + await settle(); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('AiUsageIndicator — no usage read with AI off (objectui#11799)', () => { + it('an empty agent catalog: /ai/usage is never requested, not on mount and not on a nudge', async () => { + const server = serverWith([]); + const { container } = render(); + await settle(); + await nudge(); + expect(server.usageReads()).toBe(0); + expect(container).toBeEmptyDOMElement(); + // The "never" above is read after the catalog answered empty. + expect(server.urls).toContain('/api/v1/ai-off/agents'); + }); + + it('while the catalog is still loading, usage is not read yet', async () => { + let release!: () => void; + const server = serverWith([{ name: 'ask' }], new Promise((r) => (release = r))); + render(); + await waitFor(() => expect(server.urls).toContain('/api/v1/ai-loading/agents')); + await settle(); + expect(server.usageReads()).toBe(0); + + await act(async () => release()); + await waitFor(() => expect(server.usageReads()).toBe(1)); + }); + + it('control: a catalog that lists an agent reads usage on mount and again on a nudge', async () => { + const server = serverWith([{ name: 'ask' }]); + render(); + expect(await screen.findByTestId('ai-usage-indicator')).toBeInTheDocument(); + expect(server.usageReads()).toBe(1); + + await nudge(); + expect(server.usageReads()).toBeGreaterThan(1); + }); +}); diff --git a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx index 4f7630b1d5..383b470ab8 100644 --- a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx @@ -31,6 +31,13 @@ vi.mock('../../console/marketplace/marketplaceApi', () => ({ cloudConsoleUrl: () => 'https://cloud.example', })); vi.mock('../../hooks/useAiUsage', () => ({ useAiUsage: vi.fn() })); +// objectui#11799 — the indicator reads usage only while the agent catalog lists +// an agent, so every case here is a viewer with AI on. AI off is pinned in +// `AiUsageIndicator.aiOff-11799.test.tsx`, over the real catalog hook. +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => ({ + ...(await importOriginal>()), + useAgents: () => ({ agents: [{ name: 'ask', label: 'Ask' }], isLoading: false, error: undefined, refetch: () => {} }), +})); import { useAiUsage } from '../../hooks/useAiUsage'; import { AiUsageIndicator } from '../AiUsageIndicator'; diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.expectedMisses-11799.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.expectedMisses-11799.test.tsx new file mode 100644 index 0000000000..046d5645c4 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.expectedMisses-11799.test.tsx @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11799 — opening an item in Studio asks no question whose expected + * answer is a 404. + * + * Every item Studio opened sent `GET /meta/:type/:name?state=draft`, which + * answers 404 when there is no draft, and `GET /meta/:type/:name/layers`, which + * answers 404 for an item that has never been saved. The console filled with + * red lines for expected answers. Now: + * - whether a draft exists comes from the drafts ledger (`GET /meta/_drafts`, + * the client's shared read), and the draft is read only when listed; + * - the published baseline is read only when the load uses it, for an item + * with no pending draft, so an unsaved item never asks `/layers`; + * - the Interfaces pillar's app found only in the ledger reads no `/layers`. + * + * The REAL pillars over a REAL `MetadataClient`, whose transport is an + * in-memory server answering as the framework does: a 404 for `?state=draft` + * with no draft and for `/layers` with no layer. Requests are counted on it. + * + * Not covered, said here so it does not read as covered: the Data pillar + * still reads `/layers` for a draft-only object. That read is how it learns + * there is no published definition yet (`hasBaseline`) and which fields the + * data API can select, both used even with a draft, and nothing it holds + * answers either question for certain. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +// Module-scope import of the lazily loaded dashboard renderer (AGENTS.md, +// flaky-test discipline). +import '@object-ui/plugin-dashboard'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const PKG = 'com.acme.app'; +const NAV = [ + { id: 'n1', type: 'dashboard', label: 'Customers', dashboardName: 'customer_dashboard' }, + { id: 'n2', type: 'dashboard', label: 'Fresh', dashboardName: 'fresh_dashboard' }, +]; +const dashboard = (name: string) => ({ name, label: name, widgets: [] }); +const flow = (name: string) => ({ + name, + label: name, + type: 'autolaunched', + status: 'obsolete', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +interface Row { + type: string; + name: string; + body: Record; + packageId: string | null; +} + +/** The server: published rows, draft rows, and every request it was asked. */ +const server = { + active: new Map(), + drafts: new Map(), + requests: [] as Array<{ method: string; path: string; search: string; status: number }>, +}; + +function put(rows: Map, type: string, name: string, body: Record) { + rows.set(`${type}/${name}`, { type, name, body, packageId: PKG }); +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input), 'http://studio.test'); + const q = url.searchParams; + let status = 200; + let body: unknown = []; + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (meta) { + const seg = meta[1].split('/').map(decodeURIComponent); + if (seg[0] === '_drafts') { + body = { + drafts: [...server.drafts.values()] + .filter((d) => (!q.get('type') || d.type === q.get('type')) && (!q.get('packageId') || d.packageId === q.get('packageId'))) + .map((d) => ({ type: d.type, name: d.name, packageId: d.packageId, organizationId: null })), + }; + } else if (seg.length === 1) { + body = { items: [...server.active.values()].filter((r) => r.type === seg[0]).map((r) => r.body) }; + } else if (seg.length === 3 && seg[2] === 'layers') { + const row = server.active.get(`${seg[0]}/${seg[1]}`); + if (row) body = { code: null, overlay: row.body, overlayScope: 'env', effective: row.body }; + else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2 && method === 'PUT') { + const item = JSON.parse(String(init?.body ?? '{}')) as Record; + server.drafts.set(`${seg[0]}/${seg[1]}`, { type: seg[0], name: seg[1], body: item, packageId: q.get('package') }); + body = { type: seg[0], name: seg[1], state: 'draft' }; + } else if (seg.length === 2) { + const draftRead = q.get('state') === 'draft'; + const row = (draftRead ? server.drafts : server.active).get(`${seg[0]}/${seg[1]}`); + if (row) body = { type: seg[0], name: seg[1], item: draftRead ? { ...row.body, _draft: true } : row.body }; + else [status, body] = [404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }]; + } + } + server.requests.push({ method, path: url.pathname, search: url.search, status }); + return json(status, body); +} + +const transport = vi.fn(serve) as unknown as typeof fetch; +const client = new MetadataClient({ baseUrl: '', fetch: transport }); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => client, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => [{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]) }; +}); + +// The Data pillar's records grid calls `find()` on this adapter (objectui#8620). +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +import { AutomationsPillar, DataPillar, InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview'; +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; + +const dataSource = createEmptyDataSource(); +failOnAbsorbedFetchError(); +registerMetadataPreview('dashboard', DashboardPreview); + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +beforeEach(() => { + server.active.clear(); + server.drafts.clear(); + server.requests = []; + // The Automations pillar's flow-status probe goes through the global fetch. + vi.stubGlobal('fetch', transport); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** Let every queued load run to the end. */ +async function settle() { + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +/** GETs of `/meta//` with `suffix` (`?state=draft` or `/layers`). */ +function asked(type: string, name: string, suffix: '?state=draft' | '/layers'): number { + const path = `/api/v1/meta/${type}/${name}`; + return server.requests.filter( + (r) => + r.method === 'GET' && + (suffix === '/layers' ? r.path === `${path}/layers` : r.path === path && r.search.includes('state=draft')), + ).length; +} + +const misses = () => server.requests.filter((r) => r.status === 404).map((r) => `${r.path}${r.search}`); + +function interfacesTree() { + return ( + + + + ); +} + +describe('Interfaces pillar — opening leaves asks no 404 question (objectui#11799)', () => { + beforeEach(() => { + put(server.active, 'app', 'acme_app', { name: 'acme_app', label: 'Acme', navigation: NAV }); + put(server.active, 'dashboard', 'customer_dashboard', dashboard('customer_dashboard')); + // Created in Studio and never published: a draft and no layer. + put(server.drafts, 'dashboard', 'fresh_dashboard', dashboard('fresh_dashboard')); + }); + + it('a saved leaf with no draft reads its baseline and no draft; an unsaved leaf reads its draft and no baseline', async () => { + render(interfacesTree()); + await waitFor(() => expect(asked('dashboard', 'customer_dashboard', '/layers')).toBe(1)); + await settle(); + expect(asked('dashboard', 'customer_dashboard', '?state=draft')).toBe(0); + expect(asked('app', 'acme_app', '?state=draft')).toBe(0); + + fireEvent.click(await screen.findByTitle('dashboard · fresh_dashboard')); + await waitFor(() => expect(asked('dashboard', 'fresh_dashboard', '?state=draft')).toBe(1)); + await settle(); + expect(asked('dashboard', 'fresh_dashboard', '/layers')).toBe(0); + expect(misses()).toEqual([]); + }); + + it('a draft saved a moment ago is found when the leaf is opened again: no stale "absent"', async () => { + render(interfacesTree()); + await waitFor(() => expect(asked('dashboard', 'customer_dashboard', '/layers')).toBe(1)); + await settle(); + expect(asked('dashboard', 'customer_dashboard', '?state=draft')).toBe(0); + + // A draft lands for the open leaf through the transport Studio writes on. + await act(async () => { + await client.save('dashboard', 'customer_dashboard', { ...dashboard('customer_dashboard'), label: 'Edited' }, { + mode: 'draft', + packageId: PKG, + }); + }); + + fireEvent.click(await screen.findByTitle('dashboard · fresh_dashboard')); + await waitFor(() => expect(asked('dashboard', 'fresh_dashboard', '?state=draft')).toBe(1)); + fireEvent.click(await screen.findByTitle('dashboard · customer_dashboard')); + await waitFor(() => expect(asked('dashboard', 'customer_dashboard', '?state=draft')).toBe(1)); + await settle(); + // It now has a draft, so its baseline is not read a second time. + expect(asked('dashboard', 'customer_dashboard', '/layers')).toBe(1); + expect(misses()).toEqual([]); + }); +}); + +describe('Interfaces pillar — an app found only in the drafts ledger (objectui#11799)', () => { + it('reads its draft and no /layers', async () => { + put(server.drafts, 'app', 'acme_app', { name: 'acme_app', label: 'Acme', navigation: NAV }); + put(server.active, 'dashboard', 'customer_dashboard', dashboard('customer_dashboard')); + render(interfacesTree()); + await waitFor(() => expect(asked('app', 'acme_app', '?state=draft')).toBe(1)); + await waitFor(() => expect(asked('dashboard', 'customer_dashboard', '/layers')).toBe(1)); + await settle(); + expect(asked('app', 'acme_app', '/layers')).toBe(0); + expect(misses()).toEqual([]); + }); + + it('control: a published app reads its baseline', async () => { + put(server.active, 'app', 'acme_app', { name: 'acme_app', label: 'Acme', navigation: NAV }); + put(server.active, 'dashboard', 'customer_dashboard', dashboard('customer_dashboard')); + render(interfacesTree()); + await waitFor(() => expect(asked('app', 'acme_app', '/layers')).toBe(1)); + await settle(); + expect(asked('app', 'acme_app', '?state=draft')).toBe(0); + }); +}); + +describe('Automations pillar — opening flows asks no 404 question (objectui#11799)', () => { + it('a saved flow with no draft reads its baseline and no draft; an unsaved flow reads its draft and no baseline', async () => { + put(server.active, 'flow', 'p_flow', flow('p_flow')); + put(server.drafts, 'flow', 'fresh_flow', flow('fresh_flow')); + render( + + + , + ); + await waitFor(() => expect(asked('flow', 'p_flow', '/layers')).toBe(1)); + await settle(); + expect(asked('flow', 'p_flow', '?state=draft')).toBe(0); + + fireEvent.click((await screen.findAllByText('fresh_flow'))[0]); + await waitFor(() => expect(asked('flow', 'fresh_flow', '?state=draft')).toBe(1)); + await settle(); + expect(asked('flow', 'fresh_flow', '/layers')).toBe(0); + expect(misses()).toEqual([]); + }); +}); + +describe('Data pillar — the draft read follows the ledger (objectui#11799)', () => { + it('a saved object with no draft is not asked for one; an unsaved object is', async () => { + put(server.active, 'object', 'acme_account', { name: 'acme_account', label: 'Account', fields: { title: { type: 'text' } } }); + put(server.drafts, 'object', 'acme_fresh', { name: 'acme_fresh', label: 'Fresh', fields: { title: { type: 'text' } } }); + render( + + + , + ); + await waitFor(() => expect(asked('object', 'acme_account', '/layers')).toBe(1)); + await settle(); + expect(asked('object', 'acme_account', '?state=draft')).toBe(0); + + fireEvent.click(await screen.findByText('acme_fresh')); + await waitFor(() => expect(asked('object', 'acme_fresh', '?state=draft')).toBe(1)); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index f434babcee..58775c559f 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -2896,13 +2896,17 @@ export function InterfacesPillar({ } setAppLabel(label); setAppName(name); + // objectui#11799 — an app the published list does not hold was found + // in the drafts ledger: it has never been saved, so it has no layer to + // read and `/layers` would answer 404. Its draft is the whole document. + const saved = !!published?.[0]?.name; const [layRaw, appDraftResp] = await Promise.all([ - client.layered>('app', name), + saved ? client.layered>('app', name) : null, client.getDraft>('app', name).catch(() => null), ]); if (cancelled) return; - const lay = layRaw as { effective?: Record; code?: Record }; - const eff = (lay.effective ?? lay.code ?? {}) as Record; + const lay = layRaw as { effective?: Record; code?: Record } | null; + const eff = (lay?.effective ?? lay?.code ?? {}) as Record; const appDraftBody = extractDraftBody(appDraftResp); // A served draft is the whole document — taken as-is, never spread // over the published layer (objectui#10765; the rule is stated once @@ -3073,15 +3077,19 @@ export function InterfacesPillar({ setSelection(null); (async () => { try { - const [lay, draftResp] = await Promise.all([ - client.layered>(current.type, current.name), - client.getDraft>(current.type, current.name).catch(() => null), - ]); + const draftResp = await client + .getDraft>(current.type, current.name) + .catch(() => null); if (cancelled) return; - const baseline = ((lay as { effective?: unknown; code?: unknown }).effective ?? - (lay as { code?: unknown }).code ?? - {}) as Record; const body = extractDraftBody(draftResp); + // objectui#11799 — the baseline is read only when it is used, for a + // leaf with no pending draft. An unsaved leaf always has one, so it + // never asks `/layers`, which answers 404 for a name with no layer. + const lay = body ? null : await client.layered>(current.type, current.name); + if (cancelled) return; + const baseline = ((lay as { effective?: unknown; code?: unknown } | null)?.effective ?? + (lay as { code?: unknown } | null)?.code ?? + {}) as Record; // Served draft as-is, baseline only without one (objectui#10765): a // spread over `effective` resurrects every key the draft deleted. setDraft(body ?? baseline); @@ -5644,14 +5652,18 @@ export function AutomationsPillar({ setSelection(null); (async () => { try { - const [layRaw, draftResp] = await Promise.all([ - client.layered>('flow', current.name), - client.getDraft>('flow', current.name).catch(() => null), - ]); + const draftResp = await client + .getDraft>('flow', current.name) + .catch(() => null); if (cancelled) return; - const lay = layRaw as { effective?: Record; code?: Record }; - const baseline = (lay.effective ?? lay.code ?? {}) as Record; const draftBody = extractDraftBody(draftResp); + // objectui#11799 — the baseline is read only for a flow with no + // pending draft, the one case it is used. An unsaved flow always has + // one, so it never asks `/layers`, which answers 404 for it. + const layRaw = draftBody ? null : await client.layered>('flow', current.name); + if (cancelled) return; + const lay = layRaw as { effective?: Record; code?: Record } | null; + const baseline = (lay?.effective ?? lay?.code ?? {}) as Record; // Served draft as-is, baseline only without one (objectui#10765). setDraft(draftBody ?? baseline); setDraftFor(`flow:${current.name}`); diff --git a/packages/data-objectstack/src/metadata-client.draftLedger-11799.test.ts b/packages/data-objectstack/src/metadata-client.draftLedger-11799.test.ts new file mode 100644 index 0000000000..d82112dac0 --- /dev/null +++ b/packages/data-objectstack/src/metadata-client.draftLedger-11799.test.ts @@ -0,0 +1,232 @@ +/** + * 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. + */ + +/** + * objectui#11799 — `getDraft()` asks the drafts ledger before the item. + * + * `GET /meta/:type/:name?state=draft` answers 404 when there is no draft, and + * Studio reads it for every item it opens, so the console logged one error per + * opened item for an expected answer. The client now reads the pending-drafts + * ledger (`GET /meta/_drafts`, through the shared read objectui#11797 made) and + * sends the item read only when the ledger lists the name. + * + * Pinned, counted on the wire: + * - no draft: the ledger read, and no item read; + * - a draft: one item read, whose envelope comes back as before; + * - a draft saved a moment ago is found: a ledger read sent before the save + * is not joined by a question asked after it lands; + * - an unreadable ledger is unknown, not empty: the item read is sent; + * - the ledger is read whole, so a row the item read would serve but a + * filtered ledger would not list still leads to the item read; + * - questions asked together share one ledger read. + */ +import { describe, it, expect, vi } from 'vitest'; +import { MetadataClient } from './metadata-client'; + +interface HeldRequest { + method: string; + url: string; + respond: (status: number, body: unknown) => void; + fail: (error: Error) => void; +} + +/** A transport whose every request waits until the test answers it. */ +function heldTransport() { + const held: HeldRequest[] = []; + const fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve, reject) => { + held.push({ + method: (init?.method ?? 'GET').toUpperCase(), + url: String(input).replace(BASE, ''), + respond: (status, body) => + resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ), + fail: reject, + }); + }), + ) as unknown as typeof globalThis.fetch; + const gets = () => held.filter((r) => r.method === 'GET'); + return { fetch, held, gets }; +} + +/** Let every queued continuation run, so a call that WOULD fetch has fetched. */ +async function drain() { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +} + +const BASE = 'http://test.local'; +const LEDGER = '/api/v1/meta/_drafts'; +const HOME_DRAFT = '/api/v1/meta/page/home?state=draft'; +const ENVELOPE = { type: 'page', name: 'home', item: { name: 'home', label: 'Home', _draft: true } }; +const ledgerOf = (...rows: Array<{ type: string; name: string; packageId?: string | null }>) => ({ + drafts: rows.map((r) => ({ packageId: null, ...r })), +}); + +const isItemDraftRead = (url: string) => url.includes('state=draft'); + +describe('MetadataClient.getDraft — the drafts ledger answers first (objectui#11799)', () => { + it('an item with no draft: the ledger is read, and no ?state=draft request is sent', async () => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + const draft = c.getDraft('page', 'home'); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER]); + gets()[0].respond(200, ledgerOf({ type: 'page', name: 'other' })); + + await expect(draft).resolves.toBeNull(); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER]); + expect(gets().some((r) => isItemDraftRead(r.url))).toBe(false); + }); + + it('control: an item the ledger lists gets exactly one ?state=draft request, and its envelope', async () => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + const draft = c.getDraft('page', 'home'); + await drain(); + gets()[0].respond(200, ledgerOf({ type: 'page', name: 'home' })); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER, HOME_DRAFT]); + gets()[1].respond(200, ENVELOPE); + + await expect(draft).resolves.toEqual(ENVELOPE); + }); + + it('a draft saved a moment ago is found: a ledger read sent before the save is not joined after it lands', async () => { + const { fetch, held, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + // Asked before the save: its ledger read is on the wire, still pending. + const before = c.getDraft('page', 'home'); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER]); + + // The draft is saved, and the write lands while that ledger read is pending. + const saved = c.save('page', 'home', { name: 'home', label: 'Home' }, { mode: 'draft' }); + await drain(); + const put = held.find((r) => r.method === 'PUT'); + expect(put?.url).toBe('/api/v1/meta/page/home?mode=draft'); + put!.respond(200, { type: 'page', name: 'home', state: 'draft' }); + await saved; + + // Asked after the save: a NEW ledger read, not the pending one. + const after = c.getDraft('page', 'home'); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER, LEDGER]); + + // The pending read answers from before the save; the new one lists the draft. + gets()[0].respond(200, ledgerOf()); + gets()[1].respond(200, ledgerOf({ type: 'page', name: 'home' })); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER, LEDGER, HOME_DRAFT]); + gets()[2].respond(200, ENVELOPE); + + await expect(before).resolves.toBeNull(); + await expect(after).resolves.toEqual(ENVELOPE); + }); + + it('control: with no write in between, a second question joins the pending ledger read', async () => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + const first = c.getDraft('page', 'home'); + await drain(); + const second = c.getDraft('page', 'home'); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER]); + + gets()[0].respond(200, ledgerOf()); + await expect(first).resolves.toBeNull(); + await expect(second).resolves.toBeNull(); + }); + + it.each([ + ['403, a caller without an authoring capability', 403, { error: { code: 'FORBIDDEN', message: 'no' } }], + ['501, a kernel without drafts', 501, { error: { code: 'NOT_IMPLEMENTED', message: 'no' } }], + ])('an unreadable ledger (%s) is unknown, not empty: the item read is sent', async (_label, status, body) => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + const draft = c.getDraft('page', 'home'); + await drain(); + gets()[0].respond(status, body); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER, HOME_DRAFT]); + gets()[1].respond(200, ENVELOPE); + + await expect(draft).resolves.toEqual(ENVELOPE); + }); + + it('a ledger the network never answered is unknown too: the item read is sent', async () => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + const draft = c.getDraft('page', 'home'); + await drain(); + gets()[0].fail(new TypeError('Failed to fetch')); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER, HOME_DRAFT]); + gets()[1].respond(404, { error: { code: 'NO_DRAFT', message: 'none' } }); + + await expect(draft).resolves.toBeNull(); + }); + + it('the ledger is read whole: a package-less row and a row under the plural spelling still lead to the item read', async () => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + // `?state=draft&package=` serves the package's row OR the package-less one, + // and either spelling of the type; a ledger filtered by package or type + // would not list these two. + const scoped = c.getDraft('page', 'home', { packageId: 'com.acme.app' }); + const residue = c.getDraft('object', 'acme_account'); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER]); + gets()[0].respond( + 200, + ledgerOf({ type: 'page', name: 'home', packageId: null }, { type: 'objects', name: 'acme_account' }), + ); + await drain(); + expect(gets().map((r) => r.url)).toEqual([ + LEDGER, + '/api/v1/meta/page/home?state=draft&package=com.acme.app', + '/api/v1/meta/object/acme_account?state=draft', + ]); + gets()[1].respond(200, ENVELOPE); + gets()[2].respond(200, { type: 'object', name: 'acme_account', item: { name: 'acme_account' } }); + + await expect(scoped).resolves.toEqual(ENVELOPE); + await expect(residue).resolves.toEqual({ type: 'object', name: 'acme_account', item: { name: 'acme_account' } }); + }); + + it('questions asked together share one ledger read, and only listed items are read', async () => { + const { fetch, gets } = heldTransport(); + const c = new MetadataClient({ baseUrl: BASE, fetch }); + + const answers = ['home', 'about', 'pricing'].map((name) => c.getDraft('page', name)); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER]); + gets()[0].respond(200, ledgerOf({ type: 'page', name: 'about' })); + await drain(); + expect(gets().map((r) => r.url)).toEqual([LEDGER, '/api/v1/meta/page/about?state=draft']); + gets()[1].respond(200, { type: 'page', name: 'about', item: { name: 'about' } }); + + await expect(Promise.all(answers)).resolves.toEqual([ + null, + { type: 'page', name: 'about', item: { name: 'about' } }, + null, + ]); + }); +}); diff --git a/packages/data-objectstack/src/metadata-client.get-envelope.test.ts b/packages/data-objectstack/src/metadata-client.get-envelope.test.ts index f51d9d803b..67d0ca6f67 100644 --- a/packages/data-objectstack/src/metadata-client.get-envelope.test.ts +++ b/packages/data-objectstack/src/metadata-client.get-envelope.test.ts @@ -137,11 +137,24 @@ describe('objectui#4271 · the getDraft() envelope asymmetry is preserved', () = // PermissionMatrixEditor). Routing getDraft through the unwrapping get() // would silently empty every one of them. const draftEnvelope = { type: 'object', name: 'showcase_project', item: OBJECT_BODY }; - const { c, urls } = clientAnswering(draftEnvelope); + // objectui#11799 — the item read follows the drafts ledger, which lists it. + const urls: string[] = []; + const c = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: mockFetch(async (url) => { + urls.push(url); + return jsonResponse( + url.endsWith('/meta/_drafts') ? { drafts: [{ type: 'object', name: 'showcase_project' }] } : draftEnvelope, + ); + }), + }); const draft = await c.getDraft>('object', 'showcase_project'); expect(draft).toEqual(draftEnvelope); expect((draft as any)?.item).toEqual(OBJECT_BODY); - expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft'); + expect(urls).toEqual([ + 'http://localhost:3000/api/v1/meta/_drafts', + 'http://localhost:3000/api/v1/meta/object/showcase_project?state=draft', + ]); }); it('A11: get(state:draft) unwraps like any other get()', async () => { diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index 26cfd05aa8..93ee19e070 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -1119,18 +1119,57 @@ export class MetadataClient { * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived * through {@link get}, which already unwrapped the envelope. They split on * which method feeds them, not on what they mean — all three strip. + * + * objectui#11799 — whether a draft exists is answered by the pending-drafts + * ledger first (see {@link draftLedgerMayHold}), and `?state=draft` is sent + * only when the ledger lists the name. The item read answers 404 when there + * is no draft, so opening every item in Studio used to log one error per + * item for an answer the ledger gives with a 200. */ async getDraft( type: string, name: string, options: { packageId?: string } = {}, ): Promise { + if (!(await this.draftLedgerMayHold(name))) return null; return this.readItemResponse(type, name, { state: 'draft', ...(options.packageId ? { packageId: options.packageId } : {}), }); } + /** + * Whether the pending-drafts ledger (`GET /meta/_drafts`) may hold a draft + * named `name` (objectui#11799). + * + * Read through {@link listDrafts}, the shared read objectui#11797 made: a + * question asked while a ledger read is pending waits for it, and a write + * through this transport drops it when the write lands ({@link sendWrite}), + * so a draft saved a moment ago is never reported absent by a read sent + * before the save. + * + * The WHOLE ledger, matched by name. The `?state=draft` read finds a row + * under either spelling of the type, and, when it names a package, that + * package's row or the package-less one. The ledger's `type` and `packageId` + * filters are exact, so a filtered ledger could miss a draft the item read + * would serve, and the editor would open the published body over a pending + * draft. Unfiltered, every miss here is a real absence; a same-name row of + * another type or package only costs the item read that was always sent. + * + * A ledger that cannot be read (403 without an authoring capability, 501 on + * a kernel without drafts, a network fault) is UNKNOWN, not empty: the item + * read is sent, as before. + */ + private async draftLedgerMayHold(name: string): Promise { + let drafts: MetadataDraftHeader[]; + try { + drafts = await this.listDrafts(); + } catch { + return true; + } + return drafts.some((d) => d?.name === name); + } + /** * Report the runtime authoring gate's advisory findings for a write the * server has ALREADY committed (#4133 for the save door, #5026 for the