Skip to content

Commit e3c8ed0

Browse files
huangyiireneclaude
andauthored
fix(metadata-protocol): an object extension reaches the by-name /meta read, not just the list (#7556) (#8015)
`GET /meta/object` composes its objects from `SchemaRegistry.listItems('object')`, whose object branch resolves through `resolveObject` — a base layer with its `extend` contributors folded on (ADR-0029 D9.2). The by-name read consults the `metadata` SERVICE first, because that copy is the HMR-fresh one, and served whatever it returned. For every other metadata type the two agree. For `object` they did not: a deployment booted from a compiled artifact (`artifactSource` — `objectstack serve`, sealed runtimes, the cloud) ingests `objects` and `objectExtensions` as SEPARATE collections, so the service's copy is the owner's declaration with no extender in it. An in-process dev boot was immune, because ObjectQL's `bridgeObjectsToMetadataService` seeds that service from `registry.getAllObjects()` — bodies that are already folded. Measured on the showcase, whose account extension contributes three fields: they were served by the list read and persisted through the data API round-trip, and were absent from the by-name read and from BOTH layers of `?layers=true`. The edit and new forms derive from the by-name response, so three fields a client could read and write through the API could never be set in the UI. The fold is applied to the MetadataService body at the two places that adopt one: the by-name read and the `code` layer of the layered view (`effective` is `overlay ?? code`, so an object with no tenant overlay is corrected on both layers by that single fold). The fold itself is the registry's own — `foldObjectExtendersOnto` reuses the same private fold `resolveObject` and `resolveOwnerLayer` apply, rather than growing a second copy that could drift. The `overlay` layer is left alone: it reports what a tenant customised, and a code-declared extension is not that. An object nothing extends is returned untouched. Pinned as AGREEMENT rather than presence: both reads are measured off real handlers over a real protocol over a real registry, across four hosts that genuinely differ, plus an anti-vacuity case pinning that those hosts ARE discriminated. Asserting "the route returns the extension fields" would pass again the day someone special-cased that route. Claude-Session: https://claude.ai/code/session_01QjPSKbHaJ7aVtRPJeRgaf1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 477195c commit e3c8ed0

5 files changed

Lines changed: 666 additions & 3 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': minor
4+
---
5+
6+
fix(metadata-protocol): an object extension reaches the by-name `/meta` read, not just the list (#7556)
7+
8+
**Behaviour change, and it is a payload gaining fields.** `GET /meta/object/:name`
9+
(and `?layers=true`, and the cached/compound spellings that delegate to the same
10+
read) now serve an object's RESOLVED schema — the base layer with its
11+
`objectExtensions` contributors folded on — where they previously served the base
12+
layer alone. Any consumer of that route sees the extension's fields appear.
13+
Deployments with no `objectExtensions` see a byte-identical payload; the fold is
14+
applied only to a name something actually extends.
15+
16+
Levels: `metadata-protocol` is `patch` — it restores the contract the route was
17+
already specified to answer (`GET /meta/object` and the data plane both already
18+
resolved the same way, and the divergence was the defect). `objectql` is `minor`
19+
because it gains one additive public API, `SchemaRegistry.foldObjectExtendersOnto`.
20+
21+
The defect: `GET /meta/object` composes its objects from
22+
`SchemaRegistry.listItems('object')`, whose object branch resolves through
23+
`resolveObject` — a base layer with its `extend` contributors folded on (ADR-0029
24+
D9.2). The by-name read consults the `metadata` SERVICE first, because that copy
25+
is the HMR-fresh one, and served whatever it returned. For every other metadata
26+
type the two agree. For `object` they did not: a deployment booted from a
27+
compiled artifact (`artifactSource``objectstack serve`, sealed runtimes, the
28+
cloud) ingests `objects` and `objectExtensions` as SEPARATE collections, so the
29+
service's copy is the owner's declaration with no extender in it. An in-process
30+
dev boot happened to be immune, because ObjectQL's
31+
`bridgeObjectsToMetadataService` seeds that service from `registry.getAllObjects()`
32+
— bodies that are already folded — which is why this survived so long.
33+
34+
Measured on the showcase, whose account extension contributes three fields: they
35+
were served by the list read and persisted through the data API round-trip, and
36+
were absent from the by-name read and from BOTH layers of `?layers=true`. Not
37+
cosmetic — the edit and new forms derive from the by-name response, so three
38+
fields that a client could read and write through the API could never be set in
39+
the UI.
40+
41+
The fix folds the registry's `extend` contributors onto the MetadataService body
42+
at the two places that adopt one: the by-name read and the `code` layer of the
43+
layered view (`effective` is `overlay ?? code`, so an object with no tenant
44+
overlay is corrected on both layers by that single fold). The fold itself is the
45+
registry's own — `foldObjectExtendersOnto` reuses the same private fold
46+
`resolveObject` and `resolveOwnerLayer` apply, rather than growing a second copy
47+
that could drift. The `overlay` layer is deliberately left alone: it reports what
48+
a tenant customised, and a code-declared extension is not that.
49+
50+
Pinned as AGREEMENT rather than presence, in
51+
`packages/rest/src/meta-object-extension-agreement.test.ts`: the by-name read and
52+
the list read are both measured off real handlers over a real protocol over a
53+
real registry, across four hosts that genuinely differ (artifact-ingested,
54+
bridged in-process, no metadata service, and an object nothing extends), plus an
55+
anti-vacuity case pinning that those hosts ARE discriminated. Asserting "the
56+
route returns the extension fields" would pass again the day someone
57+
special-cased that route, which is the same defect one layer over. The
58+
end-to-end proof on a real showcase over real HTTP is
59+
`packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts`,
60+
which boots the artifact path on purpose — the shared in-process harness cannot
61+
see this bug.

packages/metadata-protocol/src/protocol.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4116,6 +4116,53 @@ export class ObjectStackProtocolImplementation implements
41164116
throw metadataStoreUnavailableError(error);
41174117
}
41184118

4119+
/**
4120+
* [#7556] Resolve an OBJECT body that came from the MetadataService into the
4121+
* object's resolved schema, by folding the registry's `extend` contributors
4122+
* onto it.
4123+
*
4124+
* The two readers of a single object — this file's by-name read and its
4125+
* layered view — consult {@link readItemFromMetadataService} BEFORE the
4126+
* SchemaRegistry, because that service is the HMR-fresh copy. For every
4127+
* other metadata type that ordering is free. For `object` it is not: an
4128+
* object's resolved schema is DEFINED (ADR-0029 D9.2 / D9.6) as a base layer
4129+
* with its `extend` contributors folded on, and the MetadataService copy is
4130+
* only the base layer. A deployment that ingests a compiled artifact
4131+
* (`artifactSource`, i.e. every sealed/served runtime) registers `objects`
4132+
* and `objectExtensions` into that service as SEPARATE collections, so the
4133+
* body this method receives is the owner's declaration with no extender in
4134+
* it. Serving it unfolded is what made the showcase's three
4135+
* `objectExtensions` fields readable through `GET /meta/object`, writable
4136+
* through the data API, and absent from `GET /meta/object/:name` — the read
4137+
* the edit and new forms derive from.
4138+
*
4139+
* The list read needs no counterpart: it reads `registry.listItems`, whose
4140+
* object branch resolves through the same fold, so it was never wrong.
4141+
* This method exists to make the two AGREE at their one point of
4142+
* divergence, not to give the by-name route a rule of its own.
4143+
*
4144+
* Applied ONLY to a MetadataService body. A registry-sourced body has
4145+
* already been folded, and the fold concatenates `validations`/`indexes`
4146+
* (see {@link SchemaRegistry.foldObjectExtendersOnto}), so applying it twice
4147+
* would duplicate both.
4148+
*/
4149+
private foldObjectExtendersFromRegistry(type: string, name: string, body: unknown): unknown {
4150+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
4151+
if (singular !== 'object') return body;
4152+
if (body === null || typeof body !== 'object') return body;
4153+
const registry = (this.engine as any)?.registry;
4154+
// Partial registry doubles in tests predate this method; a host that
4155+
// cannot fold answers exactly as it did before.
4156+
if (!registry || typeof registry.foldObjectExtendersOnto !== 'function') return body;
4157+
try {
4158+
return registry.foldObjectExtendersOnto(name, body);
4159+
} catch {
4160+
// The fold is a read over in-memory contributors; a failure here
4161+
// must not turn a served schema into a 5xx.
4162+
return body;
4163+
}
4164+
}
4165+
41194166
/**
41204167
* [#5840] Read ONE item from the `metadata` service, keeping the ADR-0110
41214168
* D3 verdict instead of flattening it into `undefined`.
@@ -4754,7 +4801,12 @@ export class ObjectStackProtocolImplementation implements
47544801
request.packageId,
47554802
);
47564803
if (fromService.data !== undefined && fromService.data !== null) {
4757-
item = fromService.data;
4804+
// [#7556] A layer, not a resolved schema — see
4805+
// {@link foldObjectExtendersFromRegistry}. No-op for every
4806+
// type but `object`, and for an object nothing extends.
4807+
item = this.foldObjectExtendersFromRegistry(
4808+
request.type, request.name, fromService.data,
4809+
);
47584810
} else if (fromService.degraded) {
47594811
serviceDegraded = fromService;
47604812
}
@@ -4967,7 +5019,15 @@ export class ObjectStackProtocolImplementation implements
49675019
request.packageId,
49685020
);
49695021
if (fromService.data !== undefined && fromService.data !== null) {
4970-
code = fromService.data;
5022+
// [#7556] The CODE layer of an object is D9.6's "owner's
5023+
// declaration with its extenders folded on", so the
5024+
// MetadataService copy is its base, not the layer itself.
5025+
// `effective` is `overlay ?? code`, so an object with no
5026+
// overlay row — the ordinary shape — is corrected by this
5027+
// single fold on both layers the diagnostic reports.
5028+
code = this.foldObjectExtendersFromRegistry(
5029+
request.type, request.name, fromService.data,
5030+
);
49715031
} else if (fromService.degraded) {
49725032
// [#5840] Kept, not swallowed — acted on after the registry
49735033
// fallback below, which may still produce a real code layer.

packages/objectql/src/registry.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1461,7 +1461,19 @@ export class SchemaRegistry {
14611461
* the same way rather than growing a second, drifting copy.
14621462
*/
14631463
private foldExtenders(contributors: ObjectContributor[], base: ObjectContributor): ServiceObject {
1464-
let merged = { ...base.definition };
1464+
return this.foldExtendersOntoDefinition(contributors, base.definition);
1465+
}
1466+
1467+
/**
1468+
* The fold itself, over a base DEFINITION rather than a base contributor, so
1469+
* {@link foldObjectExtendersOnto} can apply it to a body that never came
1470+
* from this registry without growing a second copy of the merge.
1471+
*/
1472+
private foldExtendersOntoDefinition(
1473+
contributors: ObjectContributor[],
1474+
baseDefinition: ServiceObject,
1475+
): ServiceObject {
1476+
let merged = { ...baseDefinition };
14651477
for (const contrib of contributors) {
14661478
if (contrib.ownership === 'extend') {
14671479
merged = mergeObjectDefinitions(merged, contrib.definition);
@@ -1470,6 +1482,44 @@ export class SchemaRegistry {
14701482
return merged;
14711483
}
14721484

1485+
/**
1486+
* [#7556] Fold this object's `extend` contributors onto a base body the
1487+
* CALLER supplies — the same fold {@link resolveObject} (D9.2) and
1488+
* {@link resolveOwnerLayer} (D9.6) apply, exposed for a base layer that did
1489+
* not come from this registry.
1490+
*
1491+
* Why this is public API rather than the protocol reaching for the
1492+
* contributor list: `GET /meta/object/:name` reaches an object body through a
1493+
* source this registry never sees — the copy `MetadataPlugin` registers into
1494+
* the `metadata` SERVICE when a deployment ingests a compiled artifact, where
1495+
* `objects` and `objectExtensions` are stored as SEPARATE collections. That
1496+
* body is ONE LAYER, and serving a layer as the resolved schema is what
1497+
* dropped every `objectExtensions` field from the by-name read (and from both
1498+
* layers of `?layers=true`) while `GET /meta/object` — which reads
1499+
* `resolveObject` — kept them. Two folds would re-open exactly that seam one
1500+
* level down, so there is one.
1501+
*
1502+
* Returns `base` untouched when nothing extends the name, so a caller may
1503+
* apply it unconditionally.
1504+
*
1505+
* NOT idempotent, by construction: {@link mergeObjectDefinitions} CONCATENATES
1506+
* `validations` and `indexes`, so folding an already-folded body would
1507+
* duplicate both. Callers must apply this only to a base that has not been
1508+
* through the fold — which is why the protocol applies it to the
1509+
* MetadataService body and never to a registry-resolved one.
1510+
*/
1511+
foldObjectExtendersOnto<T>(name: string, base: T): T {
1512+
if (base === null || typeof base !== 'object') return base;
1513+
const fqn = this.resolveObjectKey(name);
1514+
if (fqn === undefined) return base;
1515+
const contributors = this.objectContributors.get(fqn);
1516+
if (!contributors || !contributors.some((c) => c.ownership === 'extend')) return base;
1517+
return this.foldExtendersOntoDefinition(
1518+
contributors,
1519+
base as unknown as ServiceObject,
1520+
) as unknown as T;
1521+
}
1522+
14731523
/**
14741524
* [ADR-0029 D9.6] The CODE-LAYER resolution of an object: the OWNER's
14751525
* declaration with its extenders folded on, deliberately ignoring any tenant
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#7556] The showcase's `objectExtensions` entry, read back through every
4+
// `/meta` surface that serves an object schema — over real HTTP, on a stack
5+
// booted the way a DEPLOYED runtime boots.
6+
//
7+
// `examples/app-showcase/src/data/extensions/account.extension.ts` contributes
8+
// three fields to `showcase_account` (`loyalty_tier`, `linkedin_url`,
9+
// `csat_score`) and its own docstring states the contract: they "show up on the
10+
// Account form/list exactly as if they were authored inline". They did not.
11+
// They were served by `GET /meta/object`, they round-tripped through the data
12+
// API, and they were ABSENT from `GET /meta/object/showcase_account` and from
13+
// both layers of `?layers=true` — which is the read the edit and new forms
14+
// derive from, so three fields that persist through the API could never be set
15+
// in the UI.
16+
//
17+
// WHY THIS FILE BOOTS ITS OWN STACK, and does not use `getSharedShowcase()`:
18+
// the shared harness boots the stack in-process from the TypeScript config, and
19+
// on that path ObjectQL's `bridgeObjectsToMetadataService` seeds the `metadata`
20+
// service from `registry.getAllObjects()` — bodies that are ALREADY folded. The
21+
// bug is invisible there, and measuring it on that harness reports a green that
22+
// means nothing. A deployment instead ingests a COMPILED ARTIFACT
23+
// (`artifactSource` — `objectstack serve`, sealed runtimes, the cloud), whose
24+
// `objects` and `objectExtensions` are separate collections, so the service's
25+
// copy of the object carries no extender. That is the boot reproduced here, and
26+
// it is the one the defect was measured on.
27+
//
28+
// The unit-level agreement pin for the same defect is
29+
// `packages/rest/src/meta-object-extension-agreement.test.ts`; this file is the
30+
// end-to-end proof that the fold reaches a real showcase over real HTTP.
31+
32+
import { mkdtempSync, rmSync } from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join } from 'node:path';
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import showcaseStack from '@objectstack/example-showcase';
38+
import { bootStack, type VerifyStack } from '@objectstack/verify';
39+
import { MetadataPlugin } from '@objectstack/metadata';
40+
import { writeBuildShapedArtifact } from './build-shaped-artifact.js';
41+
42+
/** Contributed by the extension ONLY — `showcase_account` declares none of them. */
43+
const EXTENSION_FIELDS = ['loyalty_tier', 'linkedin_url', 'csat_score'];
44+
45+
function fieldNamesOf(item: unknown): string[] {
46+
const fields = (item as { fields?: unknown } | null | undefined)?.fields;
47+
if (!fields) return [];
48+
const names = Array.isArray(fields)
49+
? (fields as Array<{ name?: unknown }>).map((f) => String(f?.name))
50+
: Object.keys(fields as Record<string, unknown>);
51+
return [...names].sort();
52+
}
53+
54+
describe('dogfood: an object extension reaches every /meta read (#7556)', () => {
55+
let stack: VerifyStack;
56+
let token: string;
57+
let tempDir: string;
58+
59+
beforeAll(async () => {
60+
tempDir = mkdtempSync(join(tmpdir(), 'os-7556-ext-'));
61+
const artifactPath = join(tempDir, 'objectstack.json');
62+
// The real `objectstack build` lowering, not `JSON.stringify(stack)` — that
63+
// drops callables silently and the artifact parses green carrying none of
64+
// what it advertises (#6293).
65+
writeBuildShapedArtifact(showcaseStack as unknown as Record<string, unknown>, artifactPath);
66+
67+
stack = await bootStack(showcaseStack, {
68+
extraPlugins: [
69+
new MetadataPlugin({
70+
rootDir: tempDir,
71+
watch: false,
72+
artifactWatch: false,
73+
registerSystemObjects: false,
74+
artifactSource: { mode: 'local-file', path: artifactPath },
75+
}),
76+
],
77+
});
78+
token = await stack.signIn();
79+
}, 180_000);
80+
81+
afterAll(async () => {
82+
await stack?.stop();
83+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
84+
});
85+
86+
const listedFields = async (): Promise<string[]> => {
87+
const res = await stack.apiAs(token, 'GET', '/meta/object');
88+
expect(res.status).toBe(200);
89+
const body: unknown = await res.json();
90+
const items = (Array.isArray(body)
91+
? body
92+
: ((body as { items?: unknown[]; data?: unknown[] })?.items
93+
?? (body as { data?: unknown[] })?.data
94+
?? [])) as Array<{ name?: string }>;
95+
return fieldNamesOf(items.find((o) => o?.name === 'showcase_account'));
96+
};
97+
98+
it('the list read composes the extension — the premise every other case is measured against', async () => {
99+
const listed = await listedFields();
100+
for (const field of EXTENSION_FIELDS) expect(listed).toContain(field);
101+
});
102+
103+
it('the by-name read serves the same fields the list read does', async () => {
104+
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account');
105+
expect(res.status).toBe(200);
106+
const body: any = await res.json();
107+
108+
// Agreement, not presence: pinning "contains loyalty_tier" would pass again
109+
// the day this one route were special-cased, which is the same defect one
110+
// layer over. Both sides are measured here, in this test.
111+
expect(fieldNamesOf(body?.item)).toEqual(await listedFields());
112+
});
113+
114+
it('`?layers=true` resolves the object in BOTH layers it reports', async () => {
115+
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true');
116+
expect(res.status).toBe(200);
117+
const body: any = await res.json();
118+
const listed = await listedFields();
119+
120+
// The issue's sharpest evidence was that the fields were missing from BOTH
121+
// layers rather than folded into the wrong one — which is what pointed at
122+
// layer resolution rather than REST plumbing. `code` is the owner's
123+
// declaration with its extenders folded on (ADR-0029 D9.6); `effective` is
124+
// `overlay ?? code`, and the showcase customises nothing, so both must
125+
// carry the extension and both must equal the list read.
126+
expect(fieldNamesOf(body?.code)).toEqual(listed);
127+
expect(fieldNamesOf(body?.effective)).toEqual(listed);
128+
// No tenant customisation exists, and an extension is not one: the overlay
129+
// layer stays empty rather than being handed the extension to report.
130+
expect(body?.overlay ?? null).toBeNull();
131+
});
132+
133+
it('an object nothing extends is unchanged — the fold is not applied to every payload', async () => {
134+
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_task');
135+
expect(res.status).toBe(200);
136+
const body: any = await res.json();
137+
const served = fieldNamesOf(body?.item);
138+
139+
// `showcase_task` has no `extend` contributor. If correcting three fields on
140+
// one object had altered the shape of every object's payload, it would show
141+
// here first.
142+
expect(served.length).toBeGreaterThan(0);
143+
for (const field of EXTENSION_FIELDS) expect(served).not.toContain(field);
144+
});
145+
146+
it('the fields the forms can now show are the same ones the data API persists', async () => {
147+
// The half that always worked, kept in the same file as the half that did
148+
// not: the columns are real, so a form that cannot show them is the whole
149+
// defect rather than a cosmetic gap.
150+
const created = await stack.apiAs(token, 'POST', '/data/showcase_account', {
151+
name: 'ext-meta-read-7556',
152+
loyalty_tier: 'gold',
153+
csat_score: 91,
154+
});
155+
expect(created.status).toBe(201);
156+
const createdBody: any = await created.json();
157+
const id = createdBody?.id;
158+
expect(id).toBeTruthy();
159+
160+
const read = await stack.apiAs(token, 'GET', `/data/showcase_account/${id}`);
161+
expect(read.status).toBe(200);
162+
const readBody: any = await read.json();
163+
expect(readBody?.record?.loyalty_tier).toBe('gold');
164+
expect(readBody?.record?.csat_score).toBe(91);
165+
});
166+
});

0 commit comments

Comments
 (0)