Skip to content

Commit adf6ee0

Browse files
fix(metadata-protocol): report a refused object-storage sync on the batch publish element (#30754)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DNBMwUTtGfWdmHXn1G2e37Z)_ Fixes #30693 domain:server#4 · os-elon-musk · session_016WV4czAsRaaqvLX3Tkidba Clause-②: no Metadata-protocol: none Claim: 6672619391 (amended 6672668925) · Branch: `claude/project-thread-dx78mn` · Changed lines: +165 / −9 (4 files; product code +18 / −6, the rest is pins and the changeset) ## 维护者速读 - 改了什么:Studio「发布整个应用」(`POST /api/v1/packages/:id/publish-drafts`)里某个对象的存储同步被数据源拒绝时,该对象在 `published[]` 里的那一项现在带上 `object-storage-sync-refused` 警告,与单项发布、发布模式保存(#30635)给的是同一条。 - 为什么改:之前整包发布照样回成功,表没建或没改,只有服务器日志知道;用户直到读写失败才发现。 - 风险与回滚:很小。状态码、成功标志和其他键都不变,发布不失败;同步正常的项不加 `advisories` 键(已钉)。回滚即撤 PR。 - 先看:`packages/metadata-protocol/src/protocol.ts` 里 `publishPackageDrafts` 第二阶段循环那一处 `eff.storageRefused`;`protocol-publish-drafts-advisories.test.ts` 末尾新 describe。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol-publish-drafts-advisories.test.ts`,预期 8 条全绿,其中 4 条是 #30693 的。 - 需要你做的:无,席位落地。 ## What changes - `publishPackageDrafts`, Phase 2: the element for each promoted item is now built as a local, pushed in the same place as before (element order unchanged), and after `runPublishSideEffects` answers, its `storageRefused` (the advisory `ensureObjectStorage` builds through `objectStorageSyncRefusedNotice`, added by #30691) is appended to that element's `advisories`. The builder is reused, not copied: the batch door reads the same `eff.storageRefused` field `publishMetaItem` reads. - The gate's own findings stay first; the sync advisory comes after them. The element's array is a copy of `p.advisories`, so the promotion's findings list is not mutated. - Omitted when empty, as before: an element whose sync ran (or a non-object type) carries no `advisories` key. - `published[].advisories` is already declared on `PublishPackageDraftsResponseSchema` as `RuntimeAuthoringIssueSchema[]`, so no spec change. - `ensureObjectStorage`'s docblock now names the batch door as the third consumer. ## Tests New describe `[#30693] publishPackageDrafts reports a refused object-storage sync on that object's element` in `packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts`. That file's stub engine gains a `syncObjectSchema` double (the #30635 fixture shape: a name in the refused set throws an undeclared error). Every case goes through the real `publishPackageDrafts`: - A batch of two objects and one flow, one object refused: `success: true`, `outcome: 'published'`, 3 published. Only the refused object's element carries one advisory (`rule`, `severity: 'warning'`, `path: ''`, `where: 'object "ticket"'`, the six `RuntimeAuthoringIssueSchema` keys). The undeclared driver text is withheld from the body and still in the server log. The sibling object and the flow have the key set `name, type, version`. - A declared (409) driver refusal is quoted on the element, as on the single-item doors. - A flow gate advisory and an object sync refusal in one batch each ride their own element. - CONTROL: a batch whose every sync runs has no `advisories` anywhere in `published`, and both objects were synced. ## Acceptance notes - Reproduced through the door by ablation on the fix commit `8d702f83`: guarding the new `if` to `false` (via `scripts/ablation-replace.mjs`) turns the three refusal pins red (`Tests 3 failed | 5 passed (8)`) and leaves the CONTROL and the four #9343 pins green. Restored: blob equals `HEAD`, `git diff HEAD` empty. The tests import `./protocol.js` from `src`, so no dist rebuild was involved. - The old per-element `published.push` happened before the side effects; the push stays there, so an item whose side effects throw is still reported as published (the metadata is live), unchanged. - `#30687` (codemod moving door bodies into facets) does not move `publishPackageDrafts`; read on its head `c2e3fa7f`. `#30709` and `#30713` touch other members of `protocol.ts`. Whichever lands second merges. ## Verification All on the final head `5a6e0a90` (the fix, the changeset, and a plain merge of `origin/main` at `f0ad1f79`). - `pnpm --filter @objectstack/metadata-protocol test` + `typecheck` under `os-verify-lock`: `VERDICT command-exit 0`, `Test Files 319 passed | 4 skipped (323)`, `Tests 22480 passed | 26 skipped (22506)`. No other package's public face changed (an internal loop and one docblock), so no consumer sweep. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 60 families. Each run with its exit code recorded before any pipe; `--ran` reconciliation: `60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED`, every one `exit 0`. - `check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3 (prerequisite: built dists) on the first pass and `0` after a whole-tree `turbo run build` (`119 successful, 119 total`). - `node scripts/check-nul-bytes.mjs`: exit 0. - Repo-wide scans (`pnpm lint`, `test:repo`) and every family outside the derivation are CI's. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czAsRaaqvLX3Tkidba --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6990b0d commit adf6ee0

4 files changed

Lines changed: 165 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): "publish whole app" now says when an object's storage sync was refused
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`POST /api/v1/packages/:id/publish-drafts` (Studio's "publish whole app") ran each published object's storage sync and dropped the answer, so a batch whose object table was not created or altered answered a plain success; only the server log said so. That object's `published[]` element now carries the same `object-storage-sync-refused` warning on its existing `advisories` that a publish-mode save and `POST /meta/object/NAME/publish` answer since #30635. The status and every other key are unchanged, the publish is not failed, and an element whose sync ran carries no `advisories` key.

‎packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts‎

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
* `publishPackageDrafts` — nothing on the gate path is stubbed.
3535
*/
3636

37-
import { describe, expect, it } from 'vitest';
37+
import { afterEach, describe, expect, it, vi } from 'vitest';
3838
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
3939
import { ObjectStackProtocolImplementation } from './protocol.js';
4040

@@ -87,8 +87,10 @@ function matchesMetadataWhere(r: Row, where: Record<string, unknown>): boolean {
8787
return true;
8888
}
8989

90-
function makeStubEngine() {
90+
function makeStubEngine(refusedSync: ReadonlySet<string> = new Set()) {
9191
const rows = new Map<string, Row>();
92+
// [#30693] Every object name the driver's schema sync was handed and ran.
93+
const syncedObjects: string[] = [];
9294
const historyRows: HistoryRow[] = [];
9395
let nextId = 0;
9496

@@ -161,6 +163,13 @@ function makeStubEngine() {
161163
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
162164
return cb(undefined, { owned: true });
163165
},
166+
// [#30693] A name in `refusedSync` is the driver refusing that
167+
// object's DDL — the #30635 fixture shape (an undeclared failure).
168+
syncObjectSchema: vi.fn(async (name: string) => {
169+
if (refusedSync.has(name)) throw new Error(`there is already an index named ${name}`);
170+
syncedObjects.push(name);
171+
return true;
172+
}),
164173
registry: {
165174
registerItem: () => {},
166175
registerObject: () => {},
@@ -171,7 +180,7 @@ function makeStubEngine() {
171180
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
172181
},
173182
};
174-
return { engine, rows, historyRows };
183+
return { engine, rows, historyRows, syncedObjects };
175184
}
176185

177186
const PKG = 'app.ops';
@@ -332,3 +341,128 @@ describe('publishPackageDrafts carries per-draft advisories on published[] eleme
332341
expect(JSON.stringify(res.failed)).not.toContain('advisories');
333342
});
334343
});
344+
345+
// ---------------------------------------------------------------------------
346+
// [#30693] A refused object-storage sync rides the object's element too.
347+
// ---------------------------------------------------------------------------
348+
// Since #30635 a publish-mode save and `POST /meta/:type/:name/publish` answer
349+
// a refused sync with one `warning` advisory (rule `object-storage-sync-refused`).
350+
// The batch door runs the same sync per promoted item in Phase 2 and used to
351+
// drop the answer: the element was pushed before the side effects ran, so
352+
// "publish whole app" answered a plain success while the object's table was
353+
// not created or altered. The same advisory now rides that object's
354+
// `published[]` element; the publish is not failed and every other element is
355+
// byte-unchanged.
356+
357+
const SYNC_REFUSED_RULE = 'object-storage-sync-refused';
358+
359+
/** An object body that clears the publish path's authoring gates (#8308 OWD, one field). */
360+
const objectBody = (name: string) => ({
361+
name,
362+
label: name,
363+
sharingModel: 'private',
364+
fields: { title: { type: 'text', label: 'Title' } },
365+
});
366+
367+
async function stageObjectDraft(protocol: ObjectStackProtocolImplementation, name: string): Promise<void> {
368+
await (protocol as any).saveMetaItem({
369+
type: 'object', name, item: objectBody(name), packageId: PKG, mode: 'draft',
370+
});
371+
}
372+
373+
describe('[#30693] publishPackageDrafts reports a refused object-storage sync on that object\'s element', () => {
374+
afterEach(() => {
375+
vi.restoreAllMocks();
376+
});
377+
378+
/** Quiets (and keeps) the #22985 operator line `ensureObjectStorage` logs. */
379+
const quietConsole = () => vi.spyOn(console, 'error').mockImplementation(() => {});
380+
381+
it('a batch with one refused sync answers success; exactly that element carries the warning, its siblings no key', async () => {
382+
const error = quietConsole();
383+
const { engine, syncedObjects } = makeStubEngine(new Set(['ticket']));
384+
const protocol = new ObjectStackProtocolImplementation(engine);
385+
await stageObjectDraft(protocol, 'ticket');
386+
await stageObjectDraft(protocol, 'invoice');
387+
await stageFlowDraft(protocol, 'bounded_purge', cleanFlow('bounded_purge'));
388+
389+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
390+
391+
// Status unchanged: the metadata is live, the publish is not failed.
392+
expect(res.failed).toEqual([]);
393+
expect(res).toMatchObject({ success: true, outcome: 'published', publishedCount: 3, failedCount: 0 });
394+
expect(syncedObjects).toEqual(['invoice']);
395+
396+
const byName = new Map(res.published.map((p) => [p.name, p]));
397+
const refused = byName.get('ticket')!;
398+
expect(refused.advisories).toHaveLength(1);
399+
expect(refused.advisories![0]).toMatchObject({
400+
rule: SYNC_REFUSED_RULE, severity: 'warning', path: '', where: 'object "ticket"',
401+
});
402+
expect(refused.advisories![0]!.message).toContain("'ticket'");
403+
// The single-item doors' element shape (#30635 reuses the gate's
404+
// `RuntimeAuthoringIssueSchema` keys), not a batch-only variant.
405+
expect(Object.keys(refused.advisories![0]!).sort())
406+
.toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']);
407+
// An undeclared driver failure is withheld from the 2xx body, as on
408+
// the single-item doors, and still reaches the server log whole.
409+
expect(refused.advisories![0]!.message).not.toContain('there is already an index');
410+
expect(error.mock.calls.some((args) => args[1] === 'there is already an index named ticket')).toBe(true);
411+
412+
for (const name of ['invoice', 'bounded_purge']) {
413+
const el = byName.get(name)!;
414+
expect('advisories' in el).toBe(false);
415+
expect(Object.keys(el).sort()).toEqual(['name', 'type', 'version']);
416+
}
417+
});
418+
419+
it('a DECLARED driver refusal (4xx) is quoted on the element, as on the single-item doors', async () => {
420+
quietConsole();
421+
const { engine } = makeStubEngine();
422+
engine.syncObjectSchema = vi.fn(async () => {
423+
throw Object.assign(new Error("Schema sync REFUSED for the existing table 'ticket': it holds 2 rows."), { status: 409 });
424+
});
425+
const protocol = new ObjectStackProtocolImplementation(engine);
426+
await stageObjectDraft(protocol, 'ticket');
427+
428+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
429+
430+
expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 });
431+
const [advisory, ...rest] = res.published[0]!.advisories ?? [];
432+
expect(rest).toEqual([]);
433+
expect(advisory?.rule).toBe(SYNC_REFUSED_RULE);
434+
expect(advisory?.message).toContain("Schema sync REFUSED for the existing table 'ticket': it holds 2 rows.");
435+
});
436+
437+
it('the gate\'s findings and the sync refusal ride one element together, gate findings first', async () => {
438+
quietConsole();
439+
const { engine } = makeStubEngine(new Set(['ticket']));
440+
const protocol = new ObjectStackProtocolImplementation(engine);
441+
await stageObjectDraft(protocol, 'ticket');
442+
await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge'));
443+
444+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
445+
446+
expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 });
447+
const byName = new Map(res.published.map((p) => [p.name, p]));
448+
// The flow's gate advisory is untouched by the object's refusal.
449+
expect(byName.get('nightly_purge')!.advisories!.map((a) => a.rule)).toEqual(['flow-multi-write-unfiltered']);
450+
expect(byName.get('ticket')!.advisories!.map((a) => a.rule)).toEqual([SYNC_REFUSED_RULE]);
451+
});
452+
453+
it('CONTROL: a batch whose every sync runs adds no `advisories` key anywhere', async () => {
454+
const { engine, syncedObjects } = makeStubEngine();
455+
const protocol = new ObjectStackProtocolImplementation(engine);
456+
await stageObjectDraft(protocol, 'ticket');
457+
await stageObjectDraft(protocol, 'invoice');
458+
459+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
460+
461+
expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 });
462+
expect([...syncedObjects].sort()).toEqual(['invoice', 'ticket']);
463+
for (const el of res.published) {
464+
expect(Object.keys(el).sort()).toEqual(['name', 'type', 'version']);
465+
}
466+
expect(JSON.stringify(res.published)).not.toContain('advisories');
467+
});
468+
});

‎packages/metadata-protocol/src/protocol-registry-hydration.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,9 +1431,10 @@ export class ProtocolRegistryHydrationFacet {
14311431
* success.
14321432
*
14331433
* [#30635] …and to the CALLER: a refused sync returns one `warning`
1434-
* advisory ({@link objectStorageSyncRefusedNotice}) that both doors running
1435-
* this sync put on the 2xx they answer (`saveMetaItem` in publish mode and
1436-
* `publishMetaItem`, through `runPublishSideEffects`). A sync that ran, or
1434+
* advisory ({@link objectStorageSyncRefusedNotice}) that every door running
1435+
* this sync puts on the 2xx it answers (`saveMetaItem` in publish mode, and
1436+
* through `runPublishSideEffects` `publishMetaItem` and, on the object's
1437+
* `published[]` element, [#30693] `publishPackageDrafts`). A sync that ran, or
14371438
* a type with no storage, returns `undefined`, so a clean answer is
14381439
* unchanged.
14391440
*/

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12472,13 +12472,14 @@ export class ObjectStackProtocolImplementation implements
1247212472
// discipline every advisory-carrying door follows: an advisory-free
1247312473
// element's bytes are unchanged, and absence means "nothing to
1247412474
// report", never "the gate did not run".
12475-
published.push({
12475+
const element: (typeof published)[number] = {
1247612476
// [#21207] Each element's version token is keyed, like the
1247712477
// single-item doors' (`receiptVersion`); `p.version` stays the
1247812478
// stored hash for everything internal.
1247912479
type: p.d.type, name: p.d.name, version: await this.receiptVersion(p.version),
12480-
...(p.advisories.length > 0 ? { advisories: p.advisories } : {}),
12481-
});
12480+
...(p.advisories.length > 0 ? { advisories: [...p.advisories] } : {}),
12481+
};
12482+
published.push(element);
1248212483
try {
1248312484
const eff = await this.runPublishSideEffects({
1248412485
singularType: p.singularType,
@@ -12488,6 +12489,16 @@ export class ObjectStackProtocolImplementation implements
1248812489
...(request.actor ? { actor: request.actor } : {}),
1248912490
skipSeedApply: true,
1249012491
});
12492+
// [#30693] A refused object-storage sync rides THIS element's
12493+
// `advisories`, the same `object-storage-sync-refused` warning
12494+
// `publishMetaItem` and a publish-mode save answer (#30635).
12495+
// The metadata is live and the element stays published; before
12496+
// this, only the server log said the table was not created or
12497+
// altered. Appended after the gate's findings, still
12498+
// omitted-when-empty: a clean sync adds no key.
12499+
if (eff.storageRefused) {
12500+
element.advisories = [...(element.advisories ?? []), eff.storageRefused];
12501+
}
1249112502
if (eff.materializeApplied) {
1249212503
materialize.any = true;
1249312504
materialize.inserted += eff.materializeApplied.inserted;

0 commit comments

Comments
 (0)