Skip to content

Commit d3e19ed

Browse files
authored
feat(app-shell/react): adapt to framework 15.1 — atomic publish rendering + honest discovery (#2630)
1 parent c0bd483 commit d3e19ed

9 files changed

Lines changed: 208 additions & 37 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@object-ui/react": minor
3+
"@object-ui/app-shell": minor
4+
---
5+
6+
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`.

packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getSharedDiscovery } from '@object-ui/data-objectstack';
1111
import { AuthProvider } from '@object-ui/auth';
1212
import type { PreviewModeOptions } from '@object-ui/auth';
1313
import { LoadingScreen } from './LoadingScreen';
14-
import type { DiscoveryInfo } from '@object-ui/react';
14+
import { isServiceUsable, type DiscoveryInfo } from '@object-ui/react';
1515

1616
interface ConditionalAuthWrapperProps {
1717
children: ReactNode;
@@ -73,7 +73,11 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra
7373
return body;
7474
} catch (e) {
7575
if ((e as Error).name === 'AbortError') {
76-
throw new Error('timeout');
76+
// Manual `cause` assignment — the two-arg Error constructor needs
77+
// an ES2022 lib this package's tsconfig doesn't target.
78+
const timeoutErr = new Error('timeout');
79+
(timeoutErr as Error & { cause?: unknown }).cause = e;
80+
throw timeoutErr;
7781
}
7882
throw e;
7983
} finally {
@@ -101,7 +105,15 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra
101105
});
102106
setAuthEnabled(false);
103107
} else {
104-
const isAuthEnabled = discovery?.services?.auth?.enabled ?? true;
108+
// ADR-0076 D12 (honest capabilities): trust the 15.1+ signals when
109+
// present — a `stub` or `handlerReady:false` auth service must NOT
110+
// wrap the app in a real AuthProvider (login against a dev fake).
111+
// Pre-15.1 servers carry none of these fields → historical default
112+
// (enabled) is preserved by isServiceUsable.
113+
const isAuthEnabled = isServiceUsable(discovery?.services?.auth);
114+
if (discovery?.services?.auth?.status === 'degraded') {
115+
console.warn('[ConditionalAuthWrapper] auth service reports degraded — keeping auth enabled (it still serves).');
116+
}
105117
setAuthEnabled(isAuthEnabled);
106118
}
107119
setIsLoading(false);

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { toast } from 'sonner';
2121
import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react';
2222
import { useAdapter } from '../../providers/AdapterProvider';
2323
import { useMetadata } from '../../providers/MetadataProvider';
24+
import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError';
2425
import { resolveI18nLabel } from '../../utils';
2526
import { ExcelImportBar } from './ExcelImportBar';
2627
import {
@@ -2109,8 +2110,17 @@ export function ChatPane({
21092110
if (!res.ok || payload?.success === false) {
21102111
throw new Error(payload?.error?.message || `HTTP ${res.status}`);
21112112
}
2112-
const failed = payload?.data?.failedCount ?? payload?.failedCount ?? 0;
2113-
if (failed) throw new Error(String(failed));
2113+
const failedCount = payload?.data?.failedCount ?? payload?.failedCount ?? 0;
2114+
if (failedCount) {
2115+
// framework 15.1+ (ADR-0067 D2): a failed batch is ALL-OR-NOTHING
2116+
// (rolled back, nothing landed); `failed[]` carries the causal
2117+
// item plus batch_aborted markers. Surface the reason — the old
2118+
// `String(failedCount)` produced a toast that read just "3".
2119+
const failedList = (payload?.data?.failed ?? payload?.failed ?? []) as PublishFailure[];
2120+
throw new Error(
2121+
failedList.length > 0 ? formatPublishFailures(failedList) : String(failedCount),
2122+
);
2123+
}
21142124
// Surface a seed-load problem (reported under `seedApplied`, never
21152125
// thrown) so "Published!" can't hide silently empty tables.
21162126
const seedApplied = payload?.data?.seedApplied ?? payload?.seedApplied;

packages/app-shell/src/views/metadata-admin/PackagesPage.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,11 @@ export function PackageDetailSheet({
323323
run(
324324
'publish-drafts',
325325
() =>
326-
apiJson<{ publishedCount?: number; failedCount?: number; failed?: Array<{ name?: string }> }>(
326+
apiJson<{
327+
publishedCount?: number;
328+
failedCount?: number;
329+
failed?: Array<{ type?: string; name?: string; error?: string; code?: string }>;
330+
}>(
327331
`${API}/${encodeURIComponent(id)}/publish-drafts`,
328332
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) },
329333
).then(async (r) => {
@@ -336,6 +340,19 @@ export function PackageDetailSheet({
336340
setDrafts([]);
337341
}
338342
if (r?.failedCount) {
343+
// framework 15.1+ (ADR-0067 D2): the batch is all-or-nothing — a
344+
// failure means NOTHING landed and `failed[]` marks the rolled-back
345+
// drafts `batch_aborted`, with the causal item carrying the real
346+
// error. Say "rolled back because X", not "{n} failed" (which reads
347+
// as a partial publish that no longer exists).
348+
const failedList = Array.isArray(r.failed) ? r.failed : [];
349+
const causal = failedList.find((f) => f?.code !== 'batch_aborted' && f?.error);
350+
if (failedList.some((f) => f?.code === 'batch_aborted')) {
351+
throw new Error(tFormat('engine.packages.detail.publishDraftsRolledBack', locale, {
352+
cause: causal ? `${causal.type ?? '?'}/${causal.name ?? '?'}: ${causal.error}` : String(r.failedCount),
353+
}));
354+
}
355+
// pre-15.1 server — genuine partial publish.
339356
throw new Error(tFormat('engine.packages.detail.publishDraftsPartial', locale, {
340357
published: r.publishedCount ?? 0,
341358
failed: r.failedCount,

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
712712
'engine.packages.detail.nothingToPublish': 'Nothing to publish.',
713713
'engine.packages.detail.published': 'Package published.',
714714
'engine.packages.detail.publishDraftsPartial': 'Published {published}; {failed} failed.',
715+
'engine.packages.detail.publishDraftsRolledBack': 'Nothing was published — the batch rolled back (all-or-nothing): {cause}',
715716
'engine.packages.detail.publishDraftsOk': 'App published — all drafts are now live.',
716717
'engine.packages.detail.reverted': 'Reverted to last published state.',
717718
'engine.packages.detail.discardDraftsPartial': 'Discarded {discarded}; {failed} failed.',
@@ -2067,6 +2068,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
20672068
'engine.packages.detail.nothingToPublish': '没有可发布的内容。',
20682069
'engine.packages.detail.published': '软件包已发布。',
20692070
'engine.packages.detail.publishDraftsPartial': '已发布 {published} 项;{failed} 项失败。',
2071+
'engine.packages.detail.publishDraftsRolledBack': '发布未生效 — 批次已整体回滚(全有或全无):{cause}',
20702072
'engine.packages.detail.publishDraftsOk': '应用已发布,所有草稿均已生效。',
20712073
'engine.packages.detail.reverted': '已还原到上次发布状态。',
20722074
'engine.packages.detail.discardDraftsPartial': '已丢弃 {discarded} 项;{failed} 项失败。',

packages/app-shell/src/views/studio-design/metadataError.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,33 @@ describe('formatPublishFailures', () => {
5555
'flow/notify: start node missing',
5656
);
5757
});
58+
59+
// framework 15.1+ (ADR-0067 D2) — the batch is all-or-nothing; `failed[]`
60+
// carries the causal item + batch_aborted markers for the rolled-back rest.
61+
it('15.1+ all-or-nothing: one rolled-back banner anchored on the causal item', () => {
62+
const out = formatPublishFailures([
63+
{ type: 'object', name: 'crm_lead', error: 'not published — the batch is all-or-nothing…', code: 'batch_aborted' },
64+
{
65+
type: 'object', name: 'crm_deal', error: 'failed spec validation', code: 'invalid_metadata',
66+
issues: [{ path: 'fields.amount.type', message: 'Required' }],
67+
},
68+
{ type: 'view', name: 'lead_list', error: 'not published — …', code: 'batch_aborted' },
69+
]);
70+
expect(out).toContain('Nothing was published — the batch rolled back');
71+
// causal item with its real error and field-anchored issues…
72+
expect(out).toContain('object/crm_deal: failed spec validation');
73+
expect(out).toContain('fields.amount.type — Required');
74+
// …aborted entries summarized, not listed as parallel errors
75+
expect(out).not.toContain('crm_lead: not published');
76+
expect(out).toContain('2 other drafts aborted with it');
77+
});
78+
79+
it('all entries aborted (defensive): banner still renders with one sample', () => {
80+
const out = formatPublishFailures([
81+
{ type: 'object', name: 'a', error: 'not published — …', code: 'batch_aborted' },
82+
{ type: 'object', name: 'b', error: 'not published — …', code: 'batch_aborted' },
83+
]);
84+
expect(out).toContain('Nothing was published');
85+
expect(out).toContain('object/a');
86+
});
5887
});

packages/app-shell/src/views/studio-design/metadataError.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,44 @@ export interface PublishFailure {
3838
type: string;
3939
name: string;
4040
error: string;
41+
/** Machine code — `batch_aborted` marks a draft rolled back with the batch (ADR-0067 D2). */
42+
code?: string;
4143
issues?: MetadataValidationIssue[];
4244
}
4345

4446
/**
45-
* Format the `failed[]` from a partial publish (the server returns 200 with the
46-
* drafts that DIDN'T go live). Each failed draft gets a heading and, when the
47-
* failure was a validation error, its field-anchored issues indented below.
47+
* framework 15.1+ (ADR-0067 D2): package publishes are ALL-OR-NOTHING. A
48+
* failed batch reports every draft in `failed[]` — the causal item with its
49+
* real error, the rest with this code — and `publishedCount: 0`.
50+
*/
51+
export const BATCH_ABORTED_CODE = 'batch_aborted';
52+
53+
/**
54+
* Format the `failed[]` from a publish response (the server returns 200 with
55+
* the drafts that didn't go live).
56+
*
57+
* Two server generations produce two shapes (both handled):
58+
* - **15.1+ all-or-nothing** (ADR-0067 D2): the batch rolled back atomically —
59+
* render ONE rolled-back banner anchored on the causal item(s), not N
60+
* parallel errors (`batch_aborted` entries are consequences, not causes).
61+
* - **pre-15.1 partial publish**: each failed draft gets a heading and, when
62+
* the failure was a validation error, its field-anchored issues indented
63+
* below.
4864
*/
4965
export function formatPublishFailures(failed: PublishFailure[]): string {
50-
return failed
51-
.map((f) => {
52-
const head = `${f.type}/${f.name}: ${f.error}`;
53-
const issues = Array.isArray(f.issues) ? f.issues : [];
54-
return [head, ...issues.map((i) => ` ${issueLine(i)}`)].join('\n');
55-
})
56-
.join('\n');
66+
const line = (f: PublishFailure): string => {
67+
const head = `${f.type}/${f.name}: ${f.error}`;
68+
const issues = Array.isArray(f.issues) ? f.issues : [];
69+
return [head, ...issues.map((i) => ` ${issueLine(i)}`)].join('\n');
70+
};
71+
const aborted = failed.filter((f) => f.code === BATCH_ABORTED_CODE);
72+
if (aborted.length > 0) {
73+
const causal = failed.filter((f) => f.code !== BATCH_ABORTED_CODE);
74+
return [
75+
'Nothing was published — the batch rolled back (all-or-nothing).',
76+
...(causal.length > 0 ? causal.map(line) : aborted.slice(0, 1).map(line)),
77+
`(${aborted.length} other draft${aborted.length === 1 ? '' : 's'} aborted with it — fix the cause and publish again.)`,
78+
].join('\n');
79+
}
80+
return failed.map(line).join('\n');
5781
}

packages/react/src/hooks/useDiscovery.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,41 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext';
1313
* Discovery service information structure.
1414
* Represents server capabilities and service status.
1515
*/
16+
/**
17+
* Per-service availability entry (framework 15.1+, ADR-0076 D12 "honest
18+
* capabilities"): discovery no longer hardcodes every registered service as
19+
* `available` — a dev fake reports `stub`, a serving fallback reports
20+
* `degraded`, and `handlerReady` says whether a real handler backs the route.
21+
* Older servers omit `handlerReady` and only ever report
22+
* `available`/`unavailable`, so consumers must treat the new fields as
23+
* OPT-IN signals (see {@link isServiceUsable}), never require them.
24+
*/
25+
export interface DiscoveryServiceStatus {
26+
enabled: boolean;
27+
status?: 'available' | 'degraded' | 'stub' | 'unavailable';
28+
handlerReady?: boolean;
29+
}
30+
31+
/**
32+
* The backward-compatible "can I actually use this service?" check
33+
* (ADR-0076 D12 console slice):
34+
*
35+
* - absent entry / absent fields → usable (pre-15.1 servers say nothing —
36+
* keep their historical default);
37+
* - `enabled: false` or `handlerReady: false` → not usable;
38+
* - `status: 'stub'` → not usable (a dev fake must not be treated as the
39+
* real service — the exact dishonesty D12 removed);
40+
* - `status: 'degraded'` → USABLE (a fallback that keeps serving; callers
41+
* may surface a warning but must not turn the feature off).
42+
*/
43+
export function isServiceUsable(svc: DiscoveryServiceStatus | undefined | null): boolean {
44+
if (!svc) return true;
45+
if (svc.enabled === false) return false;
46+
if (svc.handlerReady === false) return false;
47+
if (svc.status === 'stub' || svc.status === 'unavailable') return false;
48+
return true;
49+
}
50+
1651
export interface DiscoveryInfo {
1752
/** Server name and version */
1853
name?: string;
@@ -34,25 +69,13 @@ export interface DiscoveryInfo {
3469
/** Service availability status */
3570
services?: {
3671
/** Authentication service status */
37-
auth?: {
38-
enabled: boolean;
39-
status?: 'available' | 'unavailable';
40-
message?: string;
41-
};
72+
auth?: DiscoveryServiceStatus & { message?: string };
4273
/** Data access service status */
43-
data?: {
44-
enabled: boolean;
45-
status?: 'available' | 'unavailable';
46-
};
74+
data?: DiscoveryServiceStatus;
4775
/** Metadata service status */
48-
metadata?: {
49-
enabled: boolean;
50-
status?: 'available' | 'unavailable';
51-
};
76+
metadata?: DiscoveryServiceStatus;
5277
/** AI service configuration */
53-
ai?: {
54-
enabled: boolean;
55-
status?: 'available' | 'unavailable';
78+
ai?: DiscoveryServiceStatus & {
5679
/** AI service endpoint route (e.g. '/api/v1/ai') */
5780
route?: string;
5881
};
@@ -152,17 +175,21 @@ export function useDiscovery() {
152175
isLoading,
153176
error,
154177
/**
155-
* Check if authentication is enabled on the server.
156-
* Defaults to true if discovery data is not available.
178+
* Check if authentication is enabled AND actually backed by a real
179+
* handler (ADR-0076 D12 — a `stub`/`handlerReady:false` auth service is
180+
* not treated as real auth). Defaults to true when discovery data is not
181+
* available (pre-15.1 servers report nothing).
157182
*/
158-
isAuthEnabled: discovery?.services?.auth?.enabled ?? true,
183+
isAuthEnabled: isServiceUsable(discovery?.services?.auth),
159184
/**
160185
* Check if AI service is enabled and available on the server.
161-
* Defaults to false if discovery data is not available.
186+
* Defaults to false if discovery data is not available; `degraded`
187+
* still counts as available (it serves), `stub` never does.
162188
*/
163189
isAiEnabled:
164190
discovery?.services?.ai?.enabled === true &&
165-
discovery?.services?.ai?.status === 'available',
191+
(discovery?.services?.ai?.status === 'available' || discovery?.services?.ai?.status === 'degraded') &&
192+
discovery?.services?.ai?.handlerReady !== false,
166193
};
167194
}
168195

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// isServiceUsable — the ADR-0076 D12 console slice. The contract is
4+
// BACKWARD-TOLERANT: 15.1+ honesty signals (handlerReady, status
5+
// degraded/stub) are trusted when present, never required — a pre-15.1
6+
// server that says nothing keeps its historical default (usable).
7+
8+
import { describe, it, expect } from 'vitest';
9+
import { isServiceUsable } from './useDiscovery';
10+
11+
describe('isServiceUsable (ADR-0076 D12)', () => {
12+
it('absent entry → usable (pre-15.1 default preserved)', () => {
13+
expect(isServiceUsable(undefined)).toBe(true);
14+
expect(isServiceUsable(null)).toBe(true);
15+
});
16+
17+
it('fields absent beyond enabled → usable', () => {
18+
expect(isServiceUsable({ enabled: true })).toBe(true);
19+
});
20+
21+
it('enabled:false → not usable', () => {
22+
expect(isServiceUsable({ enabled: false })).toBe(false);
23+
});
24+
25+
it('handlerReady:false → not usable (route exists, no real handler)', () => {
26+
expect(isServiceUsable({ enabled: true, handlerReady: false })).toBe(false);
27+
});
28+
29+
it('status stub → not usable (a dev fake is not the real service)', () => {
30+
expect(isServiceUsable({ enabled: true, status: 'stub', handlerReady: true })).toBe(false);
31+
});
32+
33+
it('status unavailable → not usable', () => {
34+
expect(isServiceUsable({ enabled: true, status: 'unavailable' })).toBe(false);
35+
});
36+
37+
it('status degraded → USABLE (a serving fallback must not turn the feature off)', () => {
38+
expect(isServiceUsable({ enabled: true, status: 'degraded', handlerReady: true })).toBe(true);
39+
});
40+
41+
it('fully honest available service → usable', () => {
42+
expect(isServiceUsable({ enabled: true, status: 'available', handlerReady: true })).toBe(true);
43+
});
44+
});

0 commit comments

Comments
 (0)