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
6 changes: 6 additions & 0 deletions .changeset/adapt-atomic-publish-and-honest-discovery.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 15 additions & 3 deletions packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 12 additions & 2 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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.',
Expand Down Expand Up @@ -2067,6 +2068,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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} 项失败。',
Expand Down
29 changes: 29 additions & 0 deletions packages/app-shell/src/views/studio-design/metadataError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
44 changes: 34 additions & 10 deletions packages/app-shell/src/views/studio-design/metadataError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
69 changes: 48 additions & 21 deletions packages/react/src/hooks/useDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};
Expand Down Expand Up @@ -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,
};
}

44 changes: 44 additions & 0 deletions packages/react/src/hooks/useDiscovery.usable.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading