diff --git a/.changeset/7741-list-import-mappings-discriminate.md b/.changeset/7741-list-import-mappings-discriminate.md new file mode 100644 index 0000000000..18530a9e29 --- /dev/null +++ b/.changeset/7741-list-import-mappings-discriminate.md @@ -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` 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. diff --git a/packages/app-shell/src/providers/AdapterProvider.readWarningSink.test.tsx b/packages/app-shell/src/providers/AdapterProvider.readWarningSink.test.tsx new file mode 100644 index 0000000000..327a830e27 --- /dev/null +++ b/packages/app-shell/src/providers/AdapterProvider.readWarningSink.test.tsx @@ -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; + +/** 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 } | null = null; + +function CaptureAdapter() { + const adapter = useAdapter(); + useEffect(() => { + captured = adapter as unknown as typeof captured; + }, [adapter]); + return null; +} + +async function mountProvider() { + const view = render( + + + , + ); + 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; + +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(); + }); +}); diff --git a/packages/app-shell/src/providers/AdapterProvider.tsx b/packages/app-shell/src/providers/AdapterProvider.tsx index aa396a951e..539c77e42c 100644 --- a/packages/app-shell/src/providers/AdapterProvider.tsx +++ b/packages/app-shell/src/providers/AdapterProvider.tsx @@ -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'; @@ -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). @@ -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) { @@ -109,6 +124,7 @@ export function AdapterProvider({ children, adapter: externalAdapter }: AdapterP cancelled = true; unsubscribeWriteWarning?.(); unsubscribeSaveAdvisory?.(); + unsubscribeMetadataReadWarning?.(); }; }, [externalAdapter]); diff --git a/packages/app-shell/src/providers/metadataReadWarningToast.test.ts b/packages/app-shell/src/providers/metadataReadWarningToast.test.ts new file mode 100644 index 0000000000..ee3d53521b --- /dev/null +++ b/packages/app-shell/src/providers/metadataReadWarningToast.test.ts @@ -0,0 +1,139 @@ +/** + * 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 RENDERER half of the metadata read-warning chain (objectui#7741): an + * event becomes the message a user without devtools can act on. + * + * The connection between the adapter's channel and this renderer is + * `AdapterProvider.readWarningSink.test.tsx`'s; the classification that decides + * whether an event exists at all is `data-objectstack`'s + * `listImportMappings.test.ts`. This file owns only the wording and the + * branching, over events it writes by hand. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { MetadataReadWarningEvent } from '@object-ui/data-objectstack'; +import { + emitMetadataReadWarning, + type MetadataReadWarningSink, +} from './metadataReadWarningToast'; + +/** The provider-less English fallback, which is what `t` resolves to here. */ +const t = (key: string, options?: Record): string => { + const raw = String(options?.defaultValue ?? key); + return raw.replace(/\{\{(\w+)\}\}/g, (_m, hole: string) => String(options?.[hole] ?? `{{${hole}}}`)); +}; + +function sink() { + return { warning: vi.fn() } satisfies MetadataReadWarningSink; +} + +const REFUSED: MetadataReadWarningEvent = { + operation: 'listImportMappings', + kind: 'mapping', + objectName: 'crm_plant_cost', + reason: 'refused', + code: 'UNAUTHENTICATED', + status: 401, + message: 'authentication required', +}; + +describe('emitMetadataReadWarning (objectui#7741)', () => { + it('names the object the empty list is about', () => { + const s = sink(); + + emitMetadataReadWarning(REFUSED, t, s); + + const [title] = s.warning.mock.calls[0]; + expect(title).toContain('crm_plant_cost'); + }); + + it('⭐ says the list is empty because it could not be READ, not because it is empty', () => { + // The whole point of the surface. A message that merely reported an error + // would leave the user's actual question — "are there no saved mappings?" — + // unanswered, which is the ambiguity that produced objectstack#14026. + const s = sink(); + + emitMetadataReadWarning(REFUSED, t, s); + + const [, options] = s.warning.mock.calls[0]; + expect(options.description).toContain('could not be read'); + expect(options.description).toContain('not because nothing is registered'); + }); + + it('gives a refusal a remedy that names a person, not a retry', () => { + const s = sink(); + + emitMetadataReadWarning(REFUSED, t, s); + + const [, options] = s.warning.mock.calls[0]; + expect(options.description).toContain('Sign in again'); + }); + + it('gives an unreadable answer the retry remedy instead', () => { + const s = sink(); + + emitMetadataReadWarning({ ...REFUSED, reason: 'unreadable', code: undefined, status: 500, message: 'metadata store unavailable' }, t, s); + + const [, options] = s.warning.mock.calls[0]; + expect(options.description).toContain('Try again'); + expect(options.description).not.toContain('Sign in again'); + }); + + it("carries the server's own words verbatim, so the user has evidence to paste", () => { + const s = sink(); + + emitMetadataReadWarning(REFUSED, t, s); + + const [, options] = s.warning.mock.calls[0]; + expect(options.description).toContain('UNAUTHENTICATED'); + expect(options.description).toContain('HTTP 401'); + expect(options.description).toContain('authentication required'); + }); + + it('adds no detail line at all when the failure declared nothing', () => { + const s = sink(); + + emitMetadataReadWarning( + { operation: 'listImportMappings', kind: 'mapping', objectName: 'task', reason: 'unreadable' }, + t, + s, + ); + + const [, options] = s.warning.mock.calls[0]; + // One line — the remedy — and no trailing empty parenthesis pretending the + // server said something. + expect(options.description).not.toContain('\n'); + expect(options.description).not.toContain('HTTP'); + }); + + it('refuses an unhandled reason rather than rendering the wrong remedy', () => { + // Unreachable for a type-checked caller; reachable for a JS one, because + // the event type is published. The caller swallows, so the failure mode is + // "no toast", never "a toast naming the wrong fix". + const s = sink(); + + expect(() => + emitMetadataReadWarning( + { ...REFUSED, reason: 'exploded' as unknown as MetadataReadWarningEvent['reason'] }, + t, + s, + ), + ).toThrow(/no remedy for reason/); + }); + + it('uses the warning tier and the long duration, like its advisory sibling', () => { + const s = sink(); + + emitMetadataReadWarning(REFUSED, t, s); + + const [, options] = s.warning.mock.calls[0]; + expect(options.duration).toBe(10_000); + }); +}); diff --git a/packages/app-shell/src/providers/metadataReadWarningToast.ts b/packages/app-shell/src/providers/metadataReadWarningToast.ts new file mode 100644 index 0000000000..9d7318b2c3 --- /dev/null +++ b/packages/app-shell/src/providers/metadataReadWarningToast.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ + +/** + * Turning a metadata read-warning (objectui#7741) into the message the user + * reads. + * + * Lives apart from `AdapterProvider` — and, deliberately, imports NOTHING that + * renders — so the wording and the reason-branching can be exercised directly. + * Same split, and the same reason, as its siblings `writeWarningToast.ts` and + * `saveAdvisoryToast.ts`: the caller owns the sink, the test hands over its + * own, and no module mock is needed. + * + * ## Why this surface has to exist at all + * + * `listImportMappings` degrades every failure to an empty list, and the import + * wizard hides its saved-mapping selector on an empty list. So a refused or + * broken door rendered exactly like "no mapping is registered": the feature was + * simply ABSENT, with a `console.warn` as the only discriminator — in the + * browser console, with nothing in the UI pointing at it. A user without + * devtools open could not tell the two apart, and neither could a careful + * reporter: objectstack#14026 was filed, routed and worked by two seats on that + * misreading. Promoting the log level would not have changed any of it. This + * module is the half that makes the failure visible where the decision is made. + * + * ## What it deliberately does NOT say + * + * Nothing about the supported case. A server that does not serve the `mapping` + * kind never reaches here — the adapter classifies that arm as `not-served` and + * emits no event — so an older deployment keeps its quiet, empty, selector-less + * wizard and earns no toast. Turning a real deployment shape into a visible + * fault is the failure this surface must not commit. + * + * ## Why the server's own words are appended untranslated + * + * `code`, `status` and `message` are SERVER data. They are the evidence that + * separates "could not be read" from "there are none", they are what a user + * pastes into a bug report, and they go stale the moment the producer rewords + * them. Only the frame — the title and the remedy sentence — is i18n copy, the + * same division `saveAdvisoryToast.ts` draws. + * + * @module providers/metadataReadWarningToast + */ + +import type { MetadataReadWarningEvent } from '@object-ui/data-objectstack'; +import type { TranslateFn } from './writeWarningToast.js'; + +/** + * i18next's `t`, narrowed to what this module uses — RE-EXPORTED from + * `writeWarningToast`, never re-declared. + * + * The rest of this module was modelled on its two siblings, and a fourth copy + * of their local `export type TranslateFn = …` came along with the pattern. + * That name already has three declarations (`writeWarningToast`, + * `saveAdvisoryToast`, `fields/src/widgets/file-size-guard`), and the + * objectui#6172 甲/A1 ruling is that every exported name has exactly one + * authority — so a fourth is the one thing this file must not add. + * `scripts/__tests__/one-authority-per-exported-name-6273.test.ts` caught it, + * and its remedy is this: `export type { X } from ''` is a + * re-export, not a second declaration, and the gate does not count it. ⛔ The + * baseline it also carries is SHRINK-ONLY and is not an option here. + * + * Why `writeWarningToast` is the one pointed at, from evidence already in the + * tree rather than a judgement made here: + * + * - `AdapterProvider` — this module's only caller — already imports + * `TranslateFn` from `./writeWarningToast.js` and passes that very value + * into all three emitters, including this one. Pointing here is therefore + * the wiring that already exists, not a new claim about which file owns + * the name. + * - `file-size-guard.ts`'s own declaration names + * `app-shell/src/providers/writeWarningToast` as "the established + * `TranslateFn` pattern" it was copied from. + * + * ⛔ This does NOT resolve the pre-existing three-way collision, and is not an + * attempt to: that predates this card and repairing it belongs to its own. + * This file's obligation is only to stop adding to it. + */ +export type { TranslateFn } from './writeWarningToast.js'; + +/** + * Where the message goes. Structurally satisfied by sonner's `toast`, which is + * what `AdapterProvider` passes. + * + * Required rather than defaulted to `sonner` for the same reason its two + * siblings are: a default would mean importing the toaster here, which is + * precisely the dependency that has to stay out of this module. + */ +export interface MetadataReadWarningSink { + warning(title: string, options?: { description?: string; duration?: number }): void; +} + +/** + * How long the warning stays on screen. The body is a remedy plus the server's + * own words, which the user has to actually read — the same 10s the advisory + * surfaces use, so the warning tier behaves alike wherever it appears. + */ +const READ_WARNING_TOAST_MS = 10_000; + +/** + * The remedy sentence, chosen by WHICH loud verdict this was. + * + * An exhaustive `switch` with a `never` check rather than a ternary, for the + * reason `saveAdvisoryToast.advisoryTitle` records: a ternary answers "is it + * refused, else unreadable", so a THIRD reason added to the union would compile + * everywhere and silently render the wrong remedy. Here it is a compile error + * instead — the type must not merely be STATED, it must be HANDLED. + * + * The `default` branch is unreachable for type-checked callers; it exists for + * an untyped one (the event type is published, and JS consumers are not bound + * by it). It throws rather than falling back to either sentence, because the + * caller wraps this in a try/catch that swallows: the failure mode is therefore + * "no toast", never "a toast naming the wrong remedy". + */ +function remedy(ev: MetadataReadWarningEvent, t: TranslateFn): string { + switch (ev.reason) { + case 'refused': + return t('console.importMappingsRefused', { + defaultValue: + 'The server refused this request, so this list is empty because it could not be read — not because nothing is registered. Sign in again, or ask an administrator for access.', + }); + case 'unreadable': + return t('console.importMappingsUnreadable', { + defaultValue: + 'This list is empty because it could not be read, not because nothing is registered. Try again, and report this if it keeps happening.', + }); + default: { + const unhandled: never = ev.reason; + throw new Error( + `metadataReadWarningToast: no remedy for reason ${JSON.stringify(unhandled)}`, + ); + } + } +} + +/** + * The server's own words about its own answer, as one line — or nothing at all + * when it sent none. + * + * Assembled from whichever of the three the event carries, in the order a + * reader needs them: the ADR-0112 code (the contract field the adapter branched + * ON), the HTTP status, then the message. A failure that declared none of them + * — a dropped connection, say — adds no line rather than an empty parenthesis. + */ +function serverDetail(ev: MetadataReadWarningEvent): string | undefined { + const head = [ev.code, ev.status !== undefined ? `HTTP ${ev.status}` : undefined] + .filter((p): p is string => typeof p === 'string' && p.length > 0) + .join(' · '); + const parts = [head, ev.message].filter((p): p is string => typeof p === 'string' && p.length > 0); + return parts.length > 0 ? parts.join(' — ') : undefined; +} + +/** + * Announce a metadata read that could not be answered and was degraded to an + * empty result anyway. + * + * The title names the object, because that is the scope the empty list is about + * and the wizard the user is standing in is open on exactly one object. + */ +export function emitMetadataReadWarning( + ev: MetadataReadWarningEvent, + t: TranslateFn, + sink: MetadataReadWarningSink, +): void { + const title = t('console.importMappingsUnavailable', { + object: ev.objectName, + defaultValue: 'Saved import mappings for {{object}} could not be loaded', + }); + const detail = serverDetail(ev); + sink.warning(title, { + description: detail ? `${remedy(ev, t)}\n${detail}` : remedy(ev, t), + duration: READ_WARNING_TOAST_MS, + }); +} diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 99f408b1c5..b04146ae88 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -1839,6 +1839,224 @@ export interface WriteWarningEvent { /** Event listener type for write-warning (dropped-fields) events. */ export type WriteWarningListener = (event: WriteWarningEvent) => void; +/** + * Codes that mean THE DOOR IS NOT THERE — the deployment never mounted the + * `/meta` route this read goes through, so no answer about the `mapping` kind + * exists to be had (objectui#7741). + * + * Both are the runtime dispatcher's own words, and both are already read this + * way one face over by {@link classifyAnalyticsFailure} for the same question: + * `ROUTE_NOT_FOUND` (framework#4019 stops mounting a route at all) and + * `NOT_IMPLEMENTED` (the route is mounted with nothing behind it). + */ +const IMPORT_MAPPINGS_ROUTE_ABSENT_CODES = ['ROUTE_NOT_FOUND', 'NOT_IMPLEMENTED'] as const; + +/** + * The code the metadata LIST door answers with when `:type` names a kind this + * deployment cannot serve (framework#9488 `refuseUnknownMetaListType`, in + * `packages/rest/src/rest-server.ts`), paired with the status it ships it on. + * + * `mapping` became a declared kind only when the ADR-0088 admission test + * accepted it (framework#2611; `metadata-plugin.zod.ts` lists it today), so a + * server older than that promotion has it in neither the static contract nor + * its live type set, and this is the refusal such a server writes. It is the + * modern spelling of exactly the condition this method's docblock has always + * promised to keep quiet — an older server without the `mapping` kind. (An + * even older one, predating framework#9488, answered `200 {"items":[]}` and so + * never reaches a `catch` at all; older still, with no `/meta` route, answers + * on {@link IMPORT_MAPPINGS_ROUTE_ABSENT_CODES}.) + * + * Matched on the code AND the status together, deliberately narrower than the + * code alone. `INVALID_REQUEST` is a general-purpose catalog code; what makes + * it readable as "this kind is not served" HERE is that this request carries + * nothing else to be invalid — `GET /meta/mapping`, no body, no query, one + * path segment we chose. Requiring the 400 keeps the quiet arm keyed to the + * one shape the framework documents rather than to a code that could arrive on + * some other door's terms. + */ +const IMPORT_MAPPINGS_UNKNOWN_KIND_CODE = 'INVALID_REQUEST'; +const IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS = 400; + +/** + * Statuses that identify absence ON THEIR OWN when the answer carried no + * ADR-0112 `code` at all — a bare transport 404/501 from a proxy, a gateway, + * or a host that never mounted the API, none of which any ObjectStack route + * wrote. + * + * NOT a re-entry for status-mapping: consulted only AFTER every code branch + * has declined, i.e. only when there is no contract field to read. Same + * residual, same reason, as {@link ANALYTICS_ABSENT_STATUSES} — this door's own + * 404s all ship a `code`, so a code-less 404 cannot be a refusal it wrote. + */ +const IMPORT_MAPPINGS_ABSENT_STATUSES: readonly number[] = [404, 501]; + +/** Codes that mean the server ANSWERED and declined this caller. */ +const IMPORT_MAPPINGS_REFUSAL_CODES = ['UNAUTHENTICATED', 'PERMISSION_DENIED'] as const; + +/** Statuses that are a refusal on their own terms, whatever code rides them. */ +const IMPORT_MAPPINGS_REFUSAL_STATUSES: readonly number[] = [401, 403, 405]; + +/** + * What a FAILED `listImportMappings` read actually was (objectui#7741). + * + * - `not-served` — this deployment does not serve the `mapping` kind, or has + * no `/meta` door at all. QUIET: a real, supported deployment shape, and the + * empty list plus a hidden selector is the correct rendering of it. + * - `refused` — the server answered and declined THIS caller: anonymous, + * lapsed token, missing permission, method withheld. LOUD, and the remedy + * names a person: sign in again, or ask for the grant. + * - `unreadable` — the read could not be completed for any other reason: a + * 5xx, a network failure, a code this consumer cannot name. LOUD, and the + * remedy is to retry or report. + * + * The last two are the same verdict for the user — *we could not find out* — + * and they are separated because the sentence that helps differs. What they + * share is what matters: neither is evidence that no mapping is registered. + */ +export type ImportMappingsFailureKind = 'not-served' | 'refused' | 'unreadable'; + +/** + * Classify a FAILED `meta.getItems('mapping')` call so the caller knows whether + * to stay quiet or to say something (objectui#7741). + * + * ## Why this function exists + * + * `listImportMappings` degrades every failure to `[]`, and the wizard hides its + * saved-mapping selector on an empty list. So "the server served zero mappings" + * and "the server never answered" rendered identically — the same UI, on every + * deployment, with a `console.warn` as the only discriminator. 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 repo whose wizard had been correct since `@object-ui/data-objectstack` + * 17.1.0), and the misdiagnosis travelled further than the fault would have. + * + * This is the discrimination framework #13906 decision 1 option A already + * adopted at the tenancy-posture seam — *a thing that could not be READ is not + * a thing that is ABSENT* — applied here. It is not a new principle. + * + * ## It reads the ERROR, never the result + * + * The one thing this must never do is decide from "is the result an empty + * array". An empty array is what BOTH conditions produce, so a test on it can + * never fail and can never separate *served zero* from *did not serve* — a + * reading that cannot fail is indistinguishable from a reading that passed. + * Every branch below reads `err` itself: the ADR-0112 `code` first (the + * contract), the status only where no code was declared (a transport fact many + * conditions share). Same rule, same order, and for the same reasons as + * {@link classifyAnalyticsFailure} (objectui#5663 / objectui#5721). + * + * `@objectstack/client`'s fetch wrapper hands both envelope families here + * already flattened — `errorBody?.code ?? errorBody?.error?.code` onto + * `error.code`, plus `error.httpStatus = res.status` — so no envelope reading + * belongs in this function. Comparisons go through `errorCodeIs`/ + * `errorCodeIsAnyOf` so the pre- and post-ADR-0112 spellings both match. + */ +export function classifyImportMappingsFailure(error: unknown): { + kind: ImportMappingsFailureKind; + code?: string; + status?: number; + message?: string; +} { + const err = (error ?? {}) as Record; + // An empty-string `code` is "the producer declared nothing", not a code — + // otherwise it would block the code-less residual while matching no branch. + const code = typeof err.code === 'string' && err.code.length > 0 ? err.code : undefined; + const message = typeof err.message === 'string' ? err.message : undefined; + const status = + typeof err.httpStatus === 'number' ? err.httpStatus + : typeof err.status === 'number' ? err.status + : typeof err.statusCode === 'number' ? err.statusCode + : undefined; + const found = { code, status, message }; + + // ① The `/meta` door itself is absent — nothing here can be asked at all. + if (errorCodeIsAnyOf({ code }, IMPORT_MAPPINGS_ROUTE_ABSENT_CODES)) { + return { kind: 'not-served', ...found }; + } + + // ② The door is there and says this deployment carries no such kind. + if ( + errorCodeIs({ code }, IMPORT_MAPPINGS_UNKNOWN_KIND_CODE) && + status === IMPORT_MAPPINGS_UNKNOWN_KIND_STATUS + ) { + return { kind: 'not-served', ...found }; + } + + // ③ The server answered and declined this caller. + if (errorCodeIsAnyOf({ code }, IMPORT_MAPPINGS_REFUSAL_CODES)) { + return { kind: 'refused', ...found }; + } + if (status !== undefined && IMPORT_MAPPINGS_REFUSAL_STATUSES.includes(status)) { + return { kind: 'refused', ...found }; + } + + // ④ Residual — the answer declared NO ADR-0112 code, so no ObjectStack route + // wrote it (a proxy, a gateway, a host with no API mounted). Only here is + // the bare status the best signal available, and only because every code + // branch has already declined. + if ( + code === undefined && + status !== undefined && + IMPORT_MAPPINGS_ABSENT_STATUSES.includes(status) + ) { + return { kind: 'not-served', ...found }; + } + + // ⑤ Everything else could not be read, and an unreadable answer is not an + // empty one. Deliberately NOT a silent bucket: this is where a 500, a + // dropped connection and a code this consumer cannot name all land, and + // none of them is evidence that no mapping is registered. + return { kind: 'unreadable', ...found }; +} + +/** + * Emitted when a metadata READ was answered by the server with a failure that + * is NOT the supported "this deployment does not serve that kind" shape, and + * the adapter degraded it to an empty result anyway (objectui#7741). + * + * The degrade is deliberate and unchanged — the caller still receives `[]`, and + * `listImportMappings`' published return type has not moved. This event is the + * channel ALONGSIDE it: the fact that the empty result is an artefact of a + * failed read, carried in a form a consumer can act on, instead of a + * `console.warn` nothing in the UI points at. + * + * Subscribe via {@link ObjectStackAdapter.onMetadataReadWarning}. A sibling of + * {@link WriteWarningEvent} and `MetadataSaveAdvisoryEvent` in SHAPE — one + * long-lived instance, `subscribe → unsubscribe`, `AdapterProvider` wires it + * once — and deliberately not a payload pushed down either of those: both of + * them describe a write that SUCCEEDED, so carrying a failed read on one would + * make the event lie about what happened. + * + * `operation` and `kind` are single-member unions on purpose. Exactly one + * emitter exists today, and a closed union states that honestly; a second + * emitter is an additive, reviewed widening rather than something a consumer's + * exhaustive switch discovers at runtime. (The same trade `WriteWarningEvent`'s + * required `operation` documents, taken deliberately here.) + */ +export interface MetadataReadWarningEvent { + /** The adapter method whose read failed. */ + operation: 'listImportMappings'; + /** The metadata kind it asked for. */ + kind: 'mapping'; + /** The object the read was scoped to. */ + objectName: string; + /** + * Which loud verdict this is — see {@link ImportMappingsFailureKind}. Never + * `'not-served'`: that arm is the supported deployment shape and is not + * emitted at all, so a subscriber never has to filter it out. + */ + reason: Exclude; + /** The server's own ADR-0112 code, when it declared one. */ + code?: string; + /** The HTTP status, when the failure carried one. */ + status?: number; + /** The server's own message, when it sent one. Rendered verbatim, not translated. */ + message?: string; +} + +/** Event listener type for metadata read-warning events. */ +export type MetadataReadWarningListener = (event: MetadataReadWarningEvent) => void; + // Re-export FileUploadResult from types for consumers export type { FileUploadResult } from '@object-ui/types'; @@ -2349,6 +2567,14 @@ export class ObjectStackAdapter implements DataSource { // record CRUD, this one is the metadata save door. private saveAdvisoryListeners = new Set(); + // Subscribers registered via onMetadataReadWarning(). Emitted when a metadata + // READ failed in a way that is NOT the supported "this deployment does not + // serve that kind" shape, and was degraded to an empty result anyway + // (objectui#7741). Third sibling of the two sets above: same seam shape, and + // the opposite direction — those two describe a write that succeeded, this + // one a read that did not happen. + private metadataReadWarningListeners = new Set(); + // [ADR-0066] The session's REPORTED system capabilities, pushed in by the // host (see `setSystemCapabilities`). `undefined` means NO answer was ever // reported — which is NOT the same as a reported-empty grant, and the two @@ -2988,6 +3214,47 @@ export class ObjectStackAdapter implements DataSource { } } + /** + * Subscribe to metadata read-warning events — a metadata READ that could not + * be answered and was degraded to an empty result anyway (objectui#7741). + * Returns an unsubscribe function. The app shell uses this to toast the user; + * the caller has already been handed the empty result. + * + * Deliberately the same seam as {@link onWriteWarning} and + * {@link onSaveAdvisory}, and a SIBLING of them rather than a payload pushed + * down either: both of those announce a write that SUCCEEDED, and an event + * saying "this read did not happen" carried on one of them would make it lie + * about what occurred. What is reused is the seam's SHAPE — one long-lived + * instance with a `subscribe -> unsubscribe` registration that + * `AdapterProvider` wires once — not its event type. + * + * ⛔ Nothing here changes what a caller RECEIVES. `listImportMappings` still + * answers `[]` on every failure, including the loud ones; this channel is + * added ALONGSIDE that return, which is why it is not a change to a surface + * published since `@object-ui/data-objectstack@17.1.0`. + */ + onMetadataReadWarning(callback: MetadataReadWarningListener): () => void { + this.metadataReadWarningListeners.add(callback); + return () => { + this.metadataReadWarningListeners.delete(callback); + }; + } + + /** + * Notify all metadata read-warning subscribers. Isolated exactly like + * {@link emitWriteWarning}: a throwing listener must neither break the caller + * nor starve the others. + */ + private emitMetadataReadWarning(event: MetadataReadWarningEvent): void { + for (const listener of this.metadataReadWarningListeners) { + try { + listener(event); + } catch (err) { + console.warn('ObjectStackAdapter: metadata read-warning listener error', err); + } + } + } + /** * Install the ONE emitter for the metadata save door (#4237). * @@ -4647,9 +4914,34 @@ export class ObjectStackAdapter implements DataSource { * List registered import `mapping` artifacts targeting a given object * (framework #2611). Reads the `mapping` metadata kind via the overlay API * and filters by `targetObject` client-side (the metadata index is - * name-only). Feeds the import wizard's "saved mapping" selector; a failure - * (older server without the `mapping` kind) degrades to an empty list, so - * the selector simply doesn't appear. + * name-only). Feeds the import wizard's "saved mapping" selector. + * + * ## Every failure still degrades to an empty list — and now says which kind + * ## of failure it was (objectui#7741) + * + * The degrade is unchanged and deliberate: an older server without the + * `mapping` kind must not break the wizard, so it keeps answering `[]` and + * the selector simply doesn't appear. That is a real, supported deployment + * shape and it stays quiet. + * + * What changed is that the OTHER failures no longer render as that one. A + * refused door and a broken one used to be indistinguishable, in the UI, from + * "no mapping is registered" — both were an empty list behind a + * `console.warn` nothing pointed at — and that silence 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 + * 17.1.0). So the `catch` now asks {@link classifyImportMappingsFailure} what + * the failure WAS, reading the error's own ADR-0112 `code` and status — + * never "is the result empty", which is what both conditions produce and so + * can never tell them apart — and anything that is not the supported + * kind-absent shape is announced on {@link onMetadataReadWarning}. + * + * ⛔ The RETURN is untouched. This method has answered `Promise`, never + * throwing, since `@object-ui/data-objectstack@17.1.0`; a consumer that reads + * nothing new sees exactly what it saw before, including on the loud arms. + * Applying framework #13906 decision 1 option A — *a thing that could not be + * READ is not a thing that is ABSENT* — is done by ADDING a channel, not by + * moving that contract. */ async listImportMappings(objectName: string): Promise { await this.connect(); @@ -4660,7 +4952,22 @@ export class ObjectStackAdapter implements DataSource { : Array.isArray(result) ? result : []; return items.filter((m: any) => m && m.targetObject === objectName); } catch (err) { + // Kept verbatim, on both arms. The console breadcrumb was never the + // problem — being the ONLY discriminator was — so it is not moved, not + // re-levelled, and not made conditional. console.warn('[OBJECTSTACKDataSource] listImportMappings failed:', err); + const failure = classifyImportMappingsFailure(err); + if (failure.kind !== 'not-served') { + this.emitMetadataReadWarning({ + operation: 'listImportMappings', + kind: 'mapping', + objectName, + reason: failure.kind, + ...(failure.code !== undefined ? { code: failure.code } : {}), + ...(failure.status !== undefined ? { status: failure.status } : {}), + ...(failure.message !== undefined ? { message: failure.message } : {}), + }); + } return []; } } diff --git a/packages/data-objectstack/src/listImportMappings.test.ts b/packages/data-objectstack/src/listImportMappings.test.ts index c7c0dc399f..ed9741fb15 100644 --- a/packages/data-objectstack/src/listImportMappings.test.ts +++ b/packages/data-objectstack/src/listImportMappings.test.ts @@ -6,8 +6,13 @@ * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ObjectStackAdapter, + clearSharedDiscoveryCache, + classifyImportMappingsFailure, + type MetadataReadWarningEvent, +} from './index'; /** * `listImportMappings(objectName)` feeds the import wizard's "saved mapping" @@ -116,3 +121,308 @@ describe('ObjectStackDataSource.listImportMappings (objectui#14026)', () => { expect(await ds.listImportMappings('project')).toEqual([]); }); }); + +/** + * ## The three states, and why the RESULT can only ever show two (objectui#7741) + * + * `listImportMappings` degrades every failure to `[]`, and the wizard hides its + * saved-mapping selector on an empty list. So the three states a caller has to + * tell apart — + * + * has mappings -> a non-empty list + * no mappings -> `[]` (the server served zero; supported, quiet) + * could not read -> `[]` (the server refused or broke) + * + * — collapse to two on the return value, and the two that collapse are exactly + * the two that must not be confused. That is why nothing below asserts on + * emptiness to establish which happened: an assertion on "is the result empty" + * passes identically for a refusal and for a served zero, so it can never fail + * for the condition it is supposed to be about. The discriminator is the + * `onMetadataReadWarning` channel, read from the ERROR's ADR-0112 `code` and + * status. + * + * This is framework #13906 decision 1 option A — *a thing that could not be + * READ is not a thing that is ABSENT* — applied at this seam. The empty list + * itself is UNCHANGED on every arm, including the loud ones: the published + * `Promise` contract (shipped since `@object-ui/data-objectstack@17.1.0`) + * does not move, and every assertion below re-checks that it did not. + */ + +/** An error body in the WRAPPED family (`{ success:false, error:{code,message} }`). */ +function wrappedError(code: string, message: string) { + return { success: false, error: { code, message } }; +} + +/** An error body in the FLAT family (`{ code, message }`). */ +function flatError(code: string, message: string) { + return { code, message }; +} + +/** + * A data source whose `GET /meta/mapping` answers with `answer` — a `Response` + * to serve, or a thrown value for a transport failure — with the read-warning + * channel already subscribed. + * + * Discovery is always served, so `connect()` succeeds and every reading below + * is about the mapping read itself and not about an adapter that never got off + * the ground. + */ +function makeFailingDS(answer: (() => Response) | (() => never)) { + const warnings: MetadataReadWarningEvent[] = []; + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/api/v1/discovery')) { + return json({ success: true, data: { capabilities: {}, routes: {} } }); + } + if (url.endsWith('/api/v1/meta/mapping')) return answer(); + return json({ success: false, error: { code: 'NOT_FOUND', message: `unexpected ${url}` } }, 404); + }); + const ds = new ObjectStackAdapter({ baseUrl: BASE_URL, fetch: fetchImpl, autoReconnect: false }); + const unsubscribe = ds.onMetadataReadWarning((ev) => warnings.push(ev)); + return { ds, warnings, unsubscribe, fetchImpl }; +} + +describe('listImportMappings — a refused door is not an empty list (objectui#7741)', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + clearSharedDiscoveryCache(); + // The breadcrumb is kept on every arm by design; silence it so a suite that + // exercises nine failures does not print nine stack traces. + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + describe('QUIET — a deployment that does not serve the `mapping` kind', () => { + it('says nothing when the metadata LIST door refuses the kind (framework#9488)', async () => { + // The modern spelling of "an older server without the `mapping` kind": + // `mapping` entered the platform's declared set only when the ADR-0088 + // admission test accepted it, so a server older than that promotion has + // it in neither the static contract nor its live type set. + const { ds, warnings } = makeFailingDS(() => + json( + wrappedError( + 'INVALID_REQUEST', + "'mapping' is not a metadata type. The platform declares no such type and this deployment has registered no items under it.", + ), + 400, + ), + ); + + expect(await ds.listImportMappings('task')).toEqual([]); + expect(warnings).toEqual([]); + }); + + it('says nothing when the `/meta` route is not mounted at all', async () => { + const { ds, warnings } = makeFailingDS(() => + json(wrappedError('ROUTE_NOT_FOUND', 'no route for GET /api/v1/meta/mapping'), 404), + ); + + expect(await ds.listImportMappings('task')).toEqual([]); + expect(warnings).toEqual([]); + }); + + it('says nothing for a bare, code-less transport 404 (a proxy, a gateway)', async () => { + // No ObjectStack route wrote this answer — this door's own 404s all ship + // a `code` — so the status is the best signal available and it means the + // API is not there. + const { ds, warnings } = makeFailingDS(() => json({ message: 'Not Found' }, 404)); + + expect(await ds.listImportMappings('task')).toEqual([]); + expect(warnings).toEqual([]); + }); + }); + + describe('LOUD — the server refused this caller', () => { + it('announces an anonymous or lapsed session, and STILL answers []', async () => { + const { ds, warnings } = makeFailingDS(() => + json(flatError('UNAUTHENTICATED', 'authentication required'), 401), + ); + + // The return is unchanged — this is a channel ALONGSIDE it, not a + // replacement for it. A consumer that subscribes to nothing sees exactly + // what it saw before this card. + expect(await ds.listImportMappings('task')).toEqual([]); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ + operation: 'listImportMappings', + kind: 'mapping', + objectName: 'task', + reason: 'refused', + code: 'UNAUTHENTICATED', + status: 401, + }); + // The server's own words travel, so the user has something to paste into + // a report and the message does not have to be guessed at. + expect(warnings[0].message).toBe('authentication required'); + }); + + it('announces a permission denial', async () => { + const { ds, warnings } = makeFailingDS(() => + json(wrappedError('PERMISSION_DENIED', 'manage_metadata required'), 403), + ); + + expect(await ds.listImportMappings('task')).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0].reason).toBe('refused'); + expect(warnings[0].status).toBe(403); + }); + }); + + describe('LOUD — the read could not be completed', () => { + it('announces a server error', async () => { + const { ds, warnings } = makeFailingDS(() => + json(wrappedError('INTERNAL_ERROR', 'metadata store unavailable'), 500), + ); + + expect(await ds.listImportMappings('task')).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0].reason).toBe('unreadable'); + expect(warnings[0].status).toBe(500); + }); + + it('announces a transport failure that carries no status at all', async () => { + const { ds, warnings } = makeFailingDS(() => { + throw new TypeError('Failed to fetch'); + }); + + expect(await ds.listImportMappings('task')).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ reason: 'unreadable', objectName: 'task' }); + // Nothing is invented for a failure that declared nothing. + expect(warnings[0].status).toBeUndefined(); + expect(warnings[0].code).toBeUndefined(); + }); + }); + + describe('the discrimination itself', () => { + it('⭐ served-zero and refused return the SAME value and differ only on the channel', async () => { + // Served zero: the door answered, with an empty collection. + const served = makeFailingDS(() => json({ type: 'mapping', items: [] })); + const servedResult = await served.ds.listImportMappings('task'); + + clearSharedDiscoveryCache(); + + // Refused: the door never answered at all. + const refused = makeFailingDS(() => + json(flatError('UNAUTHENTICATED', 'authentication required'), 401), + ); + const refusedResult = await refused.ds.listImportMappings('task'); + + // This is the whole defect in one line: the two conditions are byte-equal + // on the return, so no consumer reading only the return can ever separate + // them — and the wizard reads only the return. + expect(refusedResult).toEqual(servedResult); + expect(refusedResult).toEqual([]); + + // And this is the fix: they are NOT equal on the channel. + expect(served.warnings).toEqual([]); + expect(refused.warnings).toHaveLength(1); + expect(refused.warnings[0].reason).toBe('refused'); + }); + + it('a served, matching mapping is still served — the loud arm did not cost the happy path', async () => { + const { ds, warnings } = makeFailingDS(() => json(WIRE_BODY)); + + const mappings = await ds.listImportMappings('task'); + + expect(mappings.map((m: any) => m.name)).toEqual(['task_feed_import']); + expect(warnings).toEqual([]); + }); + }); + + describe('the channel itself', () => { + it('unsubscribes', async () => { + const { ds, warnings, unsubscribe } = makeFailingDS(() => + json(flatError('UNAUTHENTICATED', 'authentication required'), 401), + ); + + // Control: the channel is live first, so the silence below is about the + // unsubscribe and not about a chain that never ran. + await ds.listImportMappings('task'); + expect(warnings).toHaveLength(1); + + unsubscribe(); + await ds.listImportMappings('task'); + expect(warnings).toHaveLength(1); + }); + + it('a throwing listener neither breaks the caller nor starves the others', async () => { + const { ds, warnings } = makeFailingDS(() => + json(flatError('UNAUTHENTICATED', 'authentication required'), 401), + ); + ds.onMetadataReadWarning(() => { + throw new Error('listener exploded'); + }); + const later: MetadataReadWarningEvent[] = []; + ds.onMetadataReadWarning((ev) => later.push(ev)); + + await expect(ds.listImportMappings('task')).resolves.toEqual([]); + + expect(warnings).toHaveLength(1); + expect(later).toHaveLength(1); + }); + }); +}); + +describe('classifyImportMappingsFailure (objectui#7741)', () => { + it('reads the ADR-0112 code, not the status, when the two disagree', () => { + // A 404 that carries the route-absent code is the door being missing; a 404 + // that carries a refusal code is not. Branching on the status alone cannot + // express that difference, which is the objectui#5663 failure this ladder + // is built to avoid. + expect(classifyImportMappingsFailure({ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }).kind) + .toBe('not-served'); + expect(classifyImportMappingsFailure({ code: 'PERMISSION_DENIED', httpStatus: 404 }).kind) + .toBe('refused'); + }); + + it('matches the pre-ADR-0112 lowercase spelling too', () => { + // The console is versioned separately from the server, so at any moment it + // may be pointed at a build from either side of the vocabulary rename. + expect(classifyImportMappingsFailure({ code: 'route_not_found', httpStatus: 404 }).kind) + .toBe('not-served'); + expect(classifyImportMappingsFailure({ code: 'unauthenticated', httpStatus: 401 }).kind) + .toBe('refused'); + }); + + it('requires the 400 as well as the code before reading INVALID_REQUEST as kind-absent', () => { + expect( + classifyImportMappingsFailure({ code: 'INVALID_REQUEST', httpStatus: 400 }).kind, + ).toBe('not-served'); + // Same code, a status this door does not write it on: not the framework#9488 + // refusal, so not a licence to stay quiet. + expect( + classifyImportMappingsFailure({ code: 'INVALID_REQUEST', httpStatus: 500 }).kind, + ).toBe('unreadable'); + }); + + it('reads the status only when NO code was declared', () => { + expect(classifyImportMappingsFailure({ httpStatus: 501 }).kind).toBe('not-served'); + expect(classifyImportMappingsFailure({ httpStatus: 404 }).kind).toBe('not-served'); + // An empty-string code is "the producer declared nothing", not a code — it + // must not block the residual it fails to match. + expect(classifyImportMappingsFailure({ code: '', httpStatus: 404 }).kind).toBe('not-served'); + // A coded 5xx has no absence claim to make. + expect(classifyImportMappingsFailure({ httpStatus: 503 }).kind).toBe('unreadable'); + }); + + it('accepts every spelling of the status the transports use', () => { + expect(classifyImportMappingsFailure({ status: 401 }).kind).toBe('refused'); + expect(classifyImportMappingsFailure({ statusCode: 403 }).kind).toBe('refused'); + expect(classifyImportMappingsFailure({ httpStatus: 405 }).kind).toBe('refused'); + }); + + it('an error carrying nothing at all is unreadable, never absent', () => { + // The direction matters: inventing "this deployment has no mapping kind" + // from an answer that declared nothing is the exact over-claim this card + // exists to delete. + expect(classifyImportMappingsFailure(new Error('boom')).kind).toBe('unreadable'); + expect(classifyImportMappingsFailure(undefined).kind).toBe('unreadable'); + expect(classifyImportMappingsFailure(null).kind).toBe('unreadable'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 70a769597d..b7277b2c25 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1454,6 +1454,9 @@ const ar = { console: { saveAdvisoryTitle: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", publishAdvisoryTitle: "تم النشر — أنتج فحص التأليف {{count}} ملاحظة إرشادية", + importMappingsUnavailable: "تعذّر تحميل تعيينات الاستيراد المحفوظة لـ {{object}}", + importMappingsRefused: "رفض الخادم هذا الطلب، لذلك فإن هذه القائمة فارغة لأنه تعذّرت قراءتها، لا لأنه لا يوجد شيء مسجَّل. سجّل الدخول مرة أخرى أو اطلب صلاحية الوصول من المسؤول.", + importMappingsUnreadable: "هذه القائمة فارغة لأنه تعذّرت قراءتها، لا لأنه لا يوجد شيء مسجَّل. أعد المحاولة، وأبلغ عن المشكلة إذا استمرت.", settingsHub: { title: "الإعدادات", subtitle: "اضبط مساحة العمل والتكاملات وأعلام الميزات.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cc9e0fae3f..3230319f80 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1447,6 +1447,9 @@ const de = { console: { saveAdvisoryTitle: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweis(e)", publishAdvisoryTitle: "Veröffentlicht — die Autorenprüfung ergab {{count}} Hinweis(e)", + importMappingsUnavailable: "Gespeicherte Importzuordnungen für {{object}} konnten nicht geladen werden", + importMappingsRefused: "Der Server hat diese Anfrage abgelehnt. Die Liste ist also leer, weil sie nicht gelesen werden konnte — nicht, weil nichts registriert ist. Melden Sie sich erneut an oder bitten Sie eine Administratorin oder einen Administrator um Zugriff.", + importMappingsUnreadable: "Diese Liste ist leer, weil sie nicht gelesen werden konnte, nicht weil nichts registriert ist. Versuchen Sie es erneut und melden Sie das Problem, wenn es weiterhin auftritt.", settingsHub: { title: "Einstellungen", subtitle: "Konfigurieren Sie Ihren Workspace, Integrationen und Feature-Flags.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 165f8ce996..08dc424797 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1652,6 +1652,9 @@ const en = { console: { saveAdvisoryTitle: 'Saved — the authoring check raised {{count}} advisory finding(s)', publishAdvisoryTitle: 'Published — the authoring check raised {{count}} advisory finding(s)', + importMappingsUnavailable: 'Saved import mappings for {{object}} could not be loaded', + importMappingsRefused: 'The server refused this request, so this list is empty because it could not be read — not because nothing is registered. Sign in again, or ask an administrator for access.', + importMappingsUnreadable: 'This list is empty because it could not be read, not because nothing is registered. Try again, and report this if it keeps happening.', title: 'ObjectOS', initializing: 'Initializing application…', search: 'Search…', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 9f53385868..84d4550dff 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1451,6 +1451,9 @@ const es = { console: { saveAdvisoryTitle: "Guardado: la comprobación de creación generó {{count}} recomendación(es)", publishAdvisoryTitle: "Publicado: la comprobación de creación generó {{count}} recomendación(es)", + importMappingsUnavailable: "No se pudieron cargar las asignaciones de importación guardadas de {{object}}", + importMappingsRefused: "El servidor rechazó esta solicitud, por lo que la lista está vacía porque no se pudo leer, no porque no haya nada registrado. Vuelve a iniciar sesión o pide acceso a un administrador.", + importMappingsUnreadable: "Esta lista está vacía porque no se pudo leer, no porque no haya nada registrado. Inténtalo de nuevo e informa del problema si continúa.", settingsHub: { title: "Configuración", subtitle: "Configure su espacio de trabajo, las integraciones y los indicadores de funciones.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f9ebeab6a0..448d8a93cd 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1449,6 +1449,9 @@ const fr = { console: { saveAdvisoryTitle: "Enregistré — le contrôle de création a signalé {{count}} recommandation(s)", publishAdvisoryTitle: "Publié — le contrôle de création a signalé {{count}} recommandation(s)", + importMappingsUnavailable: "Impossible de charger les mappages d’import enregistrés pour {{object}}", + importMappingsRefused: "Le serveur a refusé cette requête : cette liste est donc vide parce qu’elle n’a pas pu être lue, et non parce que rien n’est enregistré. Reconnectez-vous ou demandez un accès à un administrateur.", + importMappingsUnreadable: "Cette liste est vide parce qu’elle n’a pas pu être lue, et non parce que rien n’est enregistré. Réessayez, et signalez le problème s’il persiste.", settingsHub: { title: "Paramètres", subtitle: "Configurez votre espace de travail, vos intégrations et vos indicateurs de fonctionnalité.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 1ec0420a9b..a479941e51 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1447,6 +1447,9 @@ const ja = { console: { saveAdvisoryTitle: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", publishAdvisoryTitle: "公開しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", + importMappingsUnavailable: "{{object}} の保存済みインポートマッピングを読み込めませんでした", + importMappingsRefused: "サーバーがこのリクエストを拒否しました。つまりこのリストが空なのは読み取れなかったためであり、何も登録されていないためではありません。再度サインインするか、管理者にアクセス権を依頼してください。", + importMappingsUnreadable: "このリストが空なのは読み取れなかったためであり、何も登録されていないためではありません。再試行し、繰り返し発生する場合は報告してください。", settingsHub: { title: "設定", subtitle: "ワークスペース、連携、機能フラグを設定します。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 9afcaeea42..55a895af80 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1447,6 +1447,9 @@ const ko = { console: { saveAdvisoryTitle: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", publishAdvisoryTitle: "게시되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", + importMappingsUnavailable: "{{object}}의 저장된 가져오기 매핑을 불러오지 못했습니다", + importMappingsRefused: "서버가 이 요청을 거부했습니다. 따라서 이 목록이 비어 있는 것은 읽지 못했기 때문이며, 등록된 항목이 없어서가 아닙니다. 다시 로그인하거나 관리자에게 접근 권한을 요청하세요.", + importMappingsUnreadable: "이 목록이 비어 있는 것은 읽지 못했기 때문이며, 등록된 항목이 없어서가 아닙니다. 다시 시도하고, 계속 발생하면 문제를 보고하세요.", settingsHub: { title: "설정", subtitle: "워크스페이스, 연동, 기능 플래그를 구성합니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 84d2c600cf..7f35500c05 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1446,6 +1446,9 @@ const pt = { console: { saveAdvisoryTitle: "Salvo — a verificação de criação gerou {{count}} recomendação(ões)", publishAdvisoryTitle: "Publicado — a verificação de criação gerou {{count}} recomendação(ões)", + importMappingsUnavailable: "Não foi possível carregar os mapeamentos de importação salvos de {{object}}", + importMappingsRefused: "O servidor recusou esta solicitação, portanto esta lista está vazia porque não pôde ser lida, não porque nada esteja registrado. Entre novamente ou peça acesso a um administrador.", + importMappingsUnreadable: "Esta lista está vazia porque não pôde ser lida, não porque nada esteja registrado. Tente novamente e relate o problema se ele persistir.", settingsHub: { title: "Configurações", subtitle: "Configure seu workspace, integrações e sinalizadores de recursos.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index dacdb403b6..cba38e4fd4 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1457,6 +1457,9 @@ const ru = { console: { saveAdvisoryTitle: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", publishAdvisoryTitle: "Опубликовано — проверка авторинга выдала рекомендаций: {{count}}", + importMappingsUnavailable: "Не удалось загрузить сохранённые сопоставления импорта для {{object}}", + importMappingsRefused: "Сервер отклонил этот запрос, поэтому список пуст из-за того, что его не удалось прочитать, а не потому, что ничего не зарегистрировано. Войдите заново или запросите доступ у администратора.", + importMappingsUnreadable: "Список пуст из-за того, что его не удалось прочитать, а не потому, что ничего не зарегистрировано. Повторите попытку и сообщите о проблеме, если она повторяется.", settingsHub: { title: "Настройки", subtitle: "Настройте рабочее пространство, интеграции и флаги функций.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9fbfd21dba..7c9eb4f40c 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1512,6 +1512,9 @@ const zh = { console: { saveAdvisoryTitle: '已保存 — 编辑检查提出了 {{count}} 条建议', publishAdvisoryTitle: '已发布 — 编辑检查提出了 {{count}} 条建议', + importMappingsUnavailable: "无法加载 {{object}} 的已保存导入映射", + importMappingsRefused: "服务器拒绝了此请求,因此该列表为空是因为读取失败,而不是因为没有注册任何映射。请重新登录,或联系管理员申请访问权限。", + importMappingsUnreadable: "该列表为空是因为读取失败,而不是因为没有注册任何映射。请重试;如果反复出现,请反馈此问题。", title: 'ObjectStack 控制台', initializing: '正在初始化应用程序…', search: '搜索…',