From 526bd1c26a81c4e5cafef235548aab8058757122 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:20:39 +0000 Subject: [PATCH 1/4] fix(app-shell): Studio and Setup read an item's layered view at its owning package A layered read naming no package is refused 409 RESOURCE_CONFLICT for a name two packages hold. Each caller now names the package it already reads and saves at; where none is known, the server's refusal is the load's error. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQi39yrLXvXVSvoKfv --- .../28584-studio-layered-reads-name-owner.md | 21 ++++++++++++ .../metadata-admin/EmbeddedItemEditor.tsx | 21 ++++++++++++ .../metadata-admin/PermissionMatrixEditor.tsx | 33 ++++++++++++++++--- .../views/metadata-admin/ResourceEditPage.tsx | 11 +++++-- .../studio-design/ObjectListViewInspector.tsx | 9 +++-- .../studio-design/PackageOwdOverviewPanel.tsx | 10 ++++-- .../studio-design/StudioDesignSurface.tsx | 32 ++++++++++++++---- 7 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 .changeset/28584-studio-layered-reads-name-owner.md diff --git a/.changeset/28584-studio-layered-reads-name-owner.md b/.changeset/28584-studio-layered-reads-name-owner.md new file mode 100644 index 00000000000..14f6bc38c20 --- /dev/null +++ b/.changeset/28584-studio-layered-reads-name-owner.md @@ -0,0 +1,21 @@ +--- +"@object-ui/app-shell": patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(app-shell): Studio and Setup read an item's layered view at the package that owns it + +The layered view (`GET /meta/:type/:name/layers`) naming no package is refused `409 RESOURCE_CONFLICT` for a name two +installed packages hold, naming the holders and the `?package=` remedy. Reads in Studio and Setup still asked +it naming no package, so on such a deployment the list-view inspector, the Interfaces leaf and app, the Data pillar's +object, the Automations pillar's flow, the package OWD overview, the permission-set matrix, the embedded item editor and +the item page's reset, publish and discard re-reads failed, and the permission-set matrix read the refusal as "no set +known". + +Each read now names the package the caller already reads and saves at: the Studio pillar's package (a read-only base's +own package, whose read serves its declared override), the permission matrix's package door, or the owner the edit +URL names (`?package=`). Where none is known, the read still names no package, and the server's refusal sentence is +shown as the load's error; the permission matrix no longer reads it as "no set known". No holder is picked on the +client. diff --git a/ui/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx index f506b077801..cb8cc26576c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx @@ -58,6 +58,21 @@ export interface EmbeddedItemEditorProps { onSaved?: (item: Record) => void; } +/** + * The parent's owning package, as the hosting edit page's URL names it + * (`?package=`, the metadata list's row link). Read when the request is + * issued, as that page's own save binding is. `all` is the list's + * every-package scope, not a package. + */ +function readParentOwnerPackage(): string | undefined { + try { + const p = new URLSearchParams(window.location.search).get('package'); + return p && p !== 'all' ? p : undefined; + } catch { + return undefined; + } +} + export function EmbeddedItemEditor({ parentType, parentName, @@ -132,9 +147,15 @@ export function EmbeddedItemEditor({ // which the client resolves as every layer `null`. ⛔ That absence is // never a body: an empty base would PUT a stub of the one item over // the parent. With nothing readable, the save is refused. + // ADR-0048 — at the parent's owner, the package the hosting edit + // page's URL names (`?package=`); with none, the read names none, and + // a parent two packages hold is refused with the server's sentence, + // which the `catch` below shows. + const ownerPackageId = readParentOwnerPackage(); const layered = await client.layered>( parentType, parentName, + ownerPackageId ? { packageId: ownerPackageId } : {}, ); const published = layered.effective ?? layered.code; if (!published) { diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 456ef231163..2c2a99cb8d4 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -244,6 +244,15 @@ function readObjectDefinition( return (packageId ? client.withPreviewDrafts(true) : client).get('object', objectName); } +/** + * ADR-0048 — the layered read's refusal of a name two packages hold, asked + * naming no package: `409 RESOURCE_CONFLICT`, its message naming the holders + * and the `?package=` remedy. A read answers that code for nothing else. + */ +function isPackagelessLayeredRefusal(err: unknown): boolean { + return (err as { status?: unknown } | null)?.status === 409 && errorCodeIs(err, 'RESOURCE_CONFLICT'); +} + /** * Is this item backed by a **code-package artifact**? (objectui#4518, #4526) * @@ -749,6 +758,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // could only be refused (400), and there is nobody to list. const [searchParams] = useSearchParams(); const showAssignments = !embedded && isEnvironmentScope(searchParams) && existsOnServer !== false; + // ADR-0048 — the package whose stack the layered reads below read: the + // door's `packageId`, else the owner the edit URL names (`?package=`, the + // metadata list's row link; `all` is that list's every-package scope, not a + // package). With neither, the read names no package, and a name two + // packages hold is refused with the server's sentence, shown as the load's + // error. ⛔ Never a holder picked here. + const routePackage = searchParams.get('package'); + const layeredPackageId = packageId ?? (routePackage && routePackage !== 'all' ? routePackage : undefined); // Which gate to NAME when the surface is locked (host > artifact > type). // The artifact tier is the DECIDING one only where the type tier would have // said yes: with both flags false the honest reason is still "this type has @@ -916,7 +933,15 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, ? readDraftForSave<{ item?: PermissionSetDraft } | PermissionSetDraft>(client, type, name, packageId) : Promise.resolve(null); const draftRead = draftReadForSave.then((read) => read?.answer ?? null); - const readLayered = () => client.layered(type, name).catch(() => null); + const readLayered = () => + client + .layered(type, name, layeredPackageId ? { packageId: layeredPackageId } : {}) + .catch((err: unknown) => { + // A name two packages hold, read naming neither: the server's + // refusal is this load's error, ⛔ never "no set known". + if (isPackagelessLayeredRefusal(err)) throw err; + return null; + }); // objectui#11799 — a set "+ New" created and nobody published has a // draft and no layer, and `GET …/layers` answers 404 for it. Under the // package door the layered envelope is used only without a pending @@ -1041,7 +1066,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, return () => { cancelled = true; }; - }, [client, type, name, packageId, publishNonce, resetDraftBaseline, reloadNonce, recordSetRead, forgetSetVersion]); + }, [client, type, name, packageId, layeredPackageId, publishNonce, resetDraftBaseline, reloadNonce, recordSetRead, forgetSetVersion]); /* ── Lazy-load fields when an object is expanded ─────────── */ async function ensureFields(objectName: string) { @@ -1450,7 +1475,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, let rereadFailed = false; const [fresh, draftRead] = await Promise.all([ client - .layered(type, payload.name) + .layered(type, payload.name, { packageId }) // Kept on `.catch` so the rejection can never escape as an // unhandled one; the refusal is raised below, inside this `try`'s // own body, and reaches the author on the same error channel a @@ -1537,7 +1562,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // write is already done, and refusing anything would be a lie. let rereadFailed = false; const lay = await client - .layered(type, payload.name) + .layered(type, payload.name, layeredPackageId ? { packageId: layeredPackageId } : {}) .catch(() => { rereadFailed = true; return null; diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 29a9d54485e..95e37b96dab 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -552,6 +552,11 @@ function MetadataResourceEditPageImpl({ // the canonical member and for a type with no members. const bundleMember = searchParams.get(BUNDLE_MEMBER_PARAM) || undefined; const memberScope = bundleMember ? { member: bundleMember } : {}; + // ADR-0048 — every layered re-read below asks the stack the load read: at + // the owning package, when the URL names it. A layered read naming no + // package is refused for a name two packages hold, and the refusal is shown + // as the step's error. + const layeredScope = { ...(ownerPackageId ? { packageId: ownerPackageId } : {}), ...memberScope }; const client = useMetadataClient(); const { entries, loading: typesLoading } = useMetadataTypes(client); const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type); @@ -2165,7 +2170,7 @@ function MetadataResourceEditPageImpl({ // draft read behind it, so nothing pins its next save. forgetDraftVersion(); if (resetSemantic) { - const lay = await client.layered(type, name, memberScope); + const lay = await client.layered(type, name, layeredScope); setLayered(lay); const fresh = (lay.effective ?? lay.code ?? {}) as Record; setDraft(fresh); @@ -2208,7 +2213,7 @@ function MetadataResourceEditPageImpl({ // objectui#11773 — the draft is re-read at the address the next save // writes, so its answer pins that save (see the load effect). const [lay, draftRead] = await Promise.all([ - client.layered(type, name, memberScope), + client.layered(type, name, layeredScope), readDraftForSave(client, type, name, activePackage, bundleMember), ]); const draftResp = draftRead.answer; @@ -2273,7 +2278,7 @@ function MetadataResourceEditPageImpl({ forgetDraftVersion(); // objectui#11799 — a discard makes no layer: an item this page loaded // with no layer still has none, and `/layers` would answer 404. - const lay = layered && holdsNoLayer(layered) ? layered : await client.layered(type, name, memberScope); + const lay = layered && holdsNoLayer(layered) ? layered : await client.layered(type, name, layeredScope); setLayered(lay); const fresh = (lay.effective ?? lay.code ?? {}) as Record; setDraft(fresh); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx index 21f81620675..e85337eaa74 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx @@ -577,12 +577,17 @@ export function useObjectListViewDraft({ !draftChanges.some((c) => c.path.length === 0); // A failure of that added read costs the element marks only, never the // editor: the draft is the buffer either way. + // ADR-0048 — the published layer is read at this package, the view's + // owner here: a layered read naming no package is refused for a name + // two packages hold. On a read-only base that is the base, whose read + // serves its declared override (ADR-0146 D8). + const layeredAt = { packageId }; const lay = objectDraftOnly ? null : !draft - ? await client.layered('view', viewId) + ? await client.layered('view', viewId, layeredAt) : marksNeedPublished - ? await client.layered('view', viewId).catch(() => undefined) + ? await client.layered('view', viewId, layeredAt).catch(() => undefined) : null; if (cancelled) return; const published = (lay as { effective?: unknown; code?: unknown } | null)?.effective ?? (lay as { code?: unknown } | null)?.code; diff --git a/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx b/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx index 8ea5726e660..6ef3c055e88 100644 --- a/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx @@ -97,10 +97,14 @@ interface OwdRow { */ async function readObjectBodies( client: MetadataClient, + packageId: string, name: string, saved: boolean, ): Promise<[MetadataLayered> | null, unknown]> { - const readLayered = () => client.layered>('object', name).catch(() => null); + // ADR-0048 — the layers at the package this table lists and saves, the + // object's owner here: a layered read naming no package is refused for a + // name two packages hold. + const readLayered = () => client.layered>('object', name, { packageId }).catch(() => null); const readDraft = () => client.getDraft>('object', name).catch(() => null); if (saved) return Promise.all([readLayered(), readDraft()]); const draftResp = await readDraft(); @@ -284,7 +288,7 @@ export function PackageOwdOverviewPanel({ names.map(async (name): Promise => { // objectui#11799 — saved = held by the published list read above. const saved = publishedLabel.has(name); - const [layRaw, draftResp] = await readObjectBodies(client, name, saved); + const [layRaw, draftResp] = await readObjectBodies(client, packageId, name, saved); const lay = (layRaw ?? {}) as { effective?: Record; code?: Record }; const baseline = (lay.effective ?? lay.code ?? {}) as Record; const draftBody = extractDraftBody(draftResp); @@ -383,7 +387,7 @@ export function PackageOwdOverviewPanel({ for (const s of changed) { // objectui#11799 — the row's saved state from the load decides whether // the baseline is asked before the draft is in hand. - const [layRaw, draftResp] = await readObjectBodies(client, s.row.name, s.row.saved); + const [layRaw, draftResp] = await readObjectBodies(client, packageId, s.row.name, s.row.saved); const lay = (layRaw ?? {}) as { effective?: Record; code?: Record }; const baseline = (lay.effective ?? lay.code ?? {}) as Record; const draftBody = extractDraftBody(draftResp); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 0d9d1637397..99f5fc65df2 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -4759,7 +4759,9 @@ export function InterfacesPillar({ // objectui#11773 — the draft is read at the address the nav save writes // (`?package=` this package), so the version it serves pins that save. const [layRaw, appDraftRead] = await Promise.all([ - saved ? client.layered>('app', name) : null, + // ADR-0048 — read at this package, the app's owner: a layered read + // naming no package is refused for a name two packages hold. + saved ? client.layered>('app', name, { packageId }) : null, readDraftForSave>(client, 'app', name, packageId), ]); if (cancelled) return; @@ -5035,7 +5037,13 @@ export function InterfacesPillar({ // 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); + // ADR-0048 — read at this package, the leaf's owner: a layered read + // naming no package is refused for a name two packages hold. On a + // read-only base that is the base, whose read serves its declared + // override (ADR-0146 D8). + const lay = body + ? null + : await client.layered>(current.type, current.name, { packageId }); if (cancelled) return; const baseline = ((lay as { effective?: unknown; code?: unknown } | null)?.effective ?? (lay as { code?: unknown } | null)?.code ?? @@ -5094,7 +5102,8 @@ export function InterfacesPillar({ if (!publishedPending || !currentType || !currentName) return; const key = leafKey; let cancelled = false; - client.layered>(currentType, currentName).then( + // ADR-0048 — at this package, as the leaf's load reads its baseline. + client.layered>(currentType, currentName, { packageId }).then( (lay) => { if (cancelled) return; const doc = ((lay as { effective?: unknown; code?: unknown } | null)?.effective ?? @@ -5110,7 +5119,7 @@ export function InterfacesPillar({ return () => { cancelled = true; }; - }, [publishedPending, client, currentType, currentName, leafKey]); + }, [publishedPending, client, currentType, currentName, leafKey, packageId]); const servedWidgets = React.useMemo( () => currentType === 'dashboard' && leafPublished.key === leafKey && leafPublished.read @@ -7556,7 +7565,9 @@ export function DataPillar({ try { // objectui#11773 — the draft is read at the address the object saves // write, so the version it serves pins the first of them. - const readLayers = () => client.layered>('object', current.name); + // ADR-0048 — and the layers at this package, the object's owner: a + // layered read naming no package is refused for a name two packages hold. + const readLayers = () => client.layered>('object', current.name, { packageId }); const readDraft = () => readDraftForSave>(client, 'object', current.name, packageId); // objectui#12508 — a draft-only object (a pending draft, and no row in // the package's published list) has no layer, and `/layers` answers @@ -9570,7 +9581,16 @@ export function AutomationsPillar({ // 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); + // ADR-0048 — at the package the flow saves write, its owner here; with + // none, the read names none, and a name two packages hold is refused + // with the server's own sentence, shown as the load's error. + const layRaw = draftBody + ? null + : await client.layered>( + 'flow', + current.name, + draftPackageId ? { packageId: draftPackageId } : {}, + ); if (cancelled) return; const lay = layRaw as { effective?: Record; code?: Record } | null; const baseline = (lay?.effective ?? lay?.code ?? {}) as Record; From a1a6d8d552d0b4e006da2678e4d7fd360bd1cc7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:23:05 +0000 Subject: [PATCH 2/4] test(app-shell): pin the layered reads of an item two packages hold Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQi39yrLXvXVSvoKfv --- ...sionMatrixEditor.twoHolders-28584.test.tsx | 159 ++++++++++++ ...istViewInspector.twoHolders-28584.test.tsx | 160 ++++++++++++ ...dioDesignSurface.twoHolders-28584.test.tsx | 228 ++++++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.twoHolders-28584.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.twoHolders-28584.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.twoHolders-28584.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.twoHolders-28584.test.tsx new file mode 100644 index 00000000000..47f64f0277d --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.twoHolders-28584.test.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28584 — the permission matrix reads a set two packages hold at + * the package that owns it, and shows the server's refusal where it knows none. + * + * Since objectstack#28337, the layered view naming no package refuses a name + * whose active rows two packages hold: `409 RESOURCE_CONFLICT`, its message + * naming the holders and the `?package=` remedy. The editor read it naming no + * package and caught every failure to `null`, so on such a deployment it either + * failed or read the refusal as "no set known". + * + * The REAL editor over a REAL `MetadataClient`, whose transport is an + * in-memory server holding `sales_perms` in two packages and answering the + * layered read as the framework does: the holder's stack with `?package=`, the + * refusal without it. + * + * Pinned: + * - the package door reads its own package's stack, and shows its grants; + * - the routed page (no door) reads the owner its URL names, and shows that + * holder's grants; + * - with no owner known, the read names no package, and the server's refusal + * sentence is shown, ⛔ never a holder picked on the client. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const PKG_A = 'app.alpha'; +const PKG_B = 'app.beta'; +const SET = 'sales_perms'; +const REFUSAL_TEXT = + `Name the package this layered read is for, then send it again: on the REST door add ?package= with the package id. ` + + `permission '${SET}' is held by '${PKG_A}' and '${PKG_B}'.`; + +/** Each holder's published set: alpha grants Read, beta grants Create. */ +const HOLDERS: Record> = { + [PKG_A]: { name: SET, label: 'Sales (alpha)', objects: { a_account: { allowRead: true, allowCreate: false } }, fields: {} }, + [PKG_B]: { name: SET, label: 'Sales (beta)', objects: { a_account: { allowRead: false, allowCreate: true } }, fields: {} }, +}; + +const requests: Array<{ method: string; path: string; search: string; status: number }> = []; + +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 pkg = url.searchParams.get('package'); + 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: [] }; + } else if (seg.length === 1 && seg[0] === 'object') { + body = { items: [{ name: 'a_account' }] }; + } else if (seg.length === 1 && seg[0] === 'permission') { + body = { items: pkg && HOLDERS[pkg] ? [HOLDERS[pkg]] : Object.values(HOLDERS) }; + } else if (seg.length === 3 && seg[0] === 'permission' && seg[1] === SET && seg[2] === 'layers') { + const row = pkg ? HOLDERS[pkg] : undefined; + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; + else if (!pkg) [status, body] = [409, { success: false, error: { code: 'RESOURCE_CONFLICT', message: REFUSAL_TEXT } }]; + else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2 && url.searchParams.get('state') === 'draft') { + [status, body] = [404, { error: { code: 'NO_DRAFT', message: 'absent' } }]; + } else { + [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } + } + requests.push({ method, path: url.pathname, search: url.search, status }); + return json(status, body); +} + +const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => client, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: false, allowRuntimeCreate: true }], + }), +})); +vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); + +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; + +/** The layered reads the editor sent: each one's query string and status. */ +const layersReads = () => + requests + .filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/permission/${SET}/layers`) + .map((r) => ({ search: r.search, status: r.status })); + +const granted = (action: 'Read' | 'Create') => screen.getByRole('checkbox', { name: `a_account ${action}` }); + +async function settle() { + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +beforeEach(() => { + requests.length = 0; +}); +afterEach(cleanup); + +describe('PermissionMatrixEditPage — a set two packages hold (objectstack#28584)', () => { + it('the package door reads its own package\'s stack and shows that holder\'s grants', async () => { + render( + + + , + ); + await screen.findByText('a_account', undefined, { timeout: 8000 }); + await settle(); + + expect(layersReads()).toEqual([{ search: `?package=${PKG_B}`, status: 200 }]); + expect(granted('Read')).toHaveAttribute('aria-checked', 'false'); + expect(granted('Create')).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByText(REFUSAL_TEXT)).toBeNull(); + }); + + it('the routed page reads the owner its URL names and shows that holder\'s grants', async () => { + render( + + + , + ); + await screen.findByText('a_account', undefined, { timeout: 8000 }); + await settle(); + + expect(layersReads()).toEqual([{ search: `?package=${PKG_A}`, status: 200 }]); + expect(granted('Read')).toHaveAttribute('aria-checked', 'true'); + expect(granted('Create')).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByText(REFUSAL_TEXT)).toBeNull(); + }); + + it('with no owner known, the read names no package and the server\'s refusal sentence is shown', async () => { + render( + + + , + ); + expect(await screen.findByText(REFUSAL_TEXT, undefined, { timeout: 8000 })).toBeInTheDocument(); + await settle(); + + // `?package=all` is the list's every-package scope: no package is named, + // and the server, not the client, says which packages hold the set. + expect(layersReads()).toEqual([{ search: '', status: 409 }]); + expect(screen.queryByRole('checkbox', { name: 'a_account Read' })).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.twoHolders-28584.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.twoHolders-28584.test.tsx new file mode 100644 index 00000000000..bcf0a06382e --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.twoHolders-28584.test.tsx @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28584 — an `object` leaf's list-view buffer reads the published + * layer of a view two packages hold at the Studio package it is open in. + * + * Since objectstack#28337, the layered view naming no package refuses a name + * whose active rows two packages hold: `409 RESOURCE_CONFLICT`, naming the + * holders and the `?package=` remedy. The buffer read it naming no package, so + * on such a deployment the leaf's list view failed to load. It now reads at + * the package it reads the draft at and saves to. + * + * The REAL hook (`useObjectListViewDraft`) over a REAL `MetadataClient`, whose + * transport is an in-memory server holding the view in two packages and + * answering the layered read as the framework does: the holder's stack with + * `?package=`, the refusal without it. + * + * Pinned, for each holder: the one layered read names that package, nothing is + * refused, and the buffer holds that holder's view, ⛔ never the other's. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { MetadataClient } from '@object-ui/data-objectstack'; +import { useObjectListViewDraft } from './ObjectListViewInspector'; +import type { Surface } from './navSurface'; + +const PKG_A = 'app.alpha'; +const PKG_B = 'app.beta'; +const VIEW = 'showcase_task.default'; + +const OBJECT_DEF = { + name: 'showcase_task', + label: 'Task', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + status: { name: 'status', label: 'Status', type: 'text' }, + }, +}; + +/** The object's default list view as a server serves a view item. */ +const viewItem = (columns: string[]) => ({ + name: VIEW, + object: 'showcase_task', + viewKind: 'list', + isDefault: true, + config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns }, +}); + +/** Each holder's published view: alpha shows one column, beta two. */ +const HOLDERS: Record> = { + [PKG_A]: viewItem(['title']), + [PKG_B]: viewItem(['title', 'status']), +}; + +const requests: Array<{ method: string; path: string; search: string; status: number }> = []; + +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 pkg = url.searchParams.get('package'); + 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: [] }; + } else if (seg.length === 3 && seg[0] === 'view' && seg[1] === VIEW && seg[2] === 'layers') { + const row = pkg ? HOLDERS[pkg] : undefined; + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; + else if (!pkg) { + [status, body] = [ + 409, + { + success: false, + error: { + code: 'RESOURCE_CONFLICT', + message: `Name the package this layered read is for, then send it again: view '${VIEW}' is held by '${PKG_A}' and '${PKG_B}'.`, + }, + }, + ]; + } else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2) { + [status, body] = [404, { error: { code: url.searchParams.get('state') === 'draft' ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }]; + } + } + requests.push({ method, path: url.pathname, search: url.search, status }); + return json(status, body); +} + +const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +const METADATA: MetadataContextValue = { + apps: [], + objects: [OBJECT_DEF], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +}; + +const LEAF: Surface = { type: 'object', name: 'showcase_task', label: 'Tasks' }; + +/** Prints what the buffer holds. */ +function Probe({ packageId }: { packageId: string }) { + const d = useObjectListViewDraft({ client, packageId, leaf: LEAF, publishNonce: 0 }); + return ( +
+      {JSON.stringify({ loadedFor: d.loadedFor, row: d.row, hasDraft: d.hasDraft, failure: d.failure ? String(d.failure.error) : null })}
+    
+ ); +} + +async function openLeafIn(packageId: string) { + render( + + + , + ); + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); + return JSON.parse(screen.getByTestId('probe').textContent ?? '{}') as Record; +} + +const layersReads = () => + requests + .filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/view/${VIEW}/layers`) + .map((r) => ({ search: r.search, status: r.status })); + +beforeEach(() => { + requests.length = 0; +}); +afterEach(cleanup); + +describe('useObjectListViewDraft — a view two packages hold (objectstack#28584)', () => { + for (const [pkg, other] of [[PKG_A, PKG_B], [PKG_B, PKG_A]] as const) { + it(`open in ${pkg}: the layered read names ${pkg}, and the buffer holds ${pkg}'s view`, async () => { + const seen = await openLeafIn(pkg); + + expect(layersReads()).toEqual([{ search: `?package=${pkg}`, status: 200 }]); + expect(requests.filter((r) => r.status === 409)).toEqual([]); + expect(seen).toEqual({ loadedFor: `listView:${VIEW}`, row: HOLDERS[pkg], hasDraft: false, failure: null }); + expect(seen.row).not.toEqual(HOLDERS[other]); + }); + } +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx new file mode 100644 index 00000000000..f0643c89d66 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28584 — the Studio pillars read the layered view of an item two + * packages hold at the package the pillar is open in. + * + * Since objectstack#28337, the layered view naming no package refuses a name + * whose active rows two packages hold: `409 RESOURCE_CONFLICT`, naming the + * holders and the `?package=` remedy. The Interfaces pillar's app and leaf, + * the Automations pillar's flow and the Data pillar's object read it naming no + * package, so on such a deployment each failed to load. Each now reads at the + * package it reads the draft at and saves to. + * + * The REAL pillars over a REAL `MetadataClient`, whose transport is an + * in-memory server holding every published item in this package and in a + * second one, and answering the layered read as the framework does: the + * holder's stack with `?package=`, the refusal without it. + * + * Pinned, per pillar: every layered read names this package, none is refused, + * and what loads is this package's item, ⛔ never the other holder's. + */ +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 OTHER = 'com.other.app'; +const NAV = [{ id: 'n1', type: 'dashboard', label: 'Customers', dashboardName: 'customer_dashboard' }]; +const OTHER_NAV = [{ id: 'n1', type: 'dashboard', label: 'Elsewhere', dashboardName: 'other_dashboard' }]; +const flow = (name: string, label: string) => ({ + name, + label, + type: 'autolaunched', + status: 'obsolete', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +/** The server: each published item's body per holder, and every request it was asked. */ +const server = { + holders: new Map>>(), + requests: [] as Array<{ method: string; path: string; search: string; status: number }>, +}; + +/** `name` of `type`, published in both packages, each with its own body. */ +function holdBoth(type: string, name: string, mine: Record, theirs: Record) { + server.holders.set(`${type}/${name}`, new Map([[PKG, mine], [OTHER, theirs]])); +} + +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 pkg = url.searchParams.get('package'); + 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: [] }; + } else if (seg.length === 1) { + body = { + items: [...server.holders.entries()] + .filter(([key]) => key.startsWith(`${seg[0]}/`)) + .map(([, byPkg]) => byPkg.get(pkg ?? PKG)) + .filter(Boolean), + }; + } else if (seg.length === 3 && seg[2] === 'layers') { + const byPkg = server.holders.get(`${seg[0]}/${seg[1]}`); + const row = pkg ? byPkg?.get(pkg) : undefined; + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; + else if (!pkg && byPkg && byPkg.size > 1) { + [status, body] = [ + 409, + { + success: false, + error: { + code: 'RESOURCE_CONFLICT', + message: `Name the package this layered read is for, then send it again: '${seg[1]}' is held by '${PKG}' and '${OTHER}'.`, + }, + }, + ]; + } else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2) { + [status, body] = [404, { error: { code: url.searchParams.get('state') === 'draft' ? '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.holders.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)); + }); +} + +/** The layered reads sent for `type/name`: each one's query string and status. */ +function layersReads(type: string, name: string) { + return server.requests + .filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/${type}/${name}/layers`) + .map((r) => ({ search: r.search, status: r.status })); +} + +const refused = () => server.requests.filter((r) => r.status === 409).map((r) => `${r.path}${r.search}`); + +describe('Studio pillars — an item two packages hold is read at this package (objectstack#28584)', () => { + it('Interfaces: the app and its dashboard leaf are read at this package, and its nav loads', async () => { + holdBoth('app', 'acme_app', { name: 'acme_app', label: 'Acme', navigation: NAV }, { name: 'acme_app', label: 'Other', navigation: OTHER_NAV }); + holdBoth( + 'dashboard', + 'customer_dashboard', + { name: 'customer_dashboard', label: 'Mine', widgets: [] }, + { name: 'customer_dashboard', label: 'Theirs', widgets: [] }, + ); + render( + + + , + ); + await waitFor(() => expect(layersReads('dashboard', 'customer_dashboard')).toHaveLength(1)); + await settle(); + + expect(layersReads('app', 'acme_app')).toEqual([{ search: `?package=${PKG}`, status: 200 }]); + expect(layersReads('dashboard', 'customer_dashboard')).toEqual([{ search: `?package=${PKG}`, status: 200 }]); + expect(refused()).toEqual([]); + // This package's nav, not the other holder's. + expect(await screen.findByTitle('dashboard · customer_dashboard')).toBeInTheDocument(); + expect(screen.queryByTitle('dashboard · other_dashboard')).toBeNull(); + }); + + it('Automations: the flow is read at this package', async () => { + holdBoth('flow', 'p_flow', flow('p_flow', 'Mine'), flow('p_flow', 'Theirs')); + render( + + + , + ); + await waitFor(() => expect(layersReads('flow', 'p_flow')).toHaveLength(1)); + await settle(); + + expect(layersReads('flow', 'p_flow')).toEqual([{ search: `?package=${PKG}`, status: 200 }]); + expect(refused()).toEqual([]); + }); + + it('Data: the object is read at this package', async () => { + holdBoth( + 'object', + 'acme_account', + { name: 'acme_account', label: 'Account', fields: { title: { type: 'text' } } }, + { name: 'acme_account', label: 'Theirs', fields: { code: { type: 'text' } } }, + ); + render( + + + , + ); + await waitFor(() => expect(layersReads('object', 'acme_account')).toHaveLength(1)); + await settle(); + + expect(layersReads('object', 'acme_account')).toEqual([{ search: `?package=${PKG}`, status: 200 }]); + expect(refused()).toEqual([]); + }); +}); From 797eb378317f0b67fb587324f4d7a4483fa71331 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:24:24 +0000 Subject: [PATCH 3/4] test(app-shell): the app layered read names its package in two pillar tests Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQi39yrLXvXVSvoKfv --- .../StudioDesignSurface.navDeepLink-11153.test.tsx | 2 +- .../views/studio-design/StudioDesignSurface.railLabels.test.tsx | 2 +- .../studio-design/StudioDesignSurface.twoHolders-28584.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navDeepLink-11153.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navDeepLink-11153.test.tsx index 6f15404de90..ae3c48e1e39 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navDeepLink-11153.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navDeepLink-11153.test.tsx @@ -225,7 +225,7 @@ describe('Interfaces pillar — a `?sel=nav:ID` link on a read-only package (obj await treeLoaded(); // Let the apply run against the loaded tree before reading its outcome. - await waitFor(() => expect(mockClient.layered).toHaveBeenCalledWith('app', APP.name)); + await waitFor(() => expect(mockClient.layered).toHaveBeenCalledWith('app', APP.name, { packageId: PKG })); await new Promise((r) => setTimeout(r, 50)); expect(navEditingOpen()).toBe(false); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx index 8c697436f1e..d14641fa76d 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.railLabels.test.tsx @@ -1056,7 +1056,7 @@ describe('StudioDesignSurface.interfacesAppLabelMap-11181', () => { renderPillar(language); expect(await screen.findByText(HEADING[language](MAP_TEXT[language]), undefined, { timeout: 8000 })).toBeInTheDocument(); - expect(mockClient.layered).toHaveBeenCalledWith('app', 'acme_app'); + expect(mockClient.layered).toHaveBeenCalledWith('app', 'acme_app', { packageId: PKG }); expect(document.body.textContent).not.toContain('[object Object]'); }); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx index f0643c89d66..c01cd11060b 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.twoHolders-28584.test.tsx @@ -22,7 +22,7 @@ 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 { act, cleanup, 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). From 1edfa4954fe730789455db831eb26b4e96cbabfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:44:12 +0000 Subject: [PATCH 4/4] fix(app-shell): an item the pair adds reads its layers at the pair The Interfaces leaf baseline and the dashboard's published-layer read now name the leaf's owner: the pair for an item it adds, else this package. A test pins both pillars reading a pair-added item at the pair. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQi39yrLXvXVSvoKfv --- ...dioDesignSurface.oneSurface-28572.test.tsx | 29 +++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 18 +++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx index 25f1c162efd..b0f0a202a5f 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx @@ -429,3 +429,32 @@ describe('CONTROL: a base with no pair is unchanged (objectstack#28572)', () => expect(rowOf(ifRail(), 'dashboard', 'wins')).toBeNull(); }); }); + +describe('an item the pair adds reads its layers at the pair (objectstack#28584)', () => { + // A layered read naming no package is refused (409) for a name two packages + // hold, and a read naming the base finds nothing the base does not hold: the + // pair's own item is read at the pair, its owner. + const layersOf = (type: string, name: string) => + srv.reads.filter((r) => r.startsWith(`/api/v1/meta/${type}/${name}/layers`)); + + it('the Data pillar reads the pair’s object at the pair', async () => { + renderAt(`/studio/${BASE}/data`); + await waitFor(() => expect(within(dataRail()).getByText('Survey')).toBeInTheDocument(), { timeout: 8000 }); + fireEvent.click(within(dataRail()).getByText('Survey').closest('button')!); + await waitFor(() => expect(layersOf('object', 'hotcrm_custom_survey')).not.toEqual([]), { timeout: 8000 }); + for (const read of layersOf('object', 'hotcrm_custom_survey')) { + expect(new URL(read, 'http://localhost').searchParams.get('package')).toBe(PAIR); + } + }); + + it('the Interfaces pillar reads the pair’s dashboard at the pair', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + fireEvent.click(rowOf(ifRail(), 'dashboard', 'wins')!); + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent('Wins'), { timeout: 8000 }); + await waitFor(() => expect(layersOf('dashboard', 'wins')).not.toEqual([]), { timeout: 8000 }); + for (const read of layersOf('dashboard', 'wins')) { + expect(new URL(read, 'http://localhost').searchParams.get('package')).toBe(PAIR); + } + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 235f40d2eed..6be66499089 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -5180,13 +5180,15 @@ export function InterfacesPillar({ // 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. - // ADR-0048 — read at this package, the leaf's owner: a layered read - // naming no package is refused for a name two packages hold. On a - // read-only base that is the base, whose read serves its declared - // override (ADR-0146 D8). + // ADR-0048 — read at the leaf's owner: a layered read naming no + // package is refused for a name two packages hold. That is the pair + // for an item the pair adds; else this package, whose read on a + // read-only base serves its declared override (ADR-0146 D8). const lay = body ? null - : await client.layered>(current.type, current.name, { packageId }); + : await client.layered>(current.type, current.name, { + packageId: current.addedIn ?? packageId, + }); if (cancelled) return; const baseline = ((lay as { effective?: unknown; code?: unknown } | null)?.effective ?? (lay as { code?: unknown } | null)?.code ?? @@ -5245,8 +5247,8 @@ export function InterfacesPillar({ if (!publishedPending || !currentType || !currentName) return; const key = leafKey; let cancelled = false; - // ADR-0048 — at this package, as the leaf's load reads its baseline. - client.layered>(currentType, currentName, { packageId }).then( + // ADR-0048 — at the leaf's owner, as the leaf's load reads its baseline. + client.layered>(currentType, currentName, { packageId: leafPackageId }).then( (lay) => { if (cancelled) return; const doc = ((lay as { effective?: unknown; code?: unknown } | null)?.effective ?? @@ -5262,7 +5264,7 @@ export function InterfacesPillar({ return () => { cancelled = true; }; - }, [publishedPending, client, currentType, currentName, leafKey, packageId]); + }, [publishedPending, client, currentType, currentName, leafKey, leafPackageId]); const servedWidgets = React.useMemo( () => currentType === 'dashboard' && leafPublished.key === leafKey && leafPublished.read