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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/6965-batch-publish-advisories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': minor
---

Studio's "publish whole app" reports the runtime authoring gate's per-draft
advisories (objectui#6965; server half objectstack#9343).

`POST /packages/:id/publish-drafts` began answering `advisories` on each
`published[]` element when objectstack#9343 landed, but the author publishing a
whole app was still told nothing: both client call sites bypassed the data-layer
seam — a bare `fetch` in `usePublishAllDrafts` and the page-private `apiJson` in
`PackagesPage`, whose declared response type held two counts and `failed[]`, with
no `published[]` at all. The same button's own client-side capability lint was
raising a toast the whole time, so a finding from the server was the one thing
that could not reach the person pressing it.

- `MetadataClient.publishPackageDrafts(packageId)` expresses the route and emits
one advisory event per advised `published[]` element — each naming that
element's own `type` / `name` — into the sink, event and renderer the save and
single-item publish doors already use. Both call sites go through it.
- The batch door reports `door: 'publish'` rather than a third discriminator
value: every item the event names really was published, and the renderer's
only door-dependent output is that verb. The per-item identity the author
needs rides `type` / `name`, one event per item.
- It renders only what the server sent where `PublishPackageDraftsResponseSchema`
declares it. A half-shaped finding, an element that cannot name its item, and a
top-level `advisories` the ruled shape does not put there all report nothing —
pinned, alongside the presence, in `metadata-client.publishAdvisories.test.ts`,
whose absence pin this flips.
- `publishPackageDrafts` returns the batch body derived from the spec schema, so
a caller reading `failed[]` / `publishedCount` reads a declared shape. Non-2xx
raises the usual `MetadataError`; the 2xx batch verdict stays the caller's to
judge, because `success: false` is not a refusal on this route.
4 changes: 3 additions & 1 deletion .changeset/render-publish-advisory-findings-5026.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ One thing had to differ, and it is the frame's verb. Save and Publish are two di

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) discarded per-draft advisories server-side when this change was written — objectstack#9343, open and unruled at the time — and nothing here compensated for that from the client side. A test pinned the absence, so a later traversal of a batch-shaped `published[]` could not be added without turning it red.

*Corrected before release (objectui#6965): both present-tense claims in the paragraph above went false after it was written — objectstack#9343 landed, the batch response now carries per-draft advisories, and objectui#6965 routes that door through the same seam and flips the absence pin to a presence pin. The paragraph is kept in the past tense as the record of what this change did and did not do; this note is what the CHANGELOG publishes instead of a sentence that was true only while it sat here.*
30 changes: 21 additions & 9 deletions packages/app-shell/src/preview/usePublishAllDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
* L3 runtime probes; findings surface as a loud warning toast instead of a
* blind "Published!". Package-less drafts fall back to by-reference publish
* (structure first, seeds last) so they never dead-end.
*
* Both halves of that call now run through `MetadataClient` (objectui#6965):
* the batch one so the runtime authoring gate's per-draft advisories reach the
* console's advisory toast, the by-reference one because it always did. The
* asymmetry this closes was inside this very function — its own client-side
* capability lint raised a toast while the server's findings, on the same
* button, were dropped for want of a seam to report through.
*/

import { useCallback, useState } from 'react';
Expand Down Expand Up @@ -74,15 +81,20 @@ export function usePublishAllDrafts(t: TranslateFn) {
};

for (const packageId of packageIds) {
const res = await fetch(`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: '{}',
});
const payload = await res.json().catch(() => null);
if (!res.ok || (payload as any)?.success === false) {
throw new Error((payload as any)?.error?.message || `HTTP ${res.status}`);
// objectui#6965 — through `MetadataClient`, not a bare `fetch`. The
// route now answers the runtime authoring gate's per-draft advisories
// on each `published[]` element (objectstack#9343), and the client is
// the seam that reports them: it emits one advisory event per advised
// item into the same sink, renderer and wording the save and
// single-item publish doors use. A bare fetch had nothing to report
// THROUGH — which is why this door stayed silent while the L3 probe
// findings a few lines below were already shouting.
const payload = await client.publishPackageDrafts(packageId);
// A non-2xx now throws inside the client, with the server's own
// message. What is left to check here is the batch verdict, unchanged.
if ((payload as { success?: boolean }).success === false) {
const error = (payload as { error?: { message?: string } }).error;
throw new Error(error?.message || 'publish-drafts did not publish this package');
}
recordHealth(publishHealthFromResponse(payload));
}
Expand Down
102 changes: 66 additions & 36 deletions packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
SheetDescription,
} from '@object-ui/components';
import { useMetadataLocale, t, tFormat } from './i18n.js';
import { useMetadataClient } from './useMetadata.js';
import { PackageFormDialog } from './PackageFormDialog.js';
import { errorCodeIs } from '@object-ui/types';
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
Expand Down Expand Up @@ -280,6 +281,14 @@ export function PackageDetailSheet({
onChanged: () => void;
}) {
const locale = useMetadataLocale();
// objectui#6965 — the console's metadata client, for the ONE action on this
// sheet that must report: "publish drafts" promotes metadata, and the runtime
// authoring gate's findings for those promotions ride the response. This hook
// is where the advisory sink is wired (`useMetadataClient` → the toast
// renderer), so a call made through it reports and a call made through the
// page-private `apiJson` cannot. The other lifecycle actions on this sheet
// write no metadata and stay on `apiJson`.
const client = useMetadataClient();
const [busy, setBusy] = React.useState<string | null>(null);
const [msg, setMsg] = React.useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
// ADR-0033 — pending DRAFT items bound to this package. AI-authored metadata
Expand Down Expand Up @@ -360,47 +369,68 @@ export function PackageDetailSheet({
// ADR-0033 — publish every pending draft of this app in one shot, then
// refresh the pending list (it should now be empty). Distinct from the
// registry-based `publish` above; this hits `/publish-drafts`.
//
// objectui#6965 — through `MetadataClient`, not `apiJson`. This promotes
// metadata, so the runtime authoring gate grades it and answers its findings
// on each `published[]` element (objectstack#9343); the client is the seam
// that reports them to the author. `apiJson` could not — and the response
// type declared here could not even hold them: it listed the two counts and
// `failed[]`, with no `published[]` at all. The declared shape now comes from
// the spec, through the client's return type.
const publishDrafts = () =>
run(
'publish-drafts',
() =>
apiJson<{
publishedCount?: number;
failedCount?: number;
failed?: Array<{ type?: string; name?: string; error?: string; code?: string }>;
}>(
`${API}/${encodeURIComponent(id)}/publish-drafts`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) },
).then(async (r) => {
try {
const fresh = await apiJson<{ drafts?: Array<{ type: string; name: string }> }>(
`/api/v1/meta/_drafts?packageId=${encodeURIComponent(id)}`,
);
setDrafts(fresh?.drafts ?? []);
} catch {
setDrafts([]);
}
if (r?.failedCount) {
// framework 15.1+ (ADR-0067 D2): the batch is all-or-nothing — a
// failure means NOTHING landed and `failed[]` marks the rolled-back
// drafts `batch_aborted`, with the causal item carrying the real
// error. Say "rolled back because X", not "{n} failed" (which reads
// as a partial publish that no longer exists).
const failedList = Array.isArray(r.failed) ? r.failed : [];
const causal = failedList.find((f) => !errorCodeIs(f, 'BATCH_ABORTED') && f?.error);
if (failedList.some((f) => errorCodeIs(f, 'BATCH_ABORTED'))) {
throw new Error(tFormat('engine.packages.detail.publishDraftsRolledBack', locale, {
cause: causal ? `${causal.type ?? '?'}/${causal.name ?? '?'}: ${causal.error}` : String(r.failedCount),
}));
}
// pre-15.1 server — genuine partial publish.
throw new Error(tFormat('engine.packages.detail.publishDraftsPartial', locale, {
published: r.publishedCount ?? 0,
failed: r.failedCount,
async () => {
const r = await client.publishPackageDrafts(id).catch((e: unknown) => {
// The ADR-0112 rule objectui#7959 landed on this page: a
// producer-marked `error.userMessage` outranks the diagnostic
// `error.message`. `MetadataClient` raises with the diagnostic and
// keeps the body, so the marked sentence is re-read here rather
// than lost on the way through the seam.
const marked = readEnvelopeFailureText((e as { body?: unknown } | null)?.body);
throw marked ? new Error(marked) : e;
});
if ((r as { success?: boolean }).success === false) {
// Preserves what `apiJson` did for this call: a batch that did not
// publish is an error on this surface, read through the same
// envelope ladder. The status is no longer in hand — a non-2xx
// threw above — so the last rung is a sentence, not "(200)".
throw new Error(
readEnvelopeFailureText(r) ||
(typeof r.error === 'string' ? r.error : '') ||
(typeof r.message === 'string' ? r.message : '') ||
t('engine.packages.detail.actionFailed', locale),
);
}
try {
const fresh = await apiJson<{ drafts?: Array<{ type: string; name: string }> }>(
`/api/v1/meta/_drafts?packageId=${encodeURIComponent(id)}`,
);
setDrafts(fresh?.drafts ?? []);
} catch {
setDrafts([]);
}
if (r?.failedCount) {
// framework 15.1+ (ADR-0067 D2): the batch is all-or-nothing — a
// failure means NOTHING landed and `failed[]` marks the rolled-back
// drafts `batch_aborted`, with the causal item carrying the real
// error. Say "rolled back because X", not "{n} failed" (which reads
// as a partial publish that no longer exists).
const failedList = Array.isArray(r.failed) ? r.failed : [];
const causal = failedList.find((f) => !errorCodeIs(f, 'BATCH_ABORTED') && f?.error);
if (failedList.some((f) => errorCodeIs(f, 'BATCH_ABORTED'))) {
throw new Error(tFormat('engine.packages.detail.publishDraftsRolledBack', locale, {
cause: causal ? `${causal.type ?? '?'}/${causal.name ?? '?'}: ${causal.error}` : String(r.failedCount),
}));
}
return r;
}),
// pre-15.1 server — genuine partial publish.
throw new Error(tFormat('engine.packages.detail.publishDraftsPartial', locale, {
published: r.publishedCount ?? 0,
failed: r.failedCount,
}));
}
return r;
},
t('engine.packages.detail.publishDraftsOk', locale),
);

Expand Down
Loading
Loading