From abb29d4b64a2c47c99a8b55dd3ec31b6c075ec0a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:25:03 +0000 Subject: [PATCH] feat(studio): render the runtime authoring gate's advisory findings after a save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's advisories ride a 200 — the save succeeded, the row persisted — and objectui discarded them client-side: `MetadataClient.save` parsed the response body, returned it as an opaque `T`, and every call site awaited it for its side effect and dropped the value. objectstack#7435 put them on the wire; this renders them. `MetadataClient` gains an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`. The console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from — so a single wiring covers ResourceEditPage, StudioDesignSurface, EmbeddedItemEditor, DatasourceResourcePage and ObjectHooksPanel rather than a toast copied into twenty call sites. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it shares a declaration with. The affordance is the warning tier and says "Saved" first: a successful save that reads as a failure is the defect this surface must not ship. `message` and `hint` are server prose and render verbatim; only the frame is translated. Coverage is stated honestly and pinned: drafts are never gated (the framework returns at its D1 early-return before running a rule), so Studio's designer — which saves as draft on every edit — surfaces nothing today, and the publish door returns no advisories until objectstack#7294. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../render-save-advisory-findings-4133.md | 17 ++ .../src/providers/saveAdvisoryToast.test.ts | 162 +++++++++++++ .../src/providers/saveAdvisoryToast.ts | 105 ++++++++ .../metadata-admin/metadataClientFactory.ts | 14 +- .../src/views/metadata-admin/useMetadata.ts | 34 ++- packages/data-objectstack/src/index.ts | 5 +- .../metadata-client.saveAdvisories.test.ts | 226 ++++++++++++++++++ .../data-objectstack/src/metadata-client.ts | 129 +++++++++- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + 18 files changed, 694 insertions(+), 8 deletions(-) create mode 100644 .changeset/render-save-advisory-findings-4133.md create mode 100644 packages/app-shell/src/providers/saveAdvisoryToast.test.ts create mode 100644 packages/app-shell/src/providers/saveAdvisoryToast.ts create mode 100644 packages/data-objectstack/src/metadata-client.saveAdvisories.test.ts diff --git a/.changeset/render-save-advisory-findings-4133.md b/.changeset/render-save-advisory-findings-4133.md new file mode 100644 index 0000000000..348ef9eaae --- /dev/null +++ b/.changeset/render-save-advisory-findings-4133.md @@ -0,0 +1,17 @@ +--- +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side + +The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value. + +The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one. + +`MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with. + +The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error. + +**What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active *does* run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover. diff --git a/packages/app-shell/src/providers/saveAdvisoryToast.test.ts b/packages/app-shell/src/providers/saveAdvisoryToast.test.ts new file mode 100644 index 0000000000..a07c2c6369 --- /dev/null +++ b/packages/app-shell/src/providers/saveAdvisoryToast.test.ts @@ -0,0 +1,162 @@ +/** + * 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 post-save advisory affordance (objectui#4133). + * + * The save SUCCEEDED, so the whole point of these pins is the tier: the + * findings land on the WARNING channel and never the error one. A successful + * save that reads as a failure is the specific defect this surface must not + * ship — the card says so, and the sibling write-warning toast + * (`writeWarningToast.ts`) learned the same lesson at objectui#3484 point B. + * + * The sink is handed over rather than mocked, exactly as its sibling does, so + * nothing here depends on vitest project isolation. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { MetadataSaveAdvisoryEvent } from '@object-ui/data-objectstack'; +import { emitSaveAdvisories, type TranslateFn } from './saveAdvisoryToast'; + +/** i18next-shaped `t` that renders the inline default with its holes filled. */ +const t: TranslateFn = (key, options) => { + const raw = (options?.defaultValue as string) ?? key; + let out = raw; + for (const [k, v] of Object.entries(options ?? {})) { + if (k === 'defaultValue') continue; + out = out.split(`{{${k}}}`).join(String(v)); + } + return out; +}; + +function makeSink() { + return { + warning: vi.fn<(title: string, opts?: { description?: string; duration?: number }) => void>(), + // Present so a mistaken `sink.error(...)` would be observable rather than + // a TypeError — the assertion below is that it is never reached. + error: vi.fn(), + success: vi.fn(), + }; +} + +const FINDING = { + severity: 'warning' as const, + rule: 'flow/delete-without-filter', + where: 'flow "nightly_purge" · node "purge old rows"', + path: 'flows[0].nodes[2].config.filters', + message: 'this delete_record node sets multi: true with no filter, so it deletes every row', + hint: 'add a filter, or set multi: false to delete a single record', +}; + +function event(overrides: Partial = {}): MetadataSaveAdvisoryEvent { + return { + type: 'flow', + name: 'nightly_purge', + mode: 'publish', + advisories: [FINDING], + ...overrides, + }; +} + +describe('emitSaveAdvisories (#4133)', () => { + it('renders the findings on the WARNING channel, never the error one', () => { + const sink = makeSink(); + + emitSaveAdvisories(event(), t, sink); + + expect(sink.warning).toHaveBeenCalledTimes(1); + expect(sink.error).not.toHaveBeenCalled(); + }); + + it('acknowledges the save in the title — it succeeded', () => { + const sink = makeSink(); + + emitSaveAdvisories(event(), t, sink); + + const [title] = sink.warning.mock.calls[0]!; + expect(title).toMatch(/^Saved/); + expect(title).toContain('1'); + }); + + it('lists rule, message and hint for each finding', () => { + const sink = makeSink(); + + emitSaveAdvisories(event(), t, sink); + + const description = sink.warning.mock.calls[0]![1]!.description!; + expect(description).toContain('flow/delete-without-filter'); + expect(description).toContain(FINDING.message); + expect(description).toContain(FINDING.hint); + }); + + it('renders `where` as secondary context and omits the machine `path`', () => { + const sink = makeSink(); + + emitSaveAdvisories(event(), t, sink); + + const description = sink.warning.mock.calls[0]![1]!.description!; + expect(description).toContain('flow "nightly_purge" · node "purge old rows"'); + expect(description).not.toContain('flows[0].nodes[2].config.filters'); + }); + + it('renders server prose verbatim — message and hint are not translated', () => { + const sink = makeSink(); + // A `t` that mangles everything it is given. The finding text must survive + // it untouched, because it is server data and not an i18n key. + const shoutingT: TranslateFn = () => 'TRANSLATED-TITLE'; + + emitSaveAdvisories(event(), shoutingT, sink); + + const [title, opts] = sink.warning.mock.calls[0]!; + expect(title).toBe('TRANSLATED-TITLE'); + expect(opts!.description).toContain(FINDING.message); + expect(opts!.description).toContain(FINDING.hint); + }); + + it('lists every finding when the gate returned several', () => { + const sink = makeSink(); + const second = { ...FINDING, rule: 'flow/unreachable-node', message: 'node 4 is unreachable' }; + + emitSaveAdvisories(event({ advisories: [FINDING, second] }), t, sink); + + const [title, opts] = sink.warning.mock.calls[0]!; + expect(title).toContain('2'); + expect(opts!.description).toContain('flow/delete-without-filter'); + expect(opts!.description).toContain('flow/unreachable-node'); + expect(opts!.description!.split('\n')).toHaveLength(2); + }); + + it('stays on screen long enough to be read', () => { + const sink = makeSink(); + + emitSaveAdvisories(event(), t, sink); + + expect(sink.warning.mock.calls[0]![1]!.duration).toBeGreaterThanOrEqual(10_000); + }); + + it('says nothing when there is nothing to say — a clean save renders no new UI', () => { + const sink = makeSink(); + + emitSaveAdvisories(event({ advisories: [] }), t, sink); + + expect(sink.warning).not.toHaveBeenCalled(); + expect(sink.error).not.toHaveBeenCalled(); + expect(sink.success).not.toHaveBeenCalled(); + }); + + it('tolerates a finding with no `where`', () => { + const sink = makeSink(); + const bare = { ...FINDING, where: '' }; + + emitSaveAdvisories(event({ advisories: [bare] }), t, sink); + + const description = sink.warning.mock.calls[0]![1]!.description!; + expect(description).toContain('[flow/delete-without-filter]'); + expect(description).toContain(FINDING.message); + }); +}); diff --git a/packages/app-shell/src/providers/saveAdvisoryToast.ts b/packages/app-shell/src/providers/saveAdvisoryToast.ts new file mode 100644 index 0000000000..70c68d67ae --- /dev/null +++ b/packages/app-shell/src/providers/saveAdvisoryToast.ts @@ -0,0 +1,105 @@ +/** + * 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 the runtime authoring gate's advisory findings (objectui#4133, + * backend objectstack#7435) into the message the author reads. + * + * Lives apart from the client factory that wires it — and, deliberately, + * imports NOTHING that renders — so the wording and the grouping can be + * exercised directly. Same split, and the same reason, as its sibling + * `writeWarningToast.ts`: the caller owns the sink, the test hands over its + * own, and no module mock is needed. + * + * ## Why the save is acknowledged, never failed + * + * These findings ride a **200**. The row persisted, the version bumped, and the + * author's change is live; the gate merely noticed something worth saying about + * it. So the affordance is the warning tier, and the title says "Saved" first — + * the mistake this module exists to avoid is a successful save that reads as a + * failure. `severity` on this channel is never `'error'` (an error-severity + * finding is a 422 and never reaches a 2xx body), which is why nothing here + * branches on it. + * + * ## Why the finding text is not translated + * + * `message` and `hint` are SERVER data — composed by the gate's rules, which + * name the offending node, object and field. They are rendered verbatim. Only + * the frame around them (the title, and the "rule — message" scaffolding) is + * i18n copy. Putting server prose through `t()` would need a key per rule and + * would go stale the moment a rule's wording changed upstream. + * + * @module providers/saveAdvisoryToast + */ + +import type { MetadataSaveAdvisoryEvent } from '@object-ui/data-objectstack'; + +/** i18next's `t`, narrowed to what this module uses. */ +export type TranslateFn = (key: string, options?: Record) => string; + +/** + * Where the message goes. Structurally satisfied by sonner's `toast`, which is + * what the console metadata client factory passes. + * + * Required rather than defaulted to `sonner` for the same reason the write + * warning sink is: a default would mean importing the toaster here, which is + * precisely the dependency that has to stay out of this module. + */ +export interface SaveAdvisorySink { + warning(title: string, options?: { description?: string; duration?: number }): void; +} + +/** + * How long the advisory stays on screen. Longer than the 4s default because + * the body is multi-line prose the author has to actually read — the same + * 10s the pre-publish capability lint uses for its findings toast + * (`preview/usePublishAllDrafts.ts`), so the two advisory surfaces behave + * alike. + */ +const ADVISORY_TOAST_MS = 10_000; + +/** + * Render one finding as a line: the rule id that produced it, its message, and + * its remedy. `where` is appended as secondary context when the gate supplied + * it — it names the flow/node the finding is about, which is what the author + * needs to go fix it. `path` is deliberately NOT rendered: it is a document + * pointer (`flows[0].nodes[2].config…`) meant for tooling, and next to a + * human-readable `where` it reads as noise. + */ +function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): string { + const head = f.where ? `[${f.rule}] ${f.where}` : `[${f.rule}]`; + const hint = f.hint ? ` ${f.hint}` : ''; + return `${head} — ${f.message}${hint}`; +} + +/** + * Announce the gate's advisory findings for a save that SUCCEEDED. + * + * Says nothing when there is nothing to say: the server omits `advisories` + * entirely on a clean save, so the common case never reaches here, and an event + * that somehow carried an empty list is dropped rather than toasted as + * "0 findings". + */ +export function emitSaveAdvisories( + ev: MetadataSaveAdvisoryEvent, + t: TranslateFn, + sink: SaveAdvisorySink, +): void { + if (!ev.advisories || ev.advisories.length === 0) return; + + sink.warning( + t('console.saveAdvisoryTitle', { + count: ev.advisories.length, + defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)', + }), + { + description: ev.advisories.map(formatFinding).join('\n'), + duration: ADVISORY_TOAST_MS, + }, + ); +} diff --git a/packages/app-shell/src/views/metadata-admin/metadataClientFactory.ts b/packages/app-shell/src/views/metadata-admin/metadataClientFactory.ts index 57fc31098a..e0f04f9a4c 100644 --- a/packages/app-shell/src/views/metadata-admin/metadataClientFactory.ts +++ b/packages/app-shell/src/views/metadata-admin/metadataClientFactory.ts @@ -28,7 +28,7 @@ * never touches `credentials`, so a same-origin session cookie still flows. */ -import { MetadataClient } from '@object-ui/data-objectstack'; +import { MetadataClient, type MetadataSaveAdvisoryListener } from '@object-ui/data-objectstack'; import { createAuthenticatedFetch } from '@object-ui/auth'; /** @@ -63,6 +63,15 @@ export interface ConsoleMetadataClientOptions { previewDrafts?: boolean; /** Scope reads/writes to a tenant environment (`withEnvironment`). */ environmentId?: string; + /** + * #4133 — sink for the runtime authoring gate's advisory findings on a + * SUCCESSFUL save (objectstack#7435). Passed straight through to + * {@link MetadataClient}; `useMetadataClient` supplies the console's toast + * renderer, which is what puts one wiring in front of every save call site + * in the app. Omitted for read-only clients (`MetadataProvider`), which + * never write and so can never receive one. + */ + onSaveAdvisory?: MetadataSaveAdvisoryListener; } /** @@ -72,11 +81,12 @@ export interface ConsoleMetadataClientOptions { export function createConsoleMetadataClient( options: ConsoleMetadataClientOptions = {}, ): MetadataClient { - const { previewDrafts = false, environmentId } = options; + const { previewDrafts = false, environmentId, onSaveAdvisory } = options; const client = new MetadataClient({ baseUrl: resolveMetadataBaseUrl(), previewDrafts, fetch: consoleApiFetch, + ...(onSaveAdvisory ? { onSaveAdvisory } : {}), }); return environmentId ? client.withEnvironment(environmentId) : client; } diff --git a/packages/app-shell/src/views/metadata-admin/useMetadata.ts b/packages/app-shell/src/views/metadata-admin/useMetadata.ts index c7e8d0d398..67eee3cbe2 100644 --- a/packages/app-shell/src/views/metadata-admin/useMetadata.ts +++ b/packages/app-shell/src/views/metadata-admin/useMetadata.ts @@ -15,10 +15,13 @@ * consumers. */ -import { useEffect, useMemo, useState } from 'react'; -import { type MetadataClient, type MetadataDiagnosticsSummary, type MetadataDiagnosticsEntry } from '@object-ui/data-objectstack'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type MetadataClient, type MetadataDiagnosticsSummary, type MetadataDiagnosticsEntry, type MetadataSaveAdvisoryEvent } from '@object-ui/data-objectstack'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { toast } from 'sonner'; import { usePreviewDrafts } from '../../preview/PreviewModeContext'; import { createConsoleMetadataClient } from './metadataClientFactory'; +import { emitSaveAdvisories, type TranslateFn } from '../../providers/saveAdvisoryToast'; /** * A declarative **type-level** action surfaced on a metadata type by the @@ -105,9 +108,32 @@ export function useMetadataClient(environmentId?: string): MetadataClient { // ADR-0037: inside a draft-preview tree (?preview=draft), reads overlay // pending drafts on the active registry. Writes are unaffected. const previewDrafts = usePreviewDrafts(); + + // #4133 — the runtime authoring gate's advisory findings, rendered ONCE for + // every save call site in the app. + // + // This hook is why the wiring belongs here rather than at the ~20 places + // that call `client.save(...)`: every app-shell write path takes its client + // from `useMetadataClient` (ResourceEditPage, StudioDesignSurface, + // EmbeddedItemEditor, DatasourceResourcePage, ObjectHooksPanel, …), so one + // sink here covers all of them and any future one for free. The factory + // below stays render-free; this is the layer that has React context, which + // is what the translation needs. + // + // `t` is read through a ref so a locale change does not re-mint the client + // (which would remount every consumer) while the toast still renders in the + // CURRENT language — the same shape `AdapterProvider` uses for its sibling + // write-warning channel. + const { t } = useObjectTranslation(); + const tRef = useRef(t); + tRef.current = t; + const onSaveAdvisory = useCallback((ev: MetadataSaveAdvisoryEvent) => { + emitSaveAdvisories(ev, tRef.current as TranslateFn, toast); + }, []); + return useMemo( - () => createConsoleMetadataClient({ previewDrafts, environmentId }), - [environmentId, previewDrafts], + () => createConsoleMetadataClient({ previewDrafts, environmentId, onSaveAdvisory }), + [environmentId, previewDrafts, onSaveAdvisory], ); } diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index fee753b2df..e2482d6596 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -3906,8 +3906,11 @@ export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackI // Used by plugin-designer to back the Setup-app Object Manager and Field // Designer surfaces; kept separate from ObjectStackAdapter so callers // can use it without the full data-source surface. -export { MetadataClient } from './metadata-client'; +export { MetadataClient, readSaveAdvisories } from './metadata-client'; export type { + RuntimeAuthoringIssue, + MetadataSaveAdvisoryEvent, + MetadataSaveAdvisoryListener, MetadataClientConfig, MetadataListOptions, MetadataDraftHeader, diff --git a/packages/data-objectstack/src/metadata-client.saveAdvisories.test.ts b/packages/data-objectstack/src/metadata-client.saveAdvisories.test.ts new file mode 100644 index 0000000000..c861d4204f --- /dev/null +++ b/packages/data-objectstack/src/metadata-client.saveAdvisories.test.ts @@ -0,0 +1,226 @@ +/** + * 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. + */ + +/** + * `MetadataClient.save` must surface the runtime authoring gate's advisory + * findings (objectui#4133; backend objectstack#7435, landed as `89d7b35a7`). + * + * The save SUCCEEDED — a 200, the row persisted, the version bumped. The gate + * simply noticed something worth telling the author, and before this the + * findings were parsed off the wire and dropped on the floor client-side. These + * pins are the red-first evidence: with the emit removed from `save()`, the + * "renders the findings" cases below fail because no event ever arrives. + * + * The measured example the card is built on: a `nightly_purge` flow whose only + * defect is a `delete_record` node with `multi: true` and no filter yields + * `errors = 0 / advisories = 1` — a 200 the author currently learns nothing from. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + MetadataClient, + readSaveAdvisories, + type MetadataSaveAdvisoryEvent, + type RuntimeAuthoringIssue, +} from './metadata-client'; + +/** The measured `nightly_purge` finding, in the spec's D3 shape. */ +const PURGE_ADVISORY: RuntimeAuthoringIssue = { + severity: 'warning', + rule: 'flow/delete-without-filter', + where: 'flow "nightly_purge" · node "purge old rows"', + path: 'flows[0].nodes[2].config.filters', + message: 'this delete_record node sets multi: true with no filter, so it deletes every row', + hint: 'add a filter, or set multi: false to delete a single record', +}; + +function saveResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function clientWith( + responseBody: unknown, + onSaveAdvisory?: (ev: MetadataSaveAdvisoryEvent) => void, +) { + return new MetadataClient({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => saveResponse(responseBody)) as unknown as typeof fetch, + ...(onSaveAdvisory ? { onSaveAdvisory } : {}), + }); +} + +const CLEAN_BODY = { success: true, version: 'v2', seq: 4, state: 'active' }; + +describe('MetadataClient.save — runtime authoring gate advisories (#4133)', () => { + it('emits the findings a successful save returned', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const client = clientWith( + { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }, + (e) => events.push(e), + ); + + await client.save('flow', 'nightly_purge', { name: 'nightly_purge' }); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'flow', + name: 'nightly_purge', + mode: 'publish', + advisories: [PURGE_ADVISORY], + }); + }); + + it('carries rule, message and hint through verbatim — they are server prose', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const client = clientWith( + { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }, + (e) => events.push(e), + ); + + await client.save('flow', 'nightly_purge', {}); + + const [finding] = events[0]!.advisories; + expect(finding!.rule).toBe('flow/delete-without-filter'); + expect(finding!.message).toBe(PURGE_ADVISORY.message); + expect(finding!.hint).toBe(PURGE_ADVISORY.hint); + // Never `error` on this channel — an error-severity finding is a 422. + expect(finding!.severity).toBe('warning'); + }); + + it('says nothing on a clean save — the server omits the key entirely', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const client = clientWith(CLEAN_BODY, (e) => events.push(e)); + + await client.save('object', 'account', { name: 'account' }); + + expect(events).toEqual([]); + }); + + it('says nothing when the array is present but empty', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const client = clientWith({ ...CLEAN_BODY, advisories: [] }, (e) => events.push(e)); + + await client.save('object', 'account', {}); + + expect(events).toEqual([]); + }); + + it('still returns the save response unchanged', async () => { + const body = { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }; + const client = clientWith(body, () => {}); + + const result = await client.save('flow', 'nightly_purge', {}); + + expect(result).toEqual(body); + }); + + it('a throwing sink never fails a save the server already committed', async () => { + const client = clientWith({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }, () => { + throw new Error('renderer exploded'); + }); + + await expect(client.save('flow', 'nightly_purge', {})).resolves.toBeTruthy(); + }); + + /** + * The honest control for the coverage statement (#4133 requirement 3). + * + * A `mode: 'draft'` save is a PUT through the same save door, but the + * framework's gate returns at its D1 early-return before running a single + * rule — `runtime-authoring-gate.ts`: + * + * // D1 — drafts are never gated. Publishing one runs this same function. + * if (args.state !== 'active') return null; + * + * so the server produces no findings and the response carries no key. The + * client is not what suppresses this; there is nothing to suppress. Pinned + * because Studio's designer saves as draft on EVERY edit, which is why the + * affordance renders nothing on that flow today. + */ + it('a draft save carries no findings — the gate never ran (D1)', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const client = clientWith({ ...CLEAN_BODY, state: 'draft' }, (e) => events.push(e)); + + await client.save('flow', 'nightly_purge', {}, { mode: 'draft' }); + + expect(events).toEqual([]); + }); + + it('labels the mode when a draft save does somehow advise', async () => { + // Not reachable through today's server (see D1 above), but the event's + // `mode` must tell the truth about which door it came through rather than + // hard-coding one, so the field is pinned on its own. + const events: MetadataSaveAdvisoryEvent[] = []; + const client = clientWith( + { ...CLEAN_BODY, state: 'draft', advisories: [PURGE_ADVISORY] }, + (e) => events.push(e), + ); + + await client.save('flow', 'nightly_purge', {}, { mode: 'draft' }); + + expect(events[0]!.mode).toBe('draft'); + }); + + it('survives the withEnvironment clone — console clients are all env-scoped', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const base = new MetadataClient({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + saveResponse({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }), + ) as unknown as typeof fetch, + onSaveAdvisory: (e) => events.push(e), + }); + + await base.withEnvironment('env_1').save('flow', 'nightly_purge', {}); + + expect(events).toHaveLength(1); + }); + + it('survives the withPreviewDrafts clone', async () => { + const events: MetadataSaveAdvisoryEvent[] = []; + const base = new MetadataClient({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + saveResponse({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }), + ) as unknown as typeof fetch, + onSaveAdvisory: (e) => events.push(e), + }); + + await base.withPreviewDrafts(true).save('flow', 'nightly_purge', {}); + + expect(events).toHaveLength(1); + }); +}); + +describe('readSaveAdvisories', () => { + it('reads a well-formed array', () => { + expect(readSaveAdvisories({ advisories: [PURGE_ADVISORY] })).toEqual([PURGE_ADVISORY]); + }); + + it('returns empty for a response with no advisories key', () => { + expect(readSaveAdvisories(CLEAN_BODY)).toEqual([]); + }); + + it.each([ + ['null body', null], + ['a string body', 'nope'], + ['a non-array advisories', { advisories: { rule: 'x' } }], + ])('returns empty for %s', (_label, body) => { + expect(readSaveAdvisories(body)).toEqual([]); + }); + + it('drops half-shaped findings rather than rendering blanks at the author', () => { + const out = readSaveAdvisories({ + advisories: [PURGE_ADVISORY, { rule: 'only-a-rule' }, null, 'string'], + }); + expect(out).toEqual([PURGE_ADVISORY]); + }); +}); diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index de80ce89b7..f359145e9c 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -32,6 +32,84 @@ * type, mirroring the framework's "single Zod source per type" rule. */ +import type { RuntimeAuthoringIssue } from '@objectstack/spec/api'; + +/** + * One advisory finding the framework's runtime authoring gate produced for a + * save that SUCCEEDED (objectstack#7435, landed as `89d7b35a7`). + * + * Re-exported from `@objectstack/spec` rather than restated: this is the D3 + * finding shape, declared once as `RuntimeAuthoringIssueSchema` and shared with + * the 422 `issues[]` so the two cannot drift apart. Hand-copying the six keys + * here is exactly the fork `check:spec-symbol-derivation` exists to reject, and + * it is the same discipline `DroppedFieldsEvent` already follows one module over. + * + * `severity` carries the spec's full `error | warning | info` union. On THIS + * channel it is never `'error'` — an error-severity finding is a 422 and never + * reaches a 2xx body — but the union is copied whole rather than narrowed, + * because a consumer-side re-spelling of a producer's enum is the drift the + * gate above is about. + */ +export type { RuntimeAuthoringIssue }; + +/** + * Emitted after a {@link MetadataClient.save} whose response carried a + * non-empty `advisories` array. The save SUCCEEDED — the row persisted and the + * server returned 200 — so this is advisory, never a failure. + * + * Deliberately the same shape of seam as `ObjectStackAdapter.onWriteWarning` + * (#3431/#3455): a successful write whose response carries something the author + * needs to be told, surfaced to the shell as an event so the data layer never + * imports a toaster. The difference is only which door produced it — that one + * is record CRUD, this one is the metadata save door. + */ +export interface MetadataSaveAdvisoryEvent { + /** Metadata type saved (e.g. `'flow'`). */ + type: string; + /** Item name saved. */ + name: string; + /** + * The save mode the call used. `'draft'` writes are **never gated** — the + * framework returns at `runtime-authoring-gate.ts`'s D1 early-return + * (`if (args.state !== 'active') return null`), so a draft save produces no + * findings at all and this event never fires for one. Carried anyway so a + * consumer reading the event can tell which door it came through. + */ + mode: 'draft' | 'publish'; + /** The findings. Never empty — the event is not emitted otherwise. */ + advisories: RuntimeAuthoringIssue[]; +} + +/** Event listener type for save-advisory events. */ +export type MetadataSaveAdvisoryListener = (event: MetadataSaveAdvisoryEvent) => void; + +/** + * Read the `advisories` array off a save response, defensively. + * + * The server omits the key entirely on a clean save, so `undefined` is the + * common case and means "nothing to say". Anything that is not an array of + * objects carrying the six required keys is dropped rather than rendered: a + * half-shaped finding would print blanks at the author, and this channel must + * never turn a successful save into noise. + */ +export function readSaveAdvisories(body: unknown): RuntimeAuthoringIssue[] { + if (!body || typeof body !== 'object') return []; + const raw = (body as { advisories?: unknown }).advisories; + if (!Array.isArray(raw)) return []; + return raw.filter((f): f is RuntimeAuthoringIssue => { + if (!f || typeof f !== 'object') return false; + const c = f as Record; + return ( + typeof c.rule === 'string' && + typeof c.path === 'string' && + typeof c.where === 'string' && + typeof c.message === 'string' && + typeof c.hint === 'string' && + typeof c.severity === 'string' + ); + }); +} + export interface MetadataClientConfig { /** Base URL of the ObjectStack server (no trailing slash needed). */ baseUrl: string; @@ -55,6 +133,22 @@ export interface MetadataClientConfig { * nothing the preview shows is live until Publish. */ previewDrafts?: boolean; + /** + * Called after a {@link MetadataClient.save} whose 2xx response carried a + * non-empty `advisories` array (objectstack#7435). The save already + * succeeded; this is how the shell learns there is something to tell the + * author instead of the findings being discarded client-side. + * + * Set on the CONFIG rather than exposed as a `subscribe()` method on purpose: + * console metadata clients are minted per-component by `useMetadataClient`, + * so there is no long-lived instance to subscribe to — but every one of them + * is built by the single `createConsoleMetadataClient` factory, which is + * where this gets wired exactly once for every save call site in the app. + * + * A throw from this callback is swallowed — an advisory channel must never + * be able to fail a save that the server already committed. + */ + onSaveAdvisory?: MetadataSaveAdvisoryListener; } export interface MetadataListOptions { @@ -384,12 +478,15 @@ export class MetadataClient { private readonly headers: Record; /** ADR-0037: when true, reads render the draft-overlaid world. */ readonly previewDrafts: boolean; + /** #4133 — sink for post-save advisory findings; see the config field. */ + private readonly onSaveAdvisory: MetadataSaveAdvisoryListener | undefined; constructor(config: MetadataClientConfig) { this.base = buildBase(config); this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis); this.headers = { Accept: 'application/json', ...(config.headers ?? {}) }; this.previewDrafts = config.previewDrafts === true; + this.onSaveAdvisory = config.onSaveAdvisory; } /** Update the client's environment scope at runtime. */ @@ -402,6 +499,11 @@ export class MetadataClient { fetch: this.fetchImpl, headers: this.headers, previewDrafts: this.previewDrafts, + // #4133: the advisory sink must survive the clone. `useMetadataClient` + // routes EVERY environment-scoped console client through here, so + // dropping it would silently disable the channel for exactly the + // multi-environment tenants the gate is loudest for. + ...(this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}), }); } @@ -423,6 +525,10 @@ export class MetadataClient { fetch: this.fetchImpl, headers: this.headers, previewDrafts, + // #4133: carried for the same reason as in `withEnvironment` — preview + // mode swaps the whole renderer tree's client, and writes are unaffected + // by ADR-0037, so the save door behind this clone still advises. + ...(this.onSaveAdvisory ? { onSaveAdvisory: this.onSaveAdvisory } : {}), }); } @@ -570,7 +676,28 @@ export class MetadataClient { body: JSON.stringify(item), }); if (!res.ok) throw await parseError(res); - return (await res.json()) as T; + const body = await res.json(); + // #4133 — the runtime authoring gate's advisory findings (objectstack#7435). + // The server emits `advisories` ONLY when non-empty, so a clean save costs + // nothing here. Everything below is best-effort by construction: the save + // has already been committed server-side and returning it must not depend + // on anything the advisory channel does. + if (this.onSaveAdvisory) { + try { + const advisories = readSaveAdvisories(body); + if (advisories.length > 0) { + this.onSaveAdvisory({ + type, + name, + mode: options.mode === 'draft' ? 'draft' : 'publish', + advisories, + }); + } + } catch { + /* an advisory must never turn a committed save into a thrown error */ + } + } + return body as T; } /** diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index d616c041a1..abc443e074 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1290,6 +1290,7 @@ const ar = { }, }, console: { + saveAdvisoryTitle: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", settingsHub: { title: "الإعدادات", subtitle: "اضبط مساحة العمل والتكاملات وأعلام الميزات.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5128ecf42a..6a36c6d1f4 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1286,6 +1286,7 @@ const de = { }, }, console: { + saveAdvisoryTitle: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweis(e)", 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 1641fe60ac..68fa26962d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1409,6 +1409,7 @@ const en = { }, }, console: { + saveAdvisoryTitle: 'Saved — the authoring check raised {{count}} advisory finding(s)', title: 'ObjectOS', initializing: 'Initializing application...', search: 'Search…', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 90ee4e7862..a6b8b617ca 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1290,6 +1290,7 @@ const es = { }, }, console: { + saveAdvisoryTitle: "Guardado: la comprobación de creación generó {{count}} recomendación(es)", 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 19e15852c6..b4ddaa2245 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1286,6 +1286,7 @@ const fr = { }, }, console: { + saveAdvisoryTitle: "Enregistré — le contrôle de création a signalé {{count}} recommandation(s)", 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 3cc3ed97a9..aa505b2b04 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1286,6 +1286,7 @@ const ja = { }, }, console: { + saveAdvisoryTitle: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", settingsHub: { title: "設定", subtitle: "ワークスペース、連携、機能フラグを設定します。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index a5b405a690..0086269286 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1286,6 +1286,7 @@ const ko = { }, }, console: { + saveAdvisoryTitle: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", settingsHub: { title: "설정", subtitle: "워크스페이스, 연동, 기능 플래그를 구성합니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 93a676dbaa..f73c7fbc7a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1285,6 +1285,7 @@ const pt = { }, }, console: { + saveAdvisoryTitle: "Salvo — a verificação de criação gerou {{count}} recomendação(ões)", 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 83d063c1de..9a70e9e01f 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1292,6 +1292,7 @@ const ru = { }, }, console: { + saveAdvisoryTitle: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", settingsHub: { title: "Настройки", subtitle: "Настройте рабочее пространство, интеграции и флаги функций.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 4ddf351e5e..2f7903bf38 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1348,6 +1348,7 @@ const zh = { }, }, console: { + saveAdvisoryTitle: '已保存 — 编辑检查提出了 {{count}} 条建议', title: 'ObjectStack 控制台', initializing: '正在初始化应用程序...', search: '搜索…',