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 000000000..789343681 --- /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 5e18a3de3..08f47e9a0 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,11 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra return body; } catch (e) { if ((e as Error).name === 'AbortError') { - throw new Error('timeout'); + // 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 { @@ -101,7 +105,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 e18d0f97f..cda7c20c2 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 b3092a6ce..5f86cd448 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 0ee8f38ac..e8bc77043 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 e807e63c1..dc4d94cea 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 01e01c02c..623b867e5 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 3468e6226..4f593af16 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 000000000..9af489c4e --- /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); + }); +});