Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/metadata-client-get-unwraps-envelope-4271.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/data-objectstack': minor
---

`MetadataClient.get()` returns the item body its docblock always promised — the field half of the permission matrix is alive again

`GET /api/v1/meta/:type/:name` answers the spec-declared envelope `{ type, name, item, …protection fields }` — one shape, for published and draft reads alike, since objectstack#5563 collapsed the read to it. `get()` handed that envelope straight back to callers while its own docblock declared it returned "the unwrapped item content". Every consumer reading `obj.fields` therefore read `undefined`.

The visible cost was the entire field-level half of the permission matrix: expanding any object in `/_console/apps/:app/metadata/permission/:set` reported "No fields registered for this object." with zero checkboxes, for every object, while the network showed that object's 21 fields arriving 200 OK. Reproduced against two objects on fresh loads, and proven not to be the read-only gate — a run with the editor fully writable (864 enabled checkboxes) still showed an empty field sub-table, which is exactly what a read resolving `undefined` predicts.

That was one symptom of nine. A census of every `get()` call site found **zero** deliberate readers of the envelope and nine consumers reading the body directly, all of them broken the same way: the RLS CEL editor's field lint and autocomplete resolved an empty field set; the dataset inspectors and the preview field/catalog hooks came back empty; the report drill-down's fallback path read `def.object` off the envelope, found nothing and silently returned; the record-page seed synthesized a default layout from an envelope instead of an object; and the Field Designer read `raw.fields` for display and then wrote `{ ...raw, fields }` back — saving the envelope over the object body. None of it was caught, because the test doubles across the repo were written against the docblock: they answered a bare `{ fields }` body, so the suite exercised the documented contract while production ran the other one.

The fix is at the producer, not the nine consumers. `get()` now unwraps the envelope once, at the client boundary — so every one of those call sites is repaired without being touched. Detection is by the PRESENCE of the three keys `GetMetaItemResponseSchema` declares (`type: string`, `name: string`, an `item` slot), never guessed from payload contents: a metadata document carrying its own `type` and `name` (a view is `{ name, type: 'grid', … }`) has no `item` and is left whole, and a document with an `item` property of its own but no envelope identity is likewise untouched. Key count is deliberately not part of the test, since a real envelope also spreads the ADR-0008 protection carriers. Anything that is not the envelope — an older server answering the bare document — passes through byte-for-byte, and 404 still reads as `null`.

`getDraft()` is unchanged and keeps returning the envelope, which its docblock declares and roughly eleven call sites depend on by reading `.item`. That asymmetry is now real rather than aspirational: the two methods share one private transport, and differ only in whether they unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft` (this package) remain the shared helpers for taking a draft body out, and both were already tolerant of either shape, so the two seams that reach a draft through `get()` keep their exact semantics — including reading an empty draft as "nothing pending".

Minor rather than patch: this moves published behavior for existing callers, the same grading `find()`'s resolve-to-reject change took. No signature changed — the `.d.ts` diff is documentation plus one private member — so nothing needs a code edit to keep compiling; a caller that had written its own `.item` compensator against the old behavior would need to drop it, and none exists in this repo.
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#4271 — the field half of the permission matrix, driven through a
* REAL `MetadataClient` answering the REAL server shape.
*
* Every other test in this family hands the editor a hand-rolled client whose
* `get()` returns a bare `{ fields }` body — i.e. the shape the client's
* DOCBLOCK promises. Production answers `GET /meta/:type/:name` with the
* spec-declared envelope `{ type, name, item }` (objectstack#5563), so the
* doubles agreed with the documentation while the real client disagreed with
* both, and the whole suite stayed green while the field sub-table was dead for
* every object on every screen.
*
* These cases close that gap: `useMetadataClient` is mocked to hand back a
* genuine `MetadataClient` wired to a fetch that answers exactly what the
* framework answers. Nothing here mocks `get()` itself.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataClient } from '@object-ui/data-objectstack';

/** The card's live object: `showcase_project`, 21 fields, served under `item`. */
const FIELD_NAMES = [
'amount', 'budget', 'category', 'closed_at', 'code', 'created_at', 'description',
'due_date', 'email', 'is_active', 'name', 'notes', 'owner_id', 'phone', 'priority',
'region', 'revenue', 'stage', 'status', 'tags', 'updated_at',
];

const OBJECT_ITEM = {
name: 'showcase_project',
label: 'Project',
fields: Object.fromEntries(FIELD_NAMES.map((n) => [n, { type: 'text', label: n }])),
};

/** Exactly what `GET /api/v1/meta/object/showcase_project` returns (200). */
const OBJECT_ENVELOPE = { type: 'object', name: 'showcase_project', item: OBJECT_ITEM };

let capturedFacetProps: Record<string, any> | null = null;

vi.mock('./PermissionAdvancedFacets', () => ({
PermissionAdvancedFacets: (props: Record<string, any>) => {
capturedFacetProps = props;
return null;
},
}));

let clientImpl: MetadataClient;

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(() => {
cleanup();
capturedFacetProps = null;
});

function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

/** A real MetadataClient over a fetch that speaks the framework's shapes. */
function realClient(): MetadataClient {
return new MetadataClient({
baseUrl: 'http://localhost:3000',
fetch: (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/meta/permission/sales_perms?layers=true')) {
return json({
code: null,
overlay: null,
overlayScope: null,
effective: {
name: 'sales_perms',
label: 'Sales',
objects: { showcase_project: { allowRead: true } },
fields: {},
},
});
}
// The single-item read — the ONE shape #5563 left standing.
if (/\/meta\/object\/showcase_project(\?|$)/.test(url)) return json(OBJECT_ENVELOPE);
if (/\/meta\/object(\?|$)/.test(url)) {
return json({ items: [{ name: 'showcase_project', label: 'Project' }] });
}
return json({ items: [] });
}) as unknown as typeof fetch,
});
}

async function renderExpanded() {
clientImpl = realClient();
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: /showcase_project/ }));
}

describe('objectui#4271 · the field sub-table survives the real server envelope', () => {
it('B1: lists the object’s fields with a readable/editable pair each', async () => {
await renderExpanded();

// The card's headline symptom, gone.
expect(await screen.findByLabelText('showcase_project.email readable')).toBeInTheDocument();
expect(screen.queryByText('No fields registered for this object.')).toBeNull();

// All 21 fields, both checkboxes each — the 42 the matrix owed this object.
for (const f of FIELD_NAMES) {
expect(screen.getByLabelText(`showcase_project.${f} readable`)).toBeInTheDocument();
expect(screen.getByLabelText(`showcase_project.${f} editable`)).toBeInTheDocument();
}

// 21 > 6, so the field filter is offered — the affordance the QA run
// (objectstack#7695) could not exercise because no row ever rendered.
expect(screen.getByLabelText('Filter fields…')).toBeInTheDocument();
});

it('B1b: the checkbox pair is live, not decorative', async () => {
await renderExpanded();

// Default field posture is readable-but-not-editable, so `editable` is the
// half with somewhere to travel.
const editable = await screen.findByLabelText('showcase_project.stage editable');
expect(editable).not.toBeChecked();
expect(screen.getByLabelText('showcase_project.stage readable')).toBeChecked();

fireEvent.click(editable);
expect(screen.getByLabelText('showcase_project.stage editable')).toBeChecked();
});

it('B2: loadObjectFields feeds the RLS CEL editor real field names', async () => {
// The collateral the card names: CEL lint + autocomplete resolve their
// field set through the same repaired read.
await renderExpanded();
expect(capturedFacetProps?.loadObjectFields).toBeTypeOf('function');

const names = await capturedFacetProps!.loadObjectFields('showcase_project');
expect(names).toEqual([...FIELD_NAMES].sort((a, b) => a.localeCompare(b)));
});
});
29 changes: 29 additions & 0 deletions packages/app-shell/src/views/runtime-metadata-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,33 @@ describe('runtime-metadata-persistence seam (ADR-0034)', () => {
expect(unwrapDraftBody({})).toBeNull();
});
});

/**
* objectui#4271 — `readRuntimeDraft` reaches the server through
* `MetadataClient.get()`, which now unwraps the `{type,name,item}` envelope
* at the client boundary. So the value this seam actually receives has
* changed shape even though its own code did not. These pin it at the NEW
* read; the envelope cases above stay because `unwrapDraftBody` is still the
* shared helper for `getDraft()`, which does hand back the envelope.
*/
describe('readRuntimeDraft against the unwrapped get() contract (#4271)', () => {
it('resolves the draft body when get() has already unwrapped it', async () => {
const metadataClient = makeMetadataClient();
metadataClient.get.mockResolvedValue({ regions: [{ name: 'x' }] });
const draft = await readRuntimeDraft('page', 'invoice_record', { metadataClient });
expect(metadataClient.get).toHaveBeenCalledWith('page', 'invoice_record', { state: 'draft' });
expect(draft).toEqual({ regions: [{ name: 'x' }] });
});

it('still reads "nothing pending" as null across both shapes', async () => {
const metadataClient = makeMetadataClient();
// 404 → get() answers null.
metadataClient.get.mockResolvedValue(null);
expect(await readRuntimeDraft('page', 'p', { metadataClient })).toBeNull();
// An empty draft body must not read as a pending draft — this is what
// gates the "unpublished changes" indicator.
metadataClient.get.mockResolvedValue({});
expect(await readRuntimeDraft('page', 'p', { metadataClient })).toBeNull();
});
});
});
16 changes: 10 additions & 6 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,12 +1031,16 @@ export function viewItemObjectName(item: any): string | undefined {
* Unwrap a `?state=draft` view read into its bare body, or `null` when there
* is nothing pending (#4139).
*
* The framework answers draft reads in a `{type, name, item}` envelope while a
* published read is the bare body — an asymmetry `MetadataClient.getDraft`
* documents and deliberately preserves. An empty body is normalized to `null`
* so the caller's "is this view draft-backed?" test is a plain truthiness
* check. Mirrors app-shell's `unwrapDraftBody` (ADR-0034 seam); the two live
* apart because the seam sits above this adapter, not beside it.
* The framework answers EVERY single-item read — draft or published — in a
* `{type, name, item}` envelope. `MetadataClient.get()` unwraps it at the
* client boundary (objectui#4271) while `getDraft()` deliberately hands the
* envelope back, so this helper stays tolerant of both: the call below reaches
* it through `get()` and therefore already holds the body, and the passthrough
* limb keeps it correct for an envelope arriving by any other route. An empty
* body is normalized to `null` so the caller's "is this view draft-backed?"
* test is a plain truthiness check. Mirrors app-shell's `unwrapDraftBody`
* (ADR-0034 seam); the two live apart because the seam sits above this
* adapter, not beside it.
*/
function unwrapViewDraft(resp: unknown): Record<string, any> | null {
if (!resp || typeof resp !== 'object') return null;
Expand Down
Loading
Loading