Skip to content

Commit 8eb5d8b

Browse files
huangyiireneclaude
andauthored
fix(metadata-protocol): draft preview no longer reports itself invalid over its own _draft badge (#8179)
* fix(metadata-protocol): draft-preview diagnostics must not judge the injected _draft badge Fixes #7656 * chore: changeset for #7656 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5e09b2 commit 8eb5d8b

3 files changed

Lines changed: 235 additions & 15 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a draft preview no longer reports itself invalid because of its own `_draft` badge (#7656)
6+
7+
`GET /api/v1/meta/<type>/<name>?preview=draft` answered with `_diagnostics.valid:
8+
false` and *"Unrecognized key(s) on this object: `_draft`"* for drafts that were
9+
perfectly valid — the read stamped `_draft:true` onto the item so the console
10+
could badge it, then validated the item **with that key still on it** against a
11+
closed schema. The verdict was about the reader, not the document, and it reached
12+
both exits: the single-item preview read and the draft overlay in the list.
13+
14+
`computeMetadataDiagnostics` now removes every key on the shared
15+
`METADATA_READ_DECORATIONS` list before its re-parse, instead of the private
16+
one-key copy it carried (which removed `_diagnostics` only, and predated `_draft`
17+
joining that list). That list exists precisely so the read path's own annotations
18+
cannot be mistaken for document content by anything that re-parses a served
19+
document — the write path's verbatim persist (#4326) and the cold-boot flow bind
20+
(cloud#971) are the other two consumers; read-time diagnostics are the third.
21+
22+
The item schema is **unchanged and still closed**: `_draft` remains rejected by
23+
name when it appears in a stored body, which is what keeps the write-path strip
24+
load-bearing. Only the reader stopped feeding its own badge to it.
25+
26+
Genuinely invalid drafts are unaffected — they still read back `valid:false` with
27+
their own errors, on both exits.

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*/
2525

2626
import type { z } from 'zod';
27-
import { getMetadataTypeSchema } from '@objectstack/spec/kernel';
27+
import { getMetadataTypeSchema, stripReadDecorations } from '@objectstack/spec/kernel';
2828
import type { MetadataValidationResult } from '@objectstack/spec/kernel';
2929
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
3030
// [#5598] The READ path's share of the #5364 expansion. `zodIssuesToMetadataIssues`
@@ -67,12 +67,28 @@ export function computeMetadataDiagnostics(
6767
};
6868
}
6969

70-
// Strip our own decoration before re-validating so it never becomes
71-
// a false-positive "unrecognized_keys" failure on schemas that grow
72-
// a `.strict()` mode in the future.
73-
const candidate = '_diagnostics' in (item as Record<string, unknown>)
74-
? stripDiagnostics(item as Record<string, unknown>)
75-
: item;
70+
// [#7656] Strip EVERY read decoration — the shared
71+
// `METADATA_READ_DECORATIONS` list — before re-validating, not just the
72+
// `_diagnostics` key this function stamps itself.
73+
//
74+
// This is a re-parse of a SERVED document in exactly the sense the module
75+
// header of `spec/kernel/metadata-read-decorations.ts` means, so it is the
76+
// third consumer of that list (after the write path's verbatim persist and
77+
// the cold-boot flow bind) and must read it rather than keep a private
78+
// one-key copy. The private copy predated `_draft` joining the list, and
79+
// the schemas being closed since #4001 turned that gap into a verdict about
80+
// the READER: `?preview=draft` stamps `_draft:true` on the item (both the
81+
// single-item exit and the list overlay) and then decorates it, so the
82+
// strict schema rejected our own badge BY NAME and every valid draft came
83+
// back `valid:false / unrecognized_keys: ["_draft"]`.
84+
//
85+
// ⛔ The item schema is NOT the thing to loosen here: `_draft` is not a
86+
// document key and must stay rejected when it appears in a stored body. It
87+
// is the response's badge, which is precisely what the decoration list
88+
// says. Same class as #6810 (`indexed`), different remedy — that key did
89+
// not belong on the served body at all and left at its injection site,
90+
// whereas this one is read by the UI and belongs on the response.
91+
const candidate = stripReadDecorations(item);
7692

7793
const parsed = (schema as z.ZodTypeAny).safeParse(candidate);
7894
if (parsed.success) {
@@ -102,12 +118,6 @@ export function computeMetadataDiagnostics(
102118
return { valid: false, errors };
103119
}
104120

105-
function stripDiagnostics(item: Record<string, unknown>): Record<string, unknown> {
106-
const { _diagnostics: _drop, ...rest } = item;
107-
void _drop;
108-
return rest;
109-
}
110-
111121
/**
112122
* Attach `_diagnostics` to a single metadata item. Returns the item
113123
* unchanged when no diagnostics could be computed (unknown type) or

packages/metadata-protocol/src/protocol.read-decorations.test.ts

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ import { describe, expect, it } from 'vitest';
3131
// #5619 sank the two predicates into a package both sides already depend on.
3232
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
3333
import { FlowSchema } from '@objectstack/spec/automation';
34-
import { METADATA_READ_DECORATIONS } from '@objectstack/spec/kernel';
35-
import { ObjectStackProtocolImplementation, stripReadDecorations } from './index.js';
34+
import { METADATA_READ_DECORATIONS, getMetadataTypeSchema } from '@objectstack/spec/kernel';
35+
import {
36+
ObjectStackProtocolImplementation,
37+
computeMetadataDiagnostics,
38+
stripReadDecorations,
39+
} from './index.js';
3640

3741
interface Row {
3842
id: string;
@@ -326,3 +330,182 @@ describe('a served document survives its own (closed) schema — cloud#971', ()
326330
).toEqual([]);
327331
});
328332
});
333+
334+
/**
335+
* #7656 — the read must not judge its own badge.
336+
*
337+
* The THIRD consumer of the same invariant, and the one where the served
338+
* document never leaves the response: `decorateMetadataItem` re-parses the item
339+
* to compute `_diagnostics`, which is a re-parse of a served document in exactly
340+
* the sense the module header of `spec/kernel/metadata-read-decorations.ts`
341+
* means. It stripped `_diagnostics` (its own key, by hand) and nothing else, so
342+
* a `?preview=draft` read — which stamps `_draft:true` BEFORE decorating, on
343+
* both exits — validated the badge it had just added against a closed schema
344+
* and answered `_diagnostics.valid:false / unrecognized_keys: ["_draft"]` for a
345+
* perfectly valid draft. The verdict was about the reader, not the document.
346+
*
347+
* Same class as #6810 (`indexed`, rejected by name on a served object) but not
348+
* the same fix: `indexed` did not belong on the served body at all and was
349+
* removed at the injection site, whereas `_draft` is the preview badge the UI
350+
* reads — it belongs on the RESPONSE and is already a declared member of
351+
* `METADATA_READ_DECORATIONS`. So this closes where the list is consumed, not
352+
* where the badge is stamped.
353+
*
354+
* The anti-vacuity cases are the point of this block: "no `_draft` complaint"
355+
* is also satisfied by a read that stopped computing diagnostics at all, which
356+
* would be a strictly worse regression wearing a green test. Each side pairs a
357+
* valid draft (must be `valid:true`) with a genuinely broken one (must still be
358+
* `valid:false`, naming its OWN defect).
359+
*/
360+
describe('draft preview diagnostics do not judge the injected `_draft` badge (#7656)', () => {
361+
/** The symptom, verbatim from the card: a complaint naming `_draft`. */
362+
const draftKeyComplaints = (diagnostics: any): string[] =>
363+
(diagnostics?.errors ?? [])
364+
.map((e: { message?: string }) => String(e?.message ?? ''))
365+
.filter((m: string) => m.includes('_draft'));
366+
367+
/**
368+
* Seed a stored draft row directly. The save path refuses an invalid body
369+
* with 422, so a genuinely-broken draft cannot be authored through
370+
* `saveMetaItem` — which is the whole reason read-time diagnostics exist:
371+
* they badge rows that are already in the table (authored before a schema
372+
* tightened, or written by the ADR-0033 AI apply loop).
373+
*/
374+
const seedDraft = async (engine: any, name: string, body: unknown) => {
375+
await engine.insert('sys_metadata', {
376+
type: 'object',
377+
name,
378+
organization_id: null,
379+
package_id: null,
380+
state: 'draft',
381+
metadata: JSON.stringify(body),
382+
});
383+
};
384+
385+
/** Valid except for one deliberately-planted defect: `type` is not a field type. */
386+
const brokenBody = (name: string) => ({
387+
name,
388+
label: 'Broken',
389+
fields: { amount: { type: 'not_a_real_field_type', label: 'Amount' } },
390+
});
391+
392+
describe('single-item read (`getMetaItem`, previewDrafts)', () => {
393+
it('a valid draft reads back `_diagnostics.valid:true`', async () => {
394+
const { engine } = makeStubEngine();
395+
const protocol = new ObjectStackProtocolImplementation(engine);
396+
await protocol.saveMetaItem({
397+
type: 'object', name: 'crm_quote', item: objectBody('crm_quote'), mode: 'draft',
398+
});
399+
400+
const served: any = (await protocol.getMetaItem({
401+
type: 'object', name: 'crm_quote', previewDrafts: true,
402+
})).item;
403+
404+
expect(served._draft, 'precondition — the preview read badges').toBe(true);
405+
expect(draftKeyComplaints(served._diagnostics)).toEqual([]);
406+
expect(
407+
served._diagnostics.valid,
408+
`draft preview reported invalid: ${JSON.stringify(served._diagnostics?.errors)}`,
409+
).toBe(true);
410+
});
411+
412+
it('a genuinely broken draft still reports its OWN error (anti-vacuity)', async () => {
413+
const { engine } = makeStubEngine();
414+
const protocol = new ObjectStackProtocolImplementation(engine);
415+
await seedDraft(engine, 'crm_broken', brokenBody('crm_broken'));
416+
417+
const served: any = (await protocol.getMetaItem({
418+
type: 'object', name: 'crm_broken', previewDrafts: true,
419+
})).item;
420+
421+
expect(served._draft, 'precondition — the preview read badges').toBe(true);
422+
// Still computed, still false — the fix must not silence the path.
423+
expect(served._diagnostics.valid).toBe(false);
424+
expect(served._diagnostics.errors?.length).toBeGreaterThan(0);
425+
// …and false for the DOCUMENT's reason, not for the reader's badge.
426+
expect(draftKeyComplaints(served._diagnostics)).toEqual([]);
427+
expect(
428+
JSON.stringify(served._diagnostics.errors),
429+
'the real defect must still be named',
430+
).toContain('amount');
431+
});
432+
});
433+
434+
describe('list overlay (`getMetaItems`, previewDrafts)', () => {
435+
/** The overlaid draft entry for `name`, as the Studio list receives it. */
436+
const listed = async (protocol: any, name: string) => {
437+
const res: any = await protocol.getMetaItems({ type: 'object', previewDrafts: true });
438+
const items: any[] = Array.isArray(res) ? res : (res?.items ?? []);
439+
const served = items.find((i) => i?.name === name);
440+
expect(served, `getMetaItems('object') overlaid the draft ${name}`).toBeDefined();
441+
return served;
442+
};
443+
444+
it('a valid draft reads back `_diagnostics.valid:true`', async () => {
445+
const { engine } = makeStubEngine();
446+
const protocol = new ObjectStackProtocolImplementation(engine);
447+
await protocol.saveMetaItem({
448+
type: 'object', name: 'crm_quote', item: objectBody('crm_quote'), mode: 'draft',
449+
});
450+
451+
const served = await listed(protocol, 'crm_quote');
452+
expect(served._draft, 'precondition — the overlay badges').toBe(true);
453+
expect(draftKeyComplaints(served._diagnostics)).toEqual([]);
454+
expect(
455+
served._diagnostics.valid,
456+
`draft overlay reported invalid: ${JSON.stringify(served._diagnostics?.errors)}`,
457+
).toBe(true);
458+
});
459+
460+
it('a genuinely broken draft still reports its OWN error (anti-vacuity)', async () => {
461+
const { engine } = makeStubEngine();
462+
const protocol = new ObjectStackProtocolImplementation(engine);
463+
await seedDraft(engine, 'crm_broken', brokenBody('crm_broken'));
464+
465+
const served = await listed(protocol, 'crm_broken');
466+
expect(served._draft, 'precondition — the overlay badges').toBe(true);
467+
expect(served._diagnostics.valid).toBe(false);
468+
expect(served._diagnostics.errors?.length).toBeGreaterThan(0);
469+
expect(draftKeyComplaints(served._diagnostics)).toEqual([]);
470+
expect(
471+
JSON.stringify(served._diagnostics.errors),
472+
'the real defect must still be named',
473+
).toContain('amount');
474+
});
475+
});
476+
477+
describe('the verdict is computed from the list, and the schema stays closed', () => {
478+
it('every declared read decoration is invisible to the verdict (drift guard)', () => {
479+
// The mirror of the cloud#971 drift guard above, one layer down: a
480+
// FOURTH decoration added to `METADATA_READ_DECORATIONS` must not
481+
// have to remember this consumer. It fails here, on a unit, instead
482+
// of as `valid:false` on somebody's badge.
483+
const body = objectBody('crm_invoice');
484+
expect(computeMetadataDiagnostics('object', body)?.valid).toBe(true);
485+
486+
for (const key of METADATA_READ_DECORATIONS) {
487+
const verdict = computeMetadataDiagnostics('object', { ...body, [key]: true });
488+
expect(
489+
verdict?.valid,
490+
`read decoration \`${key}\` leaked into the verdict: `
491+
+ `${JSON.stringify(verdict?.errors)}`,
492+
).toBe(true);
493+
}
494+
});
495+
496+
it('the object schema itself still rejects `_draft` — only the strip moved', () => {
497+
// ⛔ The remedy is NOT a looser item schema. `_draft` is a response
498+
// badge; a STORED body carrying it is a polluted row and must keep
499+
// failing by name, which is what makes the #4326 write-path strip
500+
// load-bearing rather than cosmetic.
501+
const schema = getMetadataTypeSchema('object');
502+
expect(schema, 'precondition — `object` has a registered schema').toBeDefined();
503+
504+
const parsed = (schema as any).safeParse({ ...objectBody('crm_invoice'), _draft: true });
505+
expect(parsed.success, 'the closed schema must still reject the badge').toBe(false);
506+
expect(
507+
parsed.error.issues.some((i: { code: string }) => i.code === 'unrecognized_keys'),
508+
).toBe(true);
509+
});
510+
});
511+
});

0 commit comments

Comments
 (0)