Skip to content

Commit b309866

Browse files
test(console): pin the object-storage-sync-refused advisory on every object save and publish door (#30761)
Fixes #30723 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:只加测试。把服务端 `object-storage-sync-refused` 告警(原样复制生产者的形状,`path` 为空串)喂进控制台对象保存/发布的四个入口,钉住管理员一定能看到带服务端 message 与 hint 的警告。 - **为什么改**:卡片预期第二条——控制台本来就通用地渲染 `advisories`,这里核实它在对象保存/发布入口确实生效;此前没有任何测试用这条规则的真实形状(空 `path`)走过这些入口。 - **风险与回滚**:零运行时风险,没有改任何源码、文案、i18n 键或 package.json;回滚即删掉两个测试块。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/useMetadataClient.advisorySink.test.tsx`(新 describe:save / publish / publishDraft / publishPackageDrafts 四个入口);`ui/packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts`(新 describe:适配器 `meta.saveItem`,即 Setup 的对象保存)。 - **两分钟验证**:在 `ui/` 下运行 `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/useMetadataClient.advisorySink.test.tsx packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts`,预期 2 个文件、13 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes Tests only. One finding in the exact shape `objectStorageSyncRefusedNotice` returns (`packages/metadata-protocol/src/protocol-registry-hydration.ts`, copied verbatim with a comment naming the file and function; no new dependency), driven through every console object write door with a real client over a fake `fetch`: - `useMetadataClient.advisorySink.test.tsx`, new top-level `describe` (the real hook, factory, `MetadataClient`, `readSaveAdvisories` and `emitSaveAdvisories`; only `sonner` and `fetch` stubbed): - (a) `save('object', …)` in publish mode: resolves, warning titled "Saved"; - (b) `publish` and `publishDraft`: resolve, warning titled "Published"; - (c) `publishPackageDrafts`, the finding on one `published[]` element: resolves, warning titled "Published". - `MetadataService.saveAdvisories.test.ts`, new top-level `describe`: (d) `MetadataService.saveObject` through the adapter's `meta.saveItem` interceptor: resolves, event `door: 'save'`, `mode: 'publish'`, warning titled "Saved". Each pin asserts the warning tier (never `error`), and that the description carries `[object-storage-sync-refused] object "NAME" — MESSAGE` and the `hint`, verbatim. The check itself (the card's Expected bullet 2): the positions the claim measured hold on `40a9265ff`. The producer returns all six string keys with `path: ''`; `readSaveAdvisories` keeps a finding with an empty-string `path` (it checks `typeof`, not truthiness); `useMetadataClient` wires `onSaveAdvisory` into every console `MetadataClient`; `AdapterProvider` subscribes to the adapter's `meta.saveItem` interceptor; both render through `emitSaveAdvisories`. No console object-write door was found that does not reach a sink: the other `MetadataClient` constructions are the adapter's private one (a view draft save and view resets, not object writes, and drafts are never advised), `MetadataProvider`'s preview client (reads only), and the two `plugin-designer` pages (no console consumer). The only response schemas declaring `advisories` are the save, single-item publish and batch publish-drafts responses, all covered above. ## Tests Run on `da4bf8058` (the final head), from `ui/`: - `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/useMetadataClient.advisorySink.test.tsx packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts`: `Test Files 2 passed (2)`, `Tests 13 passed (13)`. - `pnpm --filter @object-ui/app-shell run type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`, which compiles the tests): exit 0. - `eslint` on the two files: exit 0. ### Ablations (each restored; `git diff HEAD` empty after each, blob equal to HEAD) Done through `scripts/ablation-replace.mjs`, which proved each mutation landed on disk. The `@object-ui/data-objectstack` import resolves to `src` through the `ui/vitest.config.mts` alias, so no rebuild was needed. | Ablation | Result | |:--|:--| | A1: `readSaveAdvisories` also requires a non-empty `path` (length greater than zero) | exactly the 5 new pins red, all 8 existing pins green (5 failed / 8 passed). The existing gate-finding fixtures carry a non-empty `path`, so only this shape can catch it | | A2: the console sink call `emitSaveAdvisories(…)` in `useMetadataClient` cut | the 4 new `MetadataClient` pins red, plus the 2 existing SAVE/PUBLISH connection pins (6 failed / 7 passed) | | A3: the adapter interceptor's emit cut (its condition made never true) | the new `saveItem` pin red, plus the 3 existing `MetadataService` advisory pins (4 failed / 9 passed) | The first attempt at A1 was a no-op: its anchor was a substring of its replacement, so the tool refused to run (anchor count 1 to 1) and restored the file. It was re-run with an anchor that does not overlap; the table above shows that second run. ## Acceptance notes - Out of scope, observed (from the claim; not changed here): the frame reads "the authoring check raised N advisory finding(s)", but this finding comes from the storage sync, not the authoring gate. That is wording, not behaviour. - The producer copy is a fixture. If the server's wording changes, the pins still pass (they compare the fixture's own strings), so they guard the console channel and not the server text. That matches the renderer contract: server prose is rendered verbatim and never pinned by the console. - The batch door reaches the console once #30693 lands server-side. The client half is pinned here already. ## Verification <details><summary>Derived gates: 26 of 26 run, all exit 0 (reconciled with --ran)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, then `--ran` with each exit code recorded: `Run reconciliation — 26 derived, 26 run, 0 NOT-MEASURED, 0 UNRUN`, `a DERIVED zero — all 26 recorded an exit code and none of them is 3`. These include `check:test-source-alias`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:type-check-coverage`, `check:type-check-debt`, `check:undeclared-dep-imports`, `check:published-files`, `check-sdui-manifest` (and `--self-test`), `check-comment-mask-corpus` and `pnpm --dir ui exec vitest run scripts/`, each exit 0. </details> <details><summary>Not run locally (left to CI)</summary> The whole `@object-ui/app-shell` suite (`Test UI`) and the repo-wide lint are left to CI. Locally I ran the two changed files plus the package type-check, which compiles the tests. `skip-changeset`: the diff touches only `*.test.ts(x)`, which no package's `files[]` ships. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWn6zhNa4LL7VB1rtL)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent adf6ee0 commit b309866

2 files changed

Lines changed: 147 additions & 0 deletions

File tree

‎ui/packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,52 @@ describe('MetadataService saves reach the shell advisory surface (#4237)', () =>
167167
expect(invalidate).toHaveBeenCalledWith('object:account');
168168
});
169169
});
170+
171+
/**
172+
* The storage-sync advisory, in the producer's EXACT shape (objectstack#30723).
173+
*
174+
* Copied verbatim from `objectStorageSyncRefusedNotice` in
175+
* `packages/metadata-protocol/src/protocol-registry-hydration.ts` (the server
176+
* half, objectstack#30635 / PR #30691), with `reason` fixed to the fallback the
177+
* producer prints for a driver error that carries no 4xx `status`; copied, not
178+
* imported, so the console takes no server dependency for a fixture. Its `path`
179+
* is EMPTY (the finding is about the whole object), and the adapter's
180+
* interceptor reads it through the same six-string-keys `readSaveAdvisories`
181+
* as the `MetadataClient` doors, so `''` must survive here too. The
182+
* `MetadataClient` doors' half of this pin lives in
183+
* `views/metadata-admin/useMetadataClient.advisorySink.test.tsx`.
184+
*/
185+
function storageSyncRefusedNotice(name: string) {
186+
const reason = 'the reason is in the server log';
187+
return {
188+
rule: 'object-storage-sync-refused',
189+
severity: 'warning' as const,
190+
where: `object "${name}"`,
191+
path: '',
192+
message:
193+
`object '${name}' was saved and is served, but its storage was NOT created or altered: `
194+
+ `the datasource refused its schema sync (${reason}). Until the sync runs, reads and writes `
195+
+ 'against it fail, or miss the columns this save added.',
196+
hint:
197+
"Resolve the datasource's refusal, then publish the object again (which re-runs the sync), "
198+
+ 'or restart the server, which syncs the storage of every registered object.',
199+
};
200+
}
201+
202+
describe('MetadataService object save shows a refused storage sync (objectstack#30723)', () => {
203+
it('the save resolves, and the warning says "Saved" with the server message and hint verbatim', async () => {
204+
const notice = storageSyncRefusedNotice('account');
205+
const { adapter, sink, events } = makeWiredAdapter({ ...CLEAN_BODY, advisories: [notice] });
206+
207+
await expect(new MetadataService(adapter).saveObject(ACCOUNT, ACCOUNT_FIELDS)).resolves.toBeUndefined();
208+
209+
expect(events).toHaveLength(1);
210+
expect(events[0]).toMatchObject({ type: 'object', name: 'account', door: 'save', mode: 'publish' });
211+
expect(sink.warning).toHaveBeenCalledTimes(1);
212+
expect(sink.error).not.toHaveBeenCalled();
213+
const [title, opts] = sink.warning.mock.calls[0]!;
214+
expect(title).toMatch(/^Saved\b/);
215+
expect(opts!.description).toContain(`[${notice.rule}] ${notice.where} — ${notice.message}`);
216+
expect(opts!.description).toContain(notice.hint);
217+
});
218+
});

‎ui/packages/app-shell/src/views/metadata-admin/useMetadataClient.advisorySink.test.tsx‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,101 @@ describe('useMetadataClient — an emitted advisory reaches the toast sink', ()
183183
expect(toast.warning).not.toHaveBeenCalled();
184184
});
185185
});
186+
187+
/**
188+
* The storage-sync advisory, in the producer's EXACT shape (objectstack#30723).
189+
*
190+
* Copied verbatim from `objectStorageSyncRefusedNotice` in
191+
* `packages/metadata-protocol/src/protocol-registry-hydration.ts` (the server
192+
* half, objectstack#30635 / PR #30691), with `reason` fixed to the fallback the
193+
* producer prints for a driver error that carries no 4xx `status`. Copied, not
194+
* imported: the console takes no dependency on the server package for a
195+
* fixture.
196+
*
197+
* What this shape has that the gate's findings above do not is an EMPTY `path`
198+
* (the finding is about the object as a whole). `readSaveAdvisories` keeps a
199+
* finding only when all six keys are strings, so `''` must survive it; a reader
200+
* that demanded a non-empty pointer would drop this one warning on every door
201+
* while every gate-finding pin stayed green.
202+
*/
203+
function storageSyncRefusedNotice(name: string) {
204+
const reason = 'the reason is in the server log';
205+
return {
206+
rule: 'object-storage-sync-refused',
207+
severity: 'warning' as const,
208+
where: `object "${name}"`,
209+
path: '',
210+
message:
211+
`object '${name}' was saved and is served, but its storage was NOT created or altered: `
212+
+ `the datasource refused its schema sync (${reason}). Until the sync runs, reads and writes `
213+
+ 'against it fail, or miss the columns this save added.',
214+
hint:
215+
"Resolve the datasource's refusal, then publish the object again (which re-runs the sync), "
216+
+ 'or restart the server, which syncs the storage of every registered object.',
217+
};
218+
}
219+
220+
describe('useMetadataClient — a refused object storage sync is shown on every object door (objectstack#30723)', () => {
221+
const NOTICE = storageSyncRefusedNotice('invoice');
222+
const INVOICE = { name: 'invoice', label: 'Invoice', fields: { amount: { type: 'number', label: 'Amount' } } };
223+
/** The single-item doors' 200: the object is published and served. */
224+
const SYNC_REFUSED_BODY = { success: true, version: 'v3', seq: 3, state: 'active', advisories: [NOTICE] };
225+
/** The batch door's 200: the finding rides its own `published[]` element. */
226+
const BATCH_BODY = {
227+
success: true,
228+
outcome: 'published',
229+
published: [{ type: 'object', name: 'invoice', advisories: [NOTICE] }],
230+
};
231+
232+
/** The warning the admin reads: the frame's verb, then the server's prose verbatim. */
233+
function expectStorageWarning(verb: RegExp) {
234+
const [title, options] = warningCall();
235+
expect(title).toMatch(verb);
236+
expect(toast.error).not.toHaveBeenCalled();
237+
expect(options?.description).toContain(`[${NOTICE.rule}] ${NOTICE.where} — ${NOTICE.message}`);
238+
expect(options?.description).toContain(NOTICE.hint);
239+
}
240+
241+
beforeEach(() => {
242+
vi.mocked(toast.error).mockClear();
243+
serverAnswers(SYNC_REFUSED_BODY);
244+
});
245+
246+
it('SAVE door, publish mode: the save resolves and the warning says "Saved"', async () => {
247+
const { result } = renderHook(() => useMetadataClient());
248+
249+
await expect(result.current.save('object', 'invoice', INVOICE)).resolves.toMatchObject({ success: true });
250+
251+
// Publish mode is the default: no `mode=draft` went on the wire.
252+
expect(String(fetchMock.mock.calls[0]![0])).not.toContain('mode=draft');
253+
expectStorageWarning(/^Saved\b/);
254+
});
255+
256+
it('PUBLISH door (`publish`): the promotion resolves and the warning says "Published"', async () => {
257+
const { result } = renderHook(() => useMetadataClient());
258+
259+
await expect(result.current.publish('object', 'invoice')).resolves.toMatchObject({ success: true });
260+
261+
expectStorageWarning(/^Published\b/);
262+
});
263+
264+
it('PUBLISH door (`publishDraft`): the promotion resolves and the warning says "Published"', async () => {
265+
const { result } = renderHook(() => useMetadataClient());
266+
267+
await expect(result.current.publishDraft('object', 'invoice')).resolves.toMatchObject({ success: true });
268+
269+
expectStorageWarning(/^Published\b/);
270+
});
271+
272+
it('BATCH door (`publishPackageDrafts`): one `published[]` element carries it, the warning says "Published"', async () => {
273+
serverAnswers(BATCH_BODY);
274+
const { result } = renderHook(() => useMetadataClient());
275+
276+
await expect(result.current.publishPackageDrafts('com.acme.billing')).resolves.toMatchObject({
277+
outcome: 'published',
278+
});
279+
280+
expect(String(fetchMock.mock.calls[0]![0])).toContain('/packages/com.acme.billing/publish-drafts');
281+
expectStorageWarning(/^Published\b/);
282+
});
283+
});

0 commit comments

Comments
 (0)