diff --git a/.changeset/object-extension-reaches-by-name-meta-read.md b/.changeset/object-extension-reaches-by-name-meta-read.md new file mode 100644 index 0000000000..7e02ee74b7 --- /dev/null +++ b/.changeset/object-extension-reaches-by-name-meta-read.md @@ -0,0 +1,61 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': minor +--- + +fix(metadata-protocol): an object extension reaches the by-name `/meta` read, not just the list (#7556) + +**Behaviour change, and it is a payload gaining fields.** `GET /meta/object/:name` +(and `?layers=true`, and the cached/compound spellings that delegate to the same +read) now serve an object's RESOLVED schema — the base layer with its +`objectExtensions` contributors folded on — where they previously served the base +layer alone. Any consumer of that route sees the extension's fields appear. +Deployments with no `objectExtensions` see a byte-identical payload; the fold is +applied only to a name something actually extends. + +Levels: `metadata-protocol` is `patch` — it restores the contract the route was +already specified to answer (`GET /meta/object` and the data plane both already +resolved the same way, and the divergence was the defect). `objectql` is `minor` +because it gains one additive public API, `SchemaRegistry.foldObjectExtendersOnto`. + +The defect: `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 happened to be immune, because ObjectQL's +`bridgeObjectsToMetadataService` seeds that service from `registry.getAllObjects()` +— bodies that are already folded — which is why this survived so long. + +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`. Not +cosmetic — the edit and new forms derive from the by-name response, so three +fields that a client could read and write through the API could never be set in +the UI. + +The fix folds the registry's `extend` contributors onto 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 deliberately left alone: it reports what +a tenant customised, and a code-declared extension is not that. + +Pinned as AGREEMENT rather than presence, in +`packages/rest/src/meta-object-extension-agreement.test.ts`: the by-name read and +the list read are both measured off real handlers over a real protocol over a +real registry, across four hosts that genuinely differ (artifact-ingested, +bridged in-process, no metadata service, and an object nothing extends), 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, which is the same defect one layer over. The +end-to-end proof on a real showcase over real HTTP is +`packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts`, +which boots the artifact path on purpose — the shared in-process harness cannot +see this bug. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 77d9f58ccc..bdc0ddb9bf 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4116,6 +4116,53 @@ export class ObjectStackProtocolImplementation implements throw metadataStoreUnavailableError(error); } + /** + * [#7556] Resolve an OBJECT body that came from the MetadataService into the + * object's resolved schema, by folding the registry's `extend` contributors + * onto it. + * + * The two readers of a single object — this file's by-name read and its + * layered view — consult {@link readItemFromMetadataService} BEFORE the + * SchemaRegistry, because that service is the HMR-fresh copy. For every + * other metadata type that ordering is free. For `object` it is not: an + * object's resolved schema is DEFINED (ADR-0029 D9.2 / D9.6) as a base layer + * with its `extend` contributors folded on, and the MetadataService copy is + * only the base layer. A deployment that ingests a compiled artifact + * (`artifactSource`, i.e. every sealed/served runtime) registers `objects` + * and `objectExtensions` into that service as SEPARATE collections, so the + * body this method receives is the owner's declaration with no extender in + * it. Serving it unfolded is what made the showcase's three + * `objectExtensions` fields readable through `GET /meta/object`, writable + * through the data API, and absent from `GET /meta/object/:name` — the read + * the edit and new forms derive from. + * + * The list read needs no counterpart: it reads `registry.listItems`, whose + * object branch resolves through the same fold, so it was never wrong. + * This method exists to make the two AGREE at their one point of + * divergence, not to give the by-name route a rule of its own. + * + * Applied ONLY to a MetadataService body. A registry-sourced body has + * already been folded, and the fold concatenates `validations`/`indexes` + * (see {@link SchemaRegistry.foldObjectExtendersOnto}), so applying it twice + * would duplicate both. + */ + private foldObjectExtendersFromRegistry(type: string, name: string, body: unknown): unknown { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (singular !== 'object') return body; + if (body === null || typeof body !== 'object') return body; + const registry = (this.engine as any)?.registry; + // Partial registry doubles in tests predate this method; a host that + // cannot fold answers exactly as it did before. + if (!registry || typeof registry.foldObjectExtendersOnto !== 'function') return body; + try { + return registry.foldObjectExtendersOnto(name, body); + } catch { + // The fold is a read over in-memory contributors; a failure here + // must not turn a served schema into a 5xx. + return body; + } + } + /** * [#5840] Read ONE item from the `metadata` service, keeping the ADR-0110 * D3 verdict instead of flattening it into `undefined`. @@ -4754,7 +4801,12 @@ export class ObjectStackProtocolImplementation implements request.packageId, ); if (fromService.data !== undefined && fromService.data !== null) { - item = fromService.data; + // [#7556] A layer, not a resolved schema — see + // {@link foldObjectExtendersFromRegistry}. No-op for every + // type but `object`, and for an object nothing extends. + item = this.foldObjectExtendersFromRegistry( + request.type, request.name, fromService.data, + ); } else if (fromService.degraded) { serviceDegraded = fromService; } @@ -4967,7 +5019,15 @@ export class ObjectStackProtocolImplementation implements request.packageId, ); if (fromService.data !== undefined && fromService.data !== null) { - code = fromService.data; + // [#7556] The CODE layer of an object is D9.6's "owner's + // declaration with its extenders folded on", so the + // MetadataService copy is its base, not the layer itself. + // `effective` is `overlay ?? code`, so an object with no + // overlay row — the ordinary shape — is corrected by this + // single fold on both layers the diagnostic reports. + code = this.foldObjectExtendersFromRegistry( + request.type, request.name, fromService.data, + ); } else if (fromService.degraded) { // [#5840] Kept, not swallowed — acted on after the registry // fallback below, which may still produce a real code layer. diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 88df81cb53..e8e6996ec4 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1461,7 +1461,19 @@ export class SchemaRegistry { * the same way rather than growing a second, drifting copy. */ private foldExtenders(contributors: ObjectContributor[], base: ObjectContributor): ServiceObject { - let merged = { ...base.definition }; + return this.foldExtendersOntoDefinition(contributors, base.definition); + } + + /** + * The fold itself, over a base DEFINITION rather than a base contributor, so + * {@link foldObjectExtendersOnto} can apply it to a body that never came + * from this registry without growing a second copy of the merge. + */ + private foldExtendersOntoDefinition( + contributors: ObjectContributor[], + baseDefinition: ServiceObject, + ): ServiceObject { + let merged = { ...baseDefinition }; for (const contrib of contributors) { if (contrib.ownership === 'extend') { merged = mergeObjectDefinitions(merged, contrib.definition); @@ -1470,6 +1482,44 @@ export class SchemaRegistry { return merged; } + /** + * [#7556] Fold this object's `extend` contributors onto a base body the + * CALLER supplies — the same fold {@link resolveObject} (D9.2) and + * {@link resolveOwnerLayer} (D9.6) apply, exposed for a base layer that did + * not come from this registry. + * + * Why this is public API rather than the protocol reaching for the + * contributor list: `GET /meta/object/:name` reaches an object body through a + * source this registry never sees — the copy `MetadataPlugin` registers into + * the `metadata` SERVICE when a deployment ingests a compiled artifact, where + * `objects` and `objectExtensions` are stored as SEPARATE collections. That + * body is ONE LAYER, and serving a layer as the resolved schema is what + * dropped every `objectExtensions` field from the by-name read (and from both + * layers of `?layers=true`) while `GET /meta/object` — which reads + * `resolveObject` — kept them. Two folds would re-open exactly that seam one + * level down, so there is one. + * + * Returns `base` untouched when nothing extends the name, so a caller may + * apply it unconditionally. + * + * NOT idempotent, by construction: {@link mergeObjectDefinitions} CONCATENATES + * `validations` and `indexes`, so folding an already-folded body would + * duplicate both. Callers must apply this only to a base that has not been + * through the fold — which is why the protocol applies it to the + * MetadataService body and never to a registry-resolved one. + */ + foldObjectExtendersOnto(name: string, base: T): T { + if (base === null || typeof base !== 'object') return base; + const fqn = this.resolveObjectKey(name); + if (fqn === undefined) return base; + const contributors = this.objectContributors.get(fqn); + if (!contributors || !contributors.some((c) => c.ownership === 'extend')) return base; + return this.foldExtendersOntoDefinition( + contributors, + base as unknown as ServiceObject, + ) as unknown as T; + } + /** * [ADR-0029 D9.6] The CODE-LAYER resolution of an object: the OWNER's * declaration with its extenders folded on, deliberately ignoring any tenant diff --git a/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts new file mode 100644 index 0000000000..3fec1eda04 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7556] The showcase's `objectExtensions` entry, read back through every +// `/meta` surface that serves an object schema — over real HTTP, on a stack +// booted the way a DEPLOYED runtime boots. +// +// `examples/app-showcase/src/data/extensions/account.extension.ts` contributes +// three fields to `showcase_account` (`loyalty_tier`, `linkedin_url`, +// `csat_score`) and its own docstring states the contract: they "show up on the +// Account form/list exactly as if they were authored inline". They did not. +// They were served by `GET /meta/object`, they round-tripped through the data +// API, and they were ABSENT from `GET /meta/object/showcase_account` and from +// both layers of `?layers=true` — which is the read the edit and new forms +// derive from, so three fields that persist through the API could never be set +// in the UI. +// +// WHY THIS FILE BOOTS ITS OWN STACK, and does not use `getSharedShowcase()`: +// the shared harness boots the stack in-process from the TypeScript config, and +// on that path ObjectQL's `bridgeObjectsToMetadataService` seeds the `metadata` +// service from `registry.getAllObjects()` — bodies that are ALREADY folded. The +// bug is invisible there, and measuring it on that harness reports a green that +// means nothing. A deployment instead ingests a COMPILED ARTIFACT +// (`artifactSource` — `objectstack serve`, sealed runtimes, the cloud), whose +// `objects` and `objectExtensions` are separate collections, so the service's +// copy of the object carries no extender. That is the boot reproduced here, and +// it is the one the defect was measured on. +// +// The unit-level agreement pin for the same defect is +// `packages/rest/src/meta-object-extension-agreement.test.ts`; this file is the +// end-to-end proof that the fold reaches a real showcase over real HTTP. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { writeBuildShapedArtifact } from './build-shaped-artifact.js'; + +/** Contributed by the extension ONLY — `showcase_account` declares none of them. */ +const EXTENSION_FIELDS = ['loyalty_tier', 'linkedin_url', 'csat_score']; + +function fieldNamesOf(item: unknown): string[] { + const fields = (item as { fields?: unknown } | null | undefined)?.fields; + if (!fields) return []; + const names = Array.isArray(fields) + ? (fields as Array<{ name?: unknown }>).map((f) => String(f?.name)) + : Object.keys(fields as Record); + return [...names].sort(); +} + +describe('dogfood: an object extension reaches every /meta read (#7556)', () => { + let stack: VerifyStack; + let token: string; + let tempDir: string; + + beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'os-7556-ext-')); + const artifactPath = join(tempDir, 'objectstack.json'); + // The real `objectstack build` lowering, not `JSON.stringify(stack)` — that + // drops callables silently and the artifact parses green carrying none of + // what it advertises (#6293). + writeBuildShapedArtifact(showcaseStack as unknown as Record, artifactPath); + + stack = await bootStack(showcaseStack, { + extraPlugins: [ + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + const listedFields = async (): Promise => { + const res = await stack.apiAs(token, 'GET', '/meta/object'); + expect(res.status).toBe(200); + const body: unknown = await res.json(); + const items = (Array.isArray(body) + ? body + : ((body as { items?: unknown[]; data?: unknown[] })?.items + ?? (body as { data?: unknown[] })?.data + ?? [])) as Array<{ name?: string }>; + return fieldNamesOf(items.find((o) => o?.name === 'showcase_account')); + }; + + it('the list read composes the extension — the premise every other case is measured against', async () => { + const listed = await listedFields(); + for (const field of EXTENSION_FIELDS) expect(listed).toContain(field); + }); + + it('the by-name read serves the same fields the list read does', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account'); + expect(res.status).toBe(200); + const body: any = await res.json(); + + // Agreement, not presence: pinning "contains loyalty_tier" would pass again + // the day this one route were special-cased, which is the same defect one + // layer over. Both sides are measured here, in this test. + expect(fieldNamesOf(body?.item)).toEqual(await listedFields()); + }); + + it('`?layers=true` resolves the object in BOTH layers it reports', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true'); + expect(res.status).toBe(200); + const body: any = await res.json(); + const listed = await listedFields(); + + // The issue's sharpest evidence was that the fields were missing from BOTH + // layers rather than folded into the wrong one — which is what pointed at + // layer resolution rather than REST plumbing. `code` is the owner's + // declaration with its extenders folded on (ADR-0029 D9.6); `effective` is + // `overlay ?? code`, and the showcase customises nothing, so both must + // carry the extension and both must equal the list read. + expect(fieldNamesOf(body?.code)).toEqual(listed); + expect(fieldNamesOf(body?.effective)).toEqual(listed); + // No tenant customisation exists, and an extension is not one: the overlay + // layer stays empty rather than being handed the extension to report. + expect(body?.overlay ?? null).toBeNull(); + }); + + it('an object nothing extends is unchanged — the fold is not applied to every payload', async () => { + const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_task'); + expect(res.status).toBe(200); + const body: any = await res.json(); + const served = fieldNamesOf(body?.item); + + // `showcase_task` has no `extend` contributor. If correcting three fields on + // one object had altered the shape of every object's payload, it would show + // here first. + expect(served.length).toBeGreaterThan(0); + for (const field of EXTENSION_FIELDS) expect(served).not.toContain(field); + }); + + it('the fields the forms can now show are the same ones the data API persists', async () => { + // The half that always worked, kept in the same file as the half that did + // not: the columns are real, so a form that cannot show them is the whole + // defect rather than a cosmetic gap. + const created = await stack.apiAs(token, 'POST', '/data/showcase_account', { + name: 'ext-meta-read-7556', + loyalty_tier: 'gold', + csat_score: 91, + }); + expect(created.status).toBe(201); + const createdBody: any = await created.json(); + const id = createdBody?.id; + expect(id).toBeTruthy(); + + const read = await stack.apiAs(token, 'GET', `/data/showcase_account/${id}`); + expect(read.status).toBe(200); + const readBody: any = await read.json(); + expect(readBody?.record?.loyalty_tier).toBe('gold'); + expect(readBody?.record?.csat_score).toBe(91); + }); +}); diff --git a/packages/rest/src/meta-object-extension-agreement.test.ts b/packages/rest/src/meta-object-extension-agreement.test.ts new file mode 100644 index 0000000000..34b1a98f64 --- /dev/null +++ b/packages/rest/src/meta-object-extension-agreement.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7556] The two producers of "what fields does this object have" must answer +// the same question the same way. +// +// The defect: `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). `GET /meta/object/:name` consults the `metadata` SERVICE +// first, because that copy is the HMR-fresh one, and served whatever it +// returned. For every other metadata type those two agree. For `object` they do +// not: a deployment booted from a compiled artifact (`artifactSource` — every +// sealed/served runtime, and `objectstack serve`) ingests `objects` and +// `objectExtensions` as SEPARATE collections, so the service's copy is the +// owner's declaration with no extender folded in. +// +// Measured on the showcase: the account extension's three fields +// (`loyalty_tier`, `linkedin_url`, `csat_score`) were present on the list read +// and on the data API round-trip, and absent from the by-name read and from +// BOTH layers of `?layers=true` — the read the edit and new forms derive from, +// so three fields that persist through the API could never be set in the UI. +// +// WHAT THIS FILE ASSERTS, and why it is shaped this way: it does NOT assert +// "the by-name route returns the extension fields". That assertion passes again +// the day someone hardcodes or special-cases that route, which is the same +// class of defect one layer over and is exactly how this bug would survive its +// own fix. It asserts AGREEMENT — the by-name read and the list read expose the +// SAME field set — with both sides MEASURED from the real producers in the same +// test: both through real `RestServer` handlers over a real +// `ObjectStackProtocolImplementation` over a real `SchemaRegistry`. Four hosts +// that genuinely differ (below) keep the agreement from holding vacuously, and +// the anti-vacuity case pins that they ARE discriminated. + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +/** The three fields the showcase's `objectExtensions` entry contributes. */ +const EXTENSION_FIELDS = ['loyalty_tier', 'linkedin_url', 'csat_score'] as const; + +/** The owner package's declaration — what a compiled artifact stores under `objects`. */ +const OWNER_DECLARATION = { + name: 'showcase_account', + label: 'Account', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + industry: { name: 'industry', label: 'Industry', type: 'text' }, + }, +}; + +/** What the artifact stores under `objectExtensions` — a SEPARATE collection. */ +const EXTENSION_DECLARATION = { + name: 'showcase_account', + label: 'Account (Success Overlay)', + fields: { + loyalty_tier: { name: 'loyalty_tier', label: 'Loyalty Tier', type: 'text' }, + linkedin_url: { name: 'linkedin_url', label: 'LinkedIn URL', type: 'url' }, + csat_score: { name: 'csat_score', label: 'CSAT Score', type: 'number' }, + }, +}; + +/** An object NOTHING extends — the control that keeps every other object's payload honest. */ +const UNEXTENDED_DECLARATION = { + name: 'showcase_task', + label: 'Task', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + }, +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * Field names off a served object body, tolerant of both shapes the wire uses + * (`fields` as a record, or as an array of `{name}`), so the comparison is + * about WHICH fields are served rather than about how they are spelled. + */ +function fieldNamesOf(item: unknown): string[] { + const fields = (item as { fields?: unknown } | null | undefined)?.fields; + if (!fields) return []; + const names = Array.isArray(fields) + ? (fields as Array<{ name?: unknown }>).map((f) => String(f?.name)) + : Object.keys(fields as Record); + return [...names].sort(); +} + +/** + * How this host's `metadata` service was populated — the ONE axis that made the + * two routes disagree, and the reason the hosts below are not variations on a + * theme. + */ +type ServiceMode = + /** Artifact ingest: `MetadataPlugin` registers the owner declaration, extensions live in their own collection. */ + | 'artifact' + /** In-process boot: ObjectQL's `bridgeObjectsToMetadataService` seeds the service from the MERGED registry. */ + | 'bridged' + /** No `metadata` service at all — the read falls through to the registry. */ + | 'absent'; + +interface Host { + /** Field names `GET /meta/object` serves for the object. */ + listed: string[]; + /** Field names `GET /meta/object/:name` serves (cached branch — the route default). */ + byName: string[]; + /** Field names the `code` layer of `?layers=true` serves. */ + layerCode: string[]; + /** Field names the `effective` layer of `?layers=true` serves. */ + layerEffective: string[]; + /** What the `metadata` service itself holds — the base the by-name read starts from. */ + serviceBody: string[]; + /** The registry's own resolved schema — what both routes are supposed to be reporting. */ + registryResolved: string[]; +} + +/** + * Boot a REST server over the REAL protocol over a REAL registry, and read + * BOTH routes off it. + * + * `object` selects which object this host registers; `serviceMode` selects how + * the `metadata` service was populated. The registry is always the real one, so + * the fold under test is the shipped fold and not a re-description of it. + */ +async function measure(opts: { + serviceMode: ServiceMode; + extended?: boolean; +}): Promise { + const extended = opts.extended !== false; + const declaration = extended ? OWNER_DECLARATION : UNEXTENDED_DECLARATION; + const objectName = declaration.name; + + const registry = new SchemaRegistry(); + registry.registerObject(clone(declaration) as never, 'showcase', undefined, 'own'); + if (extended) { + registry.registerObject( + clone(EXTENSION_DECLARATION) as never, 'showcase-success', undefined, 'extend', 210, + ); + } + + const engine = { + registry, + // No `sys_metadata` rows: this issue is a CODE-declared extension, and a + // tenant overlay row is a different layer with its own precedence. Both + // routes read this store, so it is held constant across every host. + find: async () => [], + findOne: async () => undefined, + }; + + const services = new Map(); + if (opts.serviceMode !== 'absent') { + // 'artifact' registers the OWNER declaration (what the compiled + // artifact's `objects` collection holds); 'bridged' registers what + // `getAllObjects()` returns, which is the merged body. This single + // difference is the whole reproduction. + const body = opts.serviceMode === 'artifact' + ? clone(declaration) + : registry.getObject(objectName); + services.set('metadata', { + get: async (type: string, name: string) => + (type === 'object' || type === 'objects') && name === objectName + ? clone(body) + : undefined, + }); + } + + const protocol = new ObjectStackProtocolImplementation( + engine as never, + () => services as Map, + ); + + const rest = new RestServer( + createMockServer() as never, + protocol as never, + { api: { requireAuth: false } } as never, + ); + (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = + async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const routes = rest.getRouteManager(); + + const run = async (path: string, params: Record, query: Record) => { + const entry = routes.get('GET', path); + if (!entry) throw new Error(`route not registered: ${path}`); + let body: unknown; + const res = { + status: () => res, + header: () => res, + json: (b: unknown) => { body = b; }, + send: (b: unknown) => { body = b; }, + } as unknown as Parameters[1]; + await entry.handler( + { params, query, headers: {}, method: 'GET', path } as unknown as Parameters[0], + res, + ); + return body as Record | undefined; + }; + + const listBody = await run('/api/v1/meta/:type', { type: 'object' }, {}); + const listItems = (Array.isArray(listBody) + ? listBody + : ((listBody?.items ?? listBody?.data ?? []) as unknown[])) as Array<{ name?: string }>; + const listed = listItems.find((o) => o?.name === objectName); + + const singleBody = await run('/api/v1/meta/:type/:name', { type: 'object', name: objectName }, {}); + const layeredBody = await run( + '/api/v1/meta/:type/:name', { type: 'object', name: objectName }, { layers: 'true' }, + ); + + const metadataService = services.get('metadata') as + { get(t: string, n: string): Promise } | undefined; + + return { + listed: fieldNamesOf(listed), + byName: fieldNamesOf(singleBody?.item), + layerCode: fieldNamesOf(layeredBody?.code), + layerEffective: fieldNamesOf(layeredBody?.effective), + serviceBody: metadataService + ? fieldNamesOf(await metadataService.get('object', objectName)) + : [], + registryResolved: fieldNamesOf(registry.getObject(objectName)), + }; +} + +describe('[#7556] the by-name and list reads of an object answer one question', () => { + it('agrees on an artifact-ingested host — where the by-name read used to drop every extension field', async () => { + const host = await measure({ serviceMode: 'artifact' }); + + // The symptom, measured: the list read really does compose the + // extension's fields here, so agreement cannot be reached by the list + // read quietly losing them too. + for (const field of EXTENSION_FIELDS) expect(host.listed).toContain(field); + + // The pin: whatever the list read serves, the by-name read serves. + // Before the fix this compared the folded set against the owner's + // declaration alone. + expect(host.byName).toEqual(host.listed); + // …and what they agree ON is the registry's resolved schema, so the + // agreement cannot be satisfied by both routes drifting together. + expect(host.byName).toEqual(host.registryResolved); + }); + + it('agrees on a bridged in-process host — the boot that always happened to work', async () => { + const host = await measure({ serviceMode: 'bridged' }); + + for (const field of EXTENSION_FIELDS) expect(host.listed).toContain(field); + expect(host.byName).toEqual(host.listed); + }); + + it('agrees on a host with no metadata service — the read falls through to the registry', async () => { + const host = await measure({ serviceMode: 'absent' }); + + for (const field of EXTENSION_FIELDS) expect(host.listed).toContain(field); + expect(host.byName).toEqual(host.listed); + }); + + it('agrees on an object nothing extends — and serves it with no extension field grafted on', async () => { + const host = await measure({ serviceMode: 'artifact', extended: false }); + + expect(host.byName).toEqual(host.listed); + // The other half of the fix: an object with no `extend` contributor must + // come back exactly as the registry resolves it — nothing grafted on. A + // fold that ran unconditionally, or that folded a contributor list it + // had not filtered, would be invisible to the three cases above and + // would have changed every object's payload to correct three fields. + expect(host.byName).toEqual(host.registryResolved); + expect(host.byName).toContain('title'); + for (const field of EXTENSION_FIELDS) expect(host.byName).not.toContain(field); + }); + + it('both layers of `?layers=true` resolve the object, not just the effective one', async () => { + const host = await measure({ serviceMode: 'artifact' }); + + // The issue's sharpest evidence: the fields were missing from BOTH + // layers, which is what pointed at layer resolution rather than REST + // plumbing. `code` is D9.6's "owner's declaration with its extenders + // folded on"; `effective` is `overlay ?? code`, so with no overlay row + // the two coincide — and both must carry the extension. + for (const field of EXTENSION_FIELDS) { + expect(host.layerCode).toContain(field); + expect(host.layerEffective).toContain(field); + } + expect(host.layerCode).toEqual(host.listed); + expect(host.layerEffective).toEqual(host.listed); + }); + + it('anti-vacuity: the hosts are genuinely discriminated, so the agreement cannot hold by emptiness', async () => { + const [artifact, bridged, absent, unextended] = await Promise.all([ + measure({ serviceMode: 'artifact' }), + measure({ serviceMode: 'bridged' }), + measure({ serviceMode: 'absent' }), + measure({ serviceMode: 'artifact', extended: false }), + ]); + + // 1. The artifact host's metadata service genuinely holds the UNFOLDED + // body — it is missing every extension field. Without this, the + // 'artifact' host could drift into being a second 'bridged' host + // (e.g. if the fixture started seeding it from the registry) and its + // agreement would then prove nothing about the defect. + for (const field of EXTENSION_FIELDS) { + expect(artifact.serviceBody).not.toContain(field); + expect(bridged.serviceBody).toContain(field); + } + expect(absent.serviceBody).toEqual([]); + + // 2. The three extended hosts really do serve the extension fields, and + // the unextended host really does not — so "byName === listed" is a + // statement about a non-empty, non-constant set on both sides. + for (const host of [artifact, bridged, absent]) { + for (const field of EXTENSION_FIELDS) expect(host.listed).toContain(field); + } + for (const field of EXTENSION_FIELDS) expect(unextended.listed).not.toContain(field); + + // 3. …and the two shapes are genuinely different sets, so a fold that + // collapsed every object to one answer would go red here. + expect(artifact.listed).not.toEqual(unextended.listed); + }); +});