From 08c3c114873630354c84ed0af138ad17eb9ec5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:06:00 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(app-shell/react):=20adapt=20to=20frame?= =?UTF-8?q?work=2015.1=20=E2=80=94=20atomic=20publish=20rendering=20+=20ho?= =?UTF-8?q?nest=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0067 D2 (all-or-nothing publishes): - formatPublishFailures renders a rolled-back batch as ONE banner anchored on the causal item; batch_aborted entries are summarized, not listed as N parallel errors. Pre-15.1 partial responses keep per-item rendering. - PackagesPage: "rolled back because X" (new publishDraftsRolledBack i18n en/zh) instead of "{n} failed" when the batch aborted. - AiChatPage publish toast: surface the causal reason via formatPublishFailures instead of throwing String(failedCount) (a toast that read just "3"). ADR-0076 D12 (honest capabilities, console slice): - DiscoveryServiceStatus: + handlerReady, + degraded/stub statuses. - isServiceUsable(): backward-tolerant gate — absent fields keep the pre-15.1 default (usable); stub/handlerReady:false gate off; degraded stays usable (it serves). Consumed by isAuthEnabled / isAiEnabled and ConditionalAuthWrapper (the load-bearing auth gate). - ConditionalAuthWrapper: also attach cause to the timeout error (preserve-caught-error). Tests: metadataError suite +2 cases, new useDiscovery.usable suite (8), adjacent suites green (106 tests incl. ConditionalAuthWrapper/i18n). --- ...apt-atomic-publish-and-honest-discovery.md | 6 ++ .../src/chrome/ConditionalAuthWrapper.tsx | 14 +++- .../app-shell/src/console/ai/AiChatPage.tsx | 14 +++- .../src/views/metadata-admin/PackagesPage.tsx | 19 ++++- .../src/views/metadata-admin/i18n.ts | 2 + .../views/studio-design/metadataError.test.ts | 29 ++++++++ .../src/views/studio-design/metadataError.ts | 44 +++++++++--- packages/react/src/hooks/useDiscovery.ts | 69 +++++++++++++------ .../src/hooks/useDiscovery.usable.test.ts | 44 ++++++++++++ 9 files changed, 204 insertions(+), 37 deletions(-) create mode 100644 .changeset/adapt-atomic-publish-and-honest-discovery.md create mode 100644 packages/react/src/hooks/useDiscovery.usable.test.ts diff --git a/.changeset/adapt-atomic-publish-and-honest-discovery.md b/.changeset/adapt-atomic-publish-and-honest-discovery.md new file mode 100644 index 0000000000..7893436812 --- /dev/null +++ b/.changeset/adapt-atomic-publish-and-honest-discovery.md @@ -0,0 +1,6 @@ +--- +"@object-ui/react": minor +"@object-ui/app-shell": minor +--- + +Adapt to framework 15.1: (1) ADR-0067 D2 all-or-nothing publishes — `formatPublishFailures` renders a rolled-back batch as ONE banner anchored on the causal item (`batch_aborted` entries are summarized, not listed as parallel errors); PackagesPage says "rolled back because X" instead of "{n} failed"; the AI chat publish toast surfaces the real reason instead of a bare count. Pre-15.1 partial-publish responses keep their per-item rendering. (2) ADR-0076 D12 honest discovery — `DiscoveryServiceStatus` gains `handlerReady` + `degraded`/`stub` statuses, new backward-tolerant `isServiceUsable()` helper (absent fields keep the pre-15.1 default; `stub`/`handlerReady:false` gate off; `degraded` stays usable), consumed by `isAuthEnabled`/`isAiEnabled` and `ConditionalAuthWrapper`. diff --git a/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx b/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx index 5e18a3de34..9e66e1273d 100644 --- a/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx +++ b/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx @@ -11,7 +11,7 @@ import { getSharedDiscovery } from '@object-ui/data-objectstack'; import { AuthProvider } from '@object-ui/auth'; import type { PreviewModeOptions } from '@object-ui/auth'; import { LoadingScreen } from './LoadingScreen'; -import type { DiscoveryInfo } from '@object-ui/react'; +import { isServiceUsable, type DiscoveryInfo } from '@object-ui/react'; interface ConditionalAuthWrapperProps { children: ReactNode; @@ -73,7 +73,7 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra return body; } catch (e) { if ((e as Error).name === 'AbortError') { - throw new Error('timeout'); + throw new Error('timeout', { cause: e }); } throw e; } finally { @@ -101,7 +101,15 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra }); setAuthEnabled(false); } else { - const isAuthEnabled = discovery?.services?.auth?.enabled ?? true; + // ADR-0076 D12 (honest capabilities): trust the 15.1+ signals when + // present — a `stub` or `handlerReady:false` auth service must NOT + // wrap the app in a real AuthProvider (login against a dev fake). + // Pre-15.1 servers carry none of these fields → historical default + // (enabled) is preserved by isServiceUsable. + const isAuthEnabled = isServiceUsable(discovery?.services?.auth); + if (discovery?.services?.auth?.status === 'degraded') { + console.warn('[ConditionalAuthWrapper] auth service reports degraded — keeping auth enabled (it still serves).'); + } setAuthEnabled(isAuthEnabled); } setIsLoading(false); diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index e18d0f97f5..cda7c20c2b 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -21,6 +21,7 @@ import { toast } from 'sonner'; import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react'; import { useAdapter } from '../../providers/AdapterProvider'; import { useMetadata } from '../../providers/MetadataProvider'; +import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError'; import { resolveI18nLabel } from '../../utils'; import { ExcelImportBar } from './ExcelImportBar'; import { @@ -2109,8 +2110,17 @@ export function ChatPane({ if (!res.ok || payload?.success === false) { throw new Error(payload?.error?.message || `HTTP ${res.status}`); } - const failed = payload?.data?.failedCount ?? payload?.failedCount ?? 0; - if (failed) throw new Error(String(failed)); + const failedCount = payload?.data?.failedCount ?? payload?.failedCount ?? 0; + if (failedCount) { + // framework 15.1+ (ADR-0067 D2): a failed batch is ALL-OR-NOTHING + // (rolled back, nothing landed); `failed[]` carries the causal + // item plus batch_aborted markers. Surface the reason — the old + // `String(failedCount)` produced a toast that read just "3". + const failedList = (payload?.data?.failed ?? payload?.failed ?? []) as PublishFailure[]; + throw new Error( + failedList.length > 0 ? formatPublishFailures(failedList) : String(failedCount), + ); + } // Surface a seed-load problem (reported under `seedApplied`, never // thrown) so "Published!" can't hide silently empty tables. const seedApplied = payload?.data?.seedApplied ?? payload?.seedApplied; diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index b3092a6cea..5f86cd448c 100644 --- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -323,7 +323,11 @@ export function PackageDetailSheet({ run( 'publish-drafts', () => - apiJson<{ publishedCount?: number; failedCount?: number; failed?: Array<{ name?: string }> }>( + 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) => { @@ -336,6 +340,19 @@ export function PackageDetailSheet({ 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) => f?.code !== 'batch_aborted' && f?.error); + if (failedList.some((f) => f?.code === '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, diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 0ee8f38acd..e8bc77043a 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -712,6 +712,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.packages.detail.nothingToPublish': 'Nothing to publish.', 'engine.packages.detail.published': 'Package published.', 'engine.packages.detail.publishDraftsPartial': 'Published {published}; {failed} failed.', + 'engine.packages.detail.publishDraftsRolledBack': 'Nothing was published — the batch rolled back (all-or-nothing): {cause}', 'engine.packages.detail.publishDraftsOk': 'App published — all drafts are now live.', 'engine.packages.detail.reverted': 'Reverted to last published state.', 'engine.packages.detail.discardDraftsPartial': 'Discarded {discarded}; {failed} failed.', @@ -2067,6 +2068,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.packages.detail.nothingToPublish': '没有可发布的内容。', 'engine.packages.detail.published': '软件包已发布。', 'engine.packages.detail.publishDraftsPartial': '已发布 {published} 项;{failed} 项失败。', + 'engine.packages.detail.publishDraftsRolledBack': '发布未生效 — 批次已整体回滚(全有或全无):{cause}', 'engine.packages.detail.publishDraftsOk': '应用已发布,所有草稿均已生效。', 'engine.packages.detail.reverted': '已还原到上次发布状态。', 'engine.packages.detail.discardDraftsPartial': '已丢弃 {discarded} 项;{failed} 项失败。', diff --git a/packages/app-shell/src/views/studio-design/metadataError.test.ts b/packages/app-shell/src/views/studio-design/metadataError.test.ts index e807e63c19..dc4d94ceaf 100644 --- a/packages/app-shell/src/views/studio-design/metadataError.test.ts +++ b/packages/app-shell/src/views/studio-design/metadataError.test.ts @@ -55,4 +55,33 @@ describe('formatPublishFailures', () => { 'flow/notify: start node missing', ); }); + + // framework 15.1+ (ADR-0067 D2) — the batch is all-or-nothing; `failed[]` + // carries the causal item + batch_aborted markers for the rolled-back rest. + it('15.1+ all-or-nothing: one rolled-back banner anchored on the causal item', () => { + const out = formatPublishFailures([ + { type: 'object', name: 'crm_lead', error: 'not published — the batch is all-or-nothing…', code: 'batch_aborted' }, + { + type: 'object', name: 'crm_deal', error: 'failed spec validation', code: 'invalid_metadata', + issues: [{ path: 'fields.amount.type', message: 'Required' }], + }, + { type: 'view', name: 'lead_list', error: 'not published — …', code: 'batch_aborted' }, + ]); + expect(out).toContain('Nothing was published — the batch rolled back'); + // causal item with its real error and field-anchored issues… + expect(out).toContain('object/crm_deal: failed spec validation'); + expect(out).toContain('fields.amount.type — Required'); + // …aborted entries summarized, not listed as parallel errors + expect(out).not.toContain('crm_lead: not published'); + expect(out).toContain('2 other drafts aborted with it'); + }); + + it('all entries aborted (defensive): banner still renders with one sample', () => { + const out = formatPublishFailures([ + { type: 'object', name: 'a', error: 'not published — …', code: 'batch_aborted' }, + { type: 'object', name: 'b', error: 'not published — …', code: 'batch_aborted' }, + ]); + expect(out).toContain('Nothing was published'); + expect(out).toContain('object/a'); + }); }); diff --git a/packages/app-shell/src/views/studio-design/metadataError.ts b/packages/app-shell/src/views/studio-design/metadataError.ts index 01e01c02c1..623b867e53 100644 --- a/packages/app-shell/src/views/studio-design/metadataError.ts +++ b/packages/app-shell/src/views/studio-design/metadataError.ts @@ -38,20 +38,44 @@ export interface PublishFailure { type: string; name: string; error: string; + /** Machine code — `batch_aborted` marks a draft rolled back with the batch (ADR-0067 D2). */ + code?: string; issues?: MetadataValidationIssue[]; } /** - * Format the `failed[]` from a partial publish (the server returns 200 with the - * drafts that DIDN'T go live). Each failed draft gets a heading and, when the - * failure was a validation error, its field-anchored issues indented below. + * framework 15.1+ (ADR-0067 D2): package publishes are ALL-OR-NOTHING. A + * failed batch reports every draft in `failed[]` — the causal item with its + * real error, the rest with this code — and `publishedCount: 0`. + */ +export const BATCH_ABORTED_CODE = 'batch_aborted'; + +/** + * Format the `failed[]` from a publish response (the server returns 200 with + * the drafts that didn't go live). + * + * Two server generations produce two shapes (both handled): + * - **15.1+ all-or-nothing** (ADR-0067 D2): the batch rolled back atomically — + * render ONE rolled-back banner anchored on the causal item(s), not N + * parallel errors (`batch_aborted` entries are consequences, not causes). + * - **pre-15.1 partial publish**: each failed draft gets a heading and, when + * the failure was a validation error, its field-anchored issues indented + * below. */ export function formatPublishFailures(failed: PublishFailure[]): string { - return failed - .map((f) => { - const head = `${f.type}/${f.name}: ${f.error}`; - const issues = Array.isArray(f.issues) ? f.issues : []; - return [head, ...issues.map((i) => ` ${issueLine(i)}`)].join('\n'); - }) - .join('\n'); + const line = (f: PublishFailure): string => { + const head = `${f.type}/${f.name}: ${f.error}`; + const issues = Array.isArray(f.issues) ? f.issues : []; + return [head, ...issues.map((i) => ` ${issueLine(i)}`)].join('\n'); + }; + const aborted = failed.filter((f) => f.code === BATCH_ABORTED_CODE); + if (aborted.length > 0) { + const causal = failed.filter((f) => f.code !== BATCH_ABORTED_CODE); + return [ + 'Nothing was published — the batch rolled back (all-or-nothing).', + ...(causal.length > 0 ? causal.map(line) : aborted.slice(0, 1).map(line)), + `(${aborted.length} other draft${aborted.length === 1 ? '' : 's'} aborted with it — fix the cause and publish again.)`, + ].join('\n'); + } + return failed.map(line).join('\n'); } diff --git a/packages/react/src/hooks/useDiscovery.ts b/packages/react/src/hooks/useDiscovery.ts index 3468e62266..4f593af166 100644 --- a/packages/react/src/hooks/useDiscovery.ts +++ b/packages/react/src/hooks/useDiscovery.ts @@ -13,6 +13,41 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext'; * Discovery service information structure. * Represents server capabilities and service status. */ +/** + * Per-service availability entry (framework 15.1+, ADR-0076 D12 "honest + * capabilities"): discovery no longer hardcodes every registered service as + * `available` — a dev fake reports `stub`, a serving fallback reports + * `degraded`, and `handlerReady` says whether a real handler backs the route. + * Older servers omit `handlerReady` and only ever report + * `available`/`unavailable`, so consumers must treat the new fields as + * OPT-IN signals (see {@link isServiceUsable}), never require them. + */ +export interface DiscoveryServiceStatus { + enabled: boolean; + status?: 'available' | 'degraded' | 'stub' | 'unavailable'; + handlerReady?: boolean; +} + +/** + * The backward-compatible "can I actually use this service?" check + * (ADR-0076 D12 console slice): + * + * - absent entry / absent fields → usable (pre-15.1 servers say nothing — + * keep their historical default); + * - `enabled: false` or `handlerReady: false` → not usable; + * - `status: 'stub'` → not usable (a dev fake must not be treated as the + * real service — the exact dishonesty D12 removed); + * - `status: 'degraded'` → USABLE (a fallback that keeps serving; callers + * may surface a warning but must not turn the feature off). + */ +export function isServiceUsable(svc: DiscoveryServiceStatus | undefined | null): boolean { + if (!svc) return true; + if (svc.enabled === false) return false; + if (svc.handlerReady === false) return false; + if (svc.status === 'stub' || svc.status === 'unavailable') return false; + return true; +} + export interface DiscoveryInfo { /** Server name and version */ name?: string; @@ -34,25 +69,13 @@ export interface DiscoveryInfo { /** Service availability status */ services?: { /** Authentication service status */ - auth?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - message?: string; - }; + auth?: DiscoveryServiceStatus & { message?: string }; /** Data access service status */ - data?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - }; + data?: DiscoveryServiceStatus; /** Metadata service status */ - metadata?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - }; + metadata?: DiscoveryServiceStatus; /** AI service configuration */ - ai?: { - enabled: boolean; - status?: 'available' | 'unavailable'; + ai?: DiscoveryServiceStatus & { /** AI service endpoint route (e.g. '/api/v1/ai') */ route?: string; }; @@ -152,17 +175,21 @@ export function useDiscovery() { isLoading, error, /** - * Check if authentication is enabled on the server. - * Defaults to true if discovery data is not available. + * Check if authentication is enabled AND actually backed by a real + * handler (ADR-0076 D12 — a `stub`/`handlerReady:false` auth service is + * not treated as real auth). Defaults to true when discovery data is not + * available (pre-15.1 servers report nothing). */ - isAuthEnabled: discovery?.services?.auth?.enabled ?? true, + isAuthEnabled: isServiceUsable(discovery?.services?.auth), /** * Check if AI service is enabled and available on the server. - * Defaults to false if discovery data is not available. + * Defaults to false if discovery data is not available; `degraded` + * still counts as available (it serves), `stub` never does. */ isAiEnabled: discovery?.services?.ai?.enabled === true && - discovery?.services?.ai?.status === 'available', + (discovery?.services?.ai?.status === 'available' || discovery?.services?.ai?.status === 'degraded') && + discovery?.services?.ai?.handlerReady !== false, }; } diff --git a/packages/react/src/hooks/useDiscovery.usable.test.ts b/packages/react/src/hooks/useDiscovery.usable.test.ts new file mode 100644 index 0000000000..9af489c4e8 --- /dev/null +++ b/packages/react/src/hooks/useDiscovery.usable.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// isServiceUsable — the ADR-0076 D12 console slice. The contract is +// BACKWARD-TOLERANT: 15.1+ honesty signals (handlerReady, status +// degraded/stub) are trusted when present, never required — a pre-15.1 +// server that says nothing keeps its historical default (usable). + +import { describe, it, expect } from 'vitest'; +import { isServiceUsable } from './useDiscovery'; + +describe('isServiceUsable (ADR-0076 D12)', () => { + it('absent entry → usable (pre-15.1 default preserved)', () => { + expect(isServiceUsable(undefined)).toBe(true); + expect(isServiceUsable(null)).toBe(true); + }); + + it('fields absent beyond enabled → usable', () => { + expect(isServiceUsable({ enabled: true })).toBe(true); + }); + + it('enabled:false → not usable', () => { + expect(isServiceUsable({ enabled: false })).toBe(false); + }); + + it('handlerReady:false → not usable (route exists, no real handler)', () => { + expect(isServiceUsable({ enabled: true, handlerReady: false })).toBe(false); + }); + + it('status stub → not usable (a dev fake is not the real service)', () => { + expect(isServiceUsable({ enabled: true, status: 'stub', handlerReady: true })).toBe(false); + }); + + it('status unavailable → not usable', () => { + expect(isServiceUsable({ enabled: true, status: 'unavailable' })).toBe(false); + }); + + it('status degraded → USABLE (a serving fallback must not turn the feature off)', () => { + expect(isServiceUsable({ enabled: true, status: 'degraded', handlerReady: true })).toBe(true); + }); + + it('fully honest available service → usable', () => { + expect(isServiceUsable({ enabled: true, status: 'available', handlerReady: true })).toBe(true); + }); +}); From 7b43709497d085cbbf2601de97df2c90245f419f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:13:23 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(app-shell):=20manual=20cause=20assignme?= =?UTF-8?q?nt=20on=20timeout=20error=20=E2=80=94=20two-arg=20Error=20ctor?= =?UTF-8?q?=20needs=20ES2022=20lib=20the=20tsconfig=20doesn't=20target=20(?= =?UTF-8?q?Bundle=20Analysis=20TS2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx b/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx index 9e66e1273d..08f47e9a01 100644 --- a/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx +++ b/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx @@ -73,7 +73,11 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra return body; } catch (e) { if ((e as Error).name === 'AbortError') { - throw new Error('timeout', { cause: e }); + // Manual `cause` assignment — the two-arg Error constructor needs + // an ES2022 lib this package's tsconfig doesn't target. + const timeoutErr = new Error('timeout'); + (timeoutErr as Error & { cause?: unknown }).cause = e; + throw timeoutErr; } throw e; } finally { From 69b182890a95710757e27be2afaa6c28ca39d864 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:25:17 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20retrigger=20CI=20(DraftPreviewBar?= =?UTF-8?q?=20flake=20=E2=80=94=20passes=20locally,=20unrelated=20to=20thi?= =?UTF-8?q?s=20diff)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 05f013acb07161ff62b1174c4641be12eafce41b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:36:13 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20retrigger=20CI=20(PeoplePicker=20f?= =?UTF-8?q?lake=20this=20round=20=E2=80=94=20different=20test=20each=20run?= =?UTF-8?q?,=20both=20unrelated=20to=20diff)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit