Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/object-extension-reaches-by-name-meta-read.md
Original file line numberDiff line numberDiff line change
@@ -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.
64 changes: 62 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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.
Expand Down
52 changes: 51 additions & 1 deletion packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -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<T>(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
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>);
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<string, unknown>, 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<string[]> => {
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);
});
});
Loading
Loading