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
45 changes: 45 additions & 0 deletions .changeset/7741-list-import-mappings-discriminate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

`listImportMappings` no longer renders a refused door as "no mapping is registered"
(objectui#7741).

`ObjectStackAdapter.listImportMappings` degrades every failure to an empty list, and the
import wizard hides its saved-mapping selector on an empty list. So "the server served
zero mappings" and "the server refused, or broke" produced the identical UI on every
deployment — the feature simply absent — with a `console.warn` as the only
discriminator, in the browser console, with nothing pointing at it. That silence did not
merely hide a fault: it produced a confident WRONG diagnosis in a careful reporter
(objectstack#14026 was filed, routed and worked by two seats against a wizard that had
been correct since `@object-ui/data-objectstack@17.1.0`).

**The empty-list return is unchanged.** `listImportMappings` still answers
`Promise<any[]>` and still never throws, on every arm including the loud ones — this is
a channel added ALONGSIDE that contract, not a change to it.

- **New: `ObjectStackAdapter.onMetadataReadWarning(cb)`** — a subscribe/unsubscribe
channel, sibling in shape to `onWriteWarning` and `onSaveAdvisory`. It fires when a
metadata read failed in a way that is NOT the supported "this deployment does not
serve that kind" shape, carrying `MetadataReadWarningEvent`: which read it was, the
object, whether the server `refused` this caller or the answer was `unreadable`, and
the server's own ADR-0112 code, HTTP status and message.
- **New: `classifyImportMappingsFailure(err)`** and `ImportMappingsFailureKind`, exported
so a consumer can apply the same verdict. It reads the ERROR — the ADR-0112 `code`
first, the status only where no code was declared — and never "is the result an empty
array", which is what both conditions produce and so can never tell them apart.
- **The older-server case stays quiet.** A deployment that does not serve the `mapping`
kind (404/501 with no route, `ROUTE_NOT_FOUND`, `NOT_IMPLEMENTED`, or the metadata list
door's 400 `INVALID_REQUEST`) still degrades to an empty list with no selector and no
event. That is a real, supported deployment shape and it must not become a visible
fault.
- **The console now says so.** `AdapterProvider` subscribes to the new channel and
renders a warning toast naming the object, the remedy and the server's own words, so a
user without devtools open can tell "there are none" from "we could not find out".
Three new `console.importMappings*` keys ship in all ten locale packs.

This applies framework #13906 decision 1 option A — *a thing that could not be READ is
not a thing that is ABSENT* — at this seam. It is an already-adopted discrimination, not
a new principle.
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The MIDDLE link of the metadata read-warning chain (objectui#7741): an
* emitted event actually REACHES the sink.
*
* Sibling of `AdapterProvider.advisorySink.test.tsx` (objectui#7116), and it
* exists for the reason that file measured: the two ENDS of a channel can both
* be green while the wire between them is cut.
*
* producer ObjectStackAdapter.listImportMappings classifies the failure and
* emits on onMetadataReadWarning
* -> pinned by data-objectstack/src/listImportMappings.test.ts
* MIDDLE AdapterProvider subscribes and renders through
* emitMetadataReadWarning into sonner
* -> pinned HERE
* renderer emitMetadataReadWarning turns an event into the warning
* -> pinned by metadataReadWarningToast.test.ts
*
* ## Why the provider must build its own adapter
*
* `AdapterProvider` takes an optional `adapter` prop, and passing it makes the
* effect return EARLY — before the subscription is installed. So a test that
* hands in a ready-made adapter cannot see this seam at all. Nothing is passed
* here; the provider runs its real `init()`, constructs the real adapter, and
* the child reads that instance back out of the context the provider publishes.
*
* Stubbed, and only these two: `sonner` (the terminal sink — `AdapterProvider`
* imports `toast` as a module binding, so intercepting the module is the only
* way to observe what arrives) and `globalThis.fetch` (the server). Everything
* between is real.
*/

import { useEffect } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';

vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
message: vi.fn(),
},
}));

import { toast } from 'sonner';
import { AdapterProvider, useAdapter } from './AdapterProvider';

/** The object the objectstack#14026 misdiagnosis was actually about. */
const OBJECT = 'crm_plant_cost';

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

let fetchMock: ReturnType<typeof vi.fn>;

/** Mapping reads that actually left — the control for any zero below. */
function mappingReadCount(): number {
return fetchMock.mock.calls.filter(([input]) =>
String(typeof input === 'string' ? input : (input as Request).url).includes('/meta/mapping'),
).length;
}

/**
* A `fetch` that answers discovery, and answers `GET /meta/mapping` with
* `mappingAnswer`. Discovery is served because the provider's `init()` awaits
* `connect()` before it publishes the adapter to children.
*/
function serverAnswers(mappingAnswer: () => Response) {
fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
);
if (url.includes('/discovery')) return jsonResponse({ success: true, data: {} });
if (url.includes('/meta/mapping')) return mappingAnswer();
return jsonResponse({ success: false }, 404);
});
vi.stubGlobal('fetch', fetchMock);
}

let captured: { listImportMappings(objectName: string): Promise<unknown[]> } | null = null;

function CaptureAdapter() {
const adapter = useAdapter();
useEffect(() => {
captured = adapter as unknown as typeof captured;
}, [adapter]);
return null;
}

async function mountProvider() {
const view = render(
<AdapterProvider>
<CaptureAdapter />
</AdapterProvider>,
);
await waitFor(() => expect(captured).not.toBeNull());
return view;
}

function warningCall(): [string, { description?: string; duration?: number } | undefined] {
const calls = vi.mocked(toast.warning).mock.calls;
expect(calls).toHaveLength(1);
return calls[0] as [string, { description?: string; duration?: number } | undefined];
}

let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
captured = null;
vi.mocked(toast.warning).mockClear();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
serverAnswers(() =>
jsonResponse({ success: false, error: { code: 'PERMISSION_DENIED', message: 'manage_metadata required' } }, 403),
);
});

afterEach(() => {
cleanup();
warnSpy.mockRestore();
vi.unstubAllGlobals();
});

describe('AdapterProvider — a refused metadata read reaches the toast sink (objectui#7741)', () => {
it('a refused mapping read through the provider-built adapter is announced', async () => {
await mountProvider();

// The return is unchanged: the caller is still handed an empty list.
await expect(captured!.listImportMappings(OBJECT)).resolves.toEqual([]);

const [title, options] = warningCall();
// The object name proves the event's payload survived the whole seam.
expect(title).toContain(OBJECT);
expect(options?.description).toContain('could not be read');
// The server's own code travelled too — the field the adapter branched ON.
expect(options?.description).toContain('PERMISSION_DENIED');
});

it('CONTROL: a served answer says nothing', async () => {
serverAnswers(() => jsonResponse({ type: 'mapping', items: [] }));
await mountProvider();

await expect(captured!.listImportMappings(OBJECT)).resolves.toEqual([]);

// The zero is only a reading beside a control that MUST hit: the read
// really did travel, so the silence is about a served empty collection and
// not about a chain that never ran.
expect(mappingReadCount()).toBe(1);
expect(toast.warning).not.toHaveBeenCalled();
});

it('CONTROL: a deployment that does not serve the `mapping` kind stays quiet', async () => {
// ⛔ The one case that must NOT become a visible fault: a real, supported
// older deployment. Same empty list, same hidden selector, no toast.
serverAnswers(() =>
jsonResponse(
{ success: false, error: { code: 'INVALID_REQUEST', message: "'mapping' is not a metadata type." } },
400,
),
);
await mountProvider();

await expect(captured!.listImportMappings(OBJECT)).resolves.toEqual([]);

expect(mappingReadCount()).toBe(1);
expect(toast.warning).not.toHaveBeenCalled();
});

it('the subscription is released on unmount', async () => {
const { unmount } = await mountProvider();

// Control: the channel is live BEFORE unmount.
await captured!.listImportMappings(OBJECT);
expect(toast.warning).toHaveBeenCalledTimes(1);

const adapter = captured!;
unmount();
vi.mocked(toast.warning).mockClear();

await adapter.listImportMappings(OBJECT);
expect(toast.warning).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions packages/app-shell/src/providers/AdapterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n';
import { installSettleSignalGlobal, withSettleSignal } from '../observability/settleSignal.js';
import { emitWriteWarning, type TranslateFn } from './writeWarningToast.js';
import { emitSaveAdvisories } from './saveAdvisoryToast.js';
import { emitMetadataReadWarning } from './metadataReadWarningToast.js';

export { useAdapter } from '@object-ui/react';

Expand Down Expand Up @@ -54,6 +55,7 @@ export function AdapterProvider({ children, adapter: externalAdapter }: AdapterP
let cancelled = false;
let unsubscribeWriteWarning: (() => void) | undefined;
let unsubscribeSaveAdvisory: (() => void) | undefined;
let unsubscribeMetadataReadWarning: (() => void) | undefined;

// Expose window.__objectui.{pendingRequests,idle,whenIdle} so an automated
// (AI) browser driver has one "is the app settled?" predicate (ADR-0054 C5).
Expand Down Expand Up @@ -92,6 +94,19 @@ export function AdapterProvider({ children, adapter: externalAdapter }: AdapterP
emitSaveAdvisories(ev, tRef.current as TranslateFn, toast);
});

// Surface a metadata READ that could not be answered and was degraded
// to an empty result anyway (objectui#7741). Without this the import
// wizard's saved-mapping selector is hidden identically whether the
// deployment registered no mapping or the server refused the read —
// the ambiguity that produced the objectstack#14026 misdiagnosis. The
// supported "this server does not serve that kind" case never reaches
// here: the adapter classifies it and emits nothing, so an older
// deployment stays quiet. `t` rides the same ref as the two channels
// above, and for the same reason.
unsubscribeMetadataReadWarning = a.onMetadataReadWarning((ev) => {
emitMetadataReadWarning(ev, tRef.current as TranslateFn, toast);
});

await a.connect();

if (!cancelled) {
Expand All @@ -109,6 +124,7 @@ export function AdapterProvider({ children, adapter: externalAdapter }: AdapterP
cancelled = true;
unsubscribeWriteWarning?.();
unsubscribeSaveAdvisory?.();
unsubscribeMetadataReadWarning?.();
};
}, [externalAdapter]);

Expand Down
Loading
Loading