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
43 changes: 43 additions & 0 deletions .changeset/layered-read-declared-path-4016.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/data-objectstack': patch
---

`MetadataClient.layered()` now reads the three-layer view from its declared path,
`GET /meta/:type/:name/layers`, instead of flagging the ordinary item read.

The consumer half of objectstack#5882 (ruled B by the maintainer; the server half
landed in objectstack#6596 and shipped in `@objectstack/spec@17.0.0`). The layered
projection — packaged baseline vs tenant overlay vs merged effective, which is
what the Studio metadata editor's comparison tabs render — used to be reached by
hanging a query flag on `GET /meta/:type/:name`. One route therefore answered two
unrelated representations chosen by a query parameter, while `packages/spec`
declared only the unflagged one: anything generating a client from the route
table produced a parser that was simply wrong for the flagged call. The
projection now has a path of its own and a response schema of its own
(`GetMetaItemLayeredResponseSchema`).

Same body, same envelope, so nothing in the editor changes shape: `code`,
`overlay`, `overlayScope`, `effective`, the load-time `_diagnostics` and the full
ADR-0010 protection envelope all still arrive on one round trip, and `?package=`
(ADR-0048) is still threaded — the two entry points are served by ONE handler
upstream precisely so the deprecation window's promise holds. The retired
spelling still answers during that window, marked with RFC 9745 `Deprecation` and
an RFC 8288 `Link: rel="successor-version"` pointing here, so this migration is
safe against a lagging backend for as long as the window stays open, and it is
what lets the maintainer close it.

One behaviour delta rides along, and it is the server's design rather than a
choice made here: the retired flag FELL THROUGH to the plain item read when the
backend's protocol implementation had no layered support, answering the
`{ type, name, item }` envelope. A dedicated path refuses to answer a different
resource under this one's declared shape and returns 501 `NOT_IMPLEMENTED`, which
surfaces as a failed read instead of a comparison view whose `code` and `overlay`
are silently blank.

The request is built in this package rather than delegated to
`@objectstack/client` because the SDK expresses no layered read in either
spelling — the framework's REST route ledger records the route as `server-only`,
"consumed by objectui over plain HTTP", and whether the SDK should express it is
an open upstream product call. The new path expectation is derived from the
installed `@objectstack/spec` route table, and a ratchet keeps any shipped source
file or skills guide from reaching the projection by query flag again.
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@
* • Overlay — pretty-printed JSON of just the deltas they've saved.
* • Effective — the merged value the runtime serves.
*
* Backed by `client.layered(type, name)` (Phase 3a `?layers=true`).
* Backed by `client.layered(type, name)`, i.e. `GET /meta/:type/:name/layers`.
*
* Diff scope: top-level keys only. Nested objects/arrays are compared by
* JSON-stringify equality. Drilling into nested diffs is a future
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,17 @@ function realClient(): MetadataClient {
baseUrl: 'http://localhost:3000',
fetch: (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/meta/permission/sales_perms?layers=true')) {
// The layered read's declared path (objectstack#5882 ruling B, migrated
// in objectui#4016). Kept exact so this double stays truthful about what
// the real client puts on the wire — the next reader copies from here.
//
// Measured, not assumed: it carries no assertion pressure. Reverting the
// client to the retired query flag leaves all three cases below GREEN,
// because the layered body only feeds the artifact-backed verdict and the
// permission draft, and the field sub-table these cases assert is fed by
// the `get()` read further down. The URL itself is pinned where it belongs,
// in `packages/data-objectstack/src/metadata-client.layeredRoute.test.ts`.
if (url.endsWith('/meta/permission/sales_perms/layers')) {
return json({
code: null,
overlay: null,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,8 @@
* MetadataResourceEditPage — generic AutoForm-driven editor (Phase 3c).
*
* What it does:
* 1. Fetches the layered view (`?layers=true`) so the user sees code
* vs overlay vs effective.
* 1. Fetches the layered view (`GET /meta/:type/:name/layers`) so the user
* sees code vs overlay vs effective.
* 2. Renders a SchemaForm against the JSONSchema in the type's
* `/meta/types` registry row.
* 3. Save → PUT, with automatic destructive-change handling: a 409
Expand Down
205 changes: 205 additions & 0 deletions packages/data-objectstack/src/metadata-client.layeredRoute.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#4016 — `MetadataClient.layered()` reads the three-layer projection
* from the path the framework DECLARES for it, `GET /meta/:type/:name/layers`,
* instead of flagging the ordinary item read.
*
* Why the move (objectstack#5882, ruled B by the maintainer; server half landed
* in objectstack#6596): one route was answering two unrelated representations
* chosen by a query flag, while `packages/spec` declared only the unflagged one.
* Anything generating a client from the route table — SDK annotations, codegen,
* an AI-written integration — produced a parser that was simply wrong for the
* flagged call. The projection now has a path of its own AND a response schema
* of its own (`GetMetaItemLayeredResponseSchema`); the flag still answers the
* same body during a deprecation window, marked with RFC 9745 `Deprecation` and
* an RFC 8288 `Link: rel="successor-version"`, and is scheduled for removal.
*
* `@objectstack/client` is not in the picture on purpose: the SDK expresses no
* layered read in either spelling (the framework's REST route ledger records
* this route as `server-only`, "consumed by objectui over plain HTTP"), so this
* package builds the request and these cases are what hold it to the contract.
*
* The path expectation is DERIVED from the installed `@objectstack/spec` route
* table rather than retyped here — a hand-copied string would keep passing
* after the producer moved the route, which is the failure mode this whole card
* exists to remove.
*
* The repo-wide half — that no shipped source file or skills guide reaches the
* projection by query flag any more — is a ratchet over a scan surface wider
* than this package, so it lives in
* `scripts/__tests__/layered-read-declared-path-4016.test.ts`.
*/

import { describe, expect, it } from 'vitest';
import {
DEFAULT_METADATA_ROUTES,
GetMetaItemLayeredResponseSchema,
} from '@objectstack/spec/api';
import { MetadataClient } from './metadata-client';

const BASE_URL = 'http://localhost:3000';

/** The layered endpoint as the installed spec declares it. */
function specLayeredEndpoint() {
const endpoints = (DEFAULT_METADATA_ROUTES as { endpoints: Array<Record<string, unknown>> }).endpoints;
const matches = endpoints.filter((e) => e.handler === 'getMetaItemLayered');
// If the producer ever declares this handler twice (or drops it), the
// expectation below is meaningless — say so instead of silently picking one.
expect(matches).toHaveLength(1);
return matches[0] as { method: string; path: string; responseSchema?: string };
}

/** `http://host/api/v1/meta/object/showcase_project/layers`, spec-derived. */
function specLayeredUrl(type: string, name: string): string {
const prefix = (DEFAULT_METADATA_ROUTES as { prefix: string }).prefix;
const suffix = specLayeredEndpoint().path
.replace(':type', type)
.replace(':name', name);
return `${BASE_URL}${prefix}${suffix}`;
}

/** A client over a fetch that records the URL and answers `body`. */
function clientAnswering(body: unknown, status = 200) {
const urls: string[] = [];
const client = new MetadataClient({
baseUrl: BASE_URL,
fetch: (async (input: RequestInfo | URL) => {
urls.push(String(input));
return new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch,
});
return { client, urls };
}

/**
* Exactly what the declared body carries — every layer, the load-time
* diagnostics and the full ADR-0010 protection envelope. Validated against the
* producer's own schema below, so the equivalence case cannot be green against
* a shape no server sends.
*/
const LAYERED_BODY = {
type: 'object',
name: 'showcase_project',
code: { name: 'showcase_project', label: 'Project', fields: { code: { type: 'text' } } },
overlay: { label: 'Projects (ours)' },
overlayScope: 'org',
effective: { name: 'showcase_project', label: 'Projects (ours)', fields: { code: { type: 'text' } } },
_diagnostics: { valid: false, errors: [{ path: 'label', message: 'too long', code: 'too_big' }] },
lock: 'no-delete',
lockReason: 'shipped by the crm package',
lockSource: 'package',
lockDocsUrl: 'https://docs.objectstack.ai/metadata/locks',
provenance: 'package',
packageId: 'crm',
packageVersion: '1.2.3',
editable: true,
deletable: false,
resettable: true,
} as const;

describe('objectui#4016 · the layered read goes to its declared path', () => {
it('is declared by the installed spec as a GET on its own path with its own schema', () => {
const endpoint = specLayeredEndpoint();
expect(endpoint.method).toBe('GET');
expect(endpoint.path).toBe('/:type/:name/layers');
// The point of ruling B: a representation of its own, not a second shape
// hiding behind the item read's schema.
expect(endpoint.responseSchema).toBe('GetMetaItemLayeredResponseSchema');
});

it('requests the spec-declared path and carries no `layers` query flag', async () => {
const { client, urls } = clientAnswering(LAYERED_BODY);

await client.layered('object', 'showcase_project');

expect(urls).toEqual([specLayeredUrl('object', 'showcase_project')]);
// Belt and braces on the retired spelling: the flag is gone as a QUERY
// parameter, not merely relocated inside the string.
expect(urls[0]).not.toMatch(/[?&]layers=/);
expect(urls[0]).not.toContain('?');
});

it('leads the query string with `?package=` instead of trailing it (ADR-0048)', async () => {
const { client, urls } = clientAnswering(LAYERED_BODY);

await client.layered('object', 'showcase_project', { packageId: 'crm/base' });

// The migration's live trap: the package id used to be appended with `&`
// behind the flag. Left as `&` it would have fused onto the last PATH
// segment (`…/layers&package=crm%2Fbase`), which matches no route — a 404
// that `layered()` reports as an empty-but-successful layered view.
expect(urls).toEqual([`${specLayeredUrl('object', 'showcase_project')}?package=crm%2Fbase`]);
expect(urls[0]).not.toContain('layers&');
});

it('percent-encodes the type and name into the path', async () => {
const { client, urls } = clientAnswering(LAYERED_BODY);

await client.layered('object', 'a/b c');

expect(urls).toEqual([`${BASE_URL}/api/v1/meta/object/a%2Fb%20c/layers`]);
});

it('hands back every layer and protection carrier the declared body sends', async () => {
// Guard the fixture first: a value verdict is being asserted, so the body
// must parse fully green against the producer's schema, not merely avoid
// unknown keys.
const parsed = GetMetaItemLayeredResponseSchema.safeParse(LAYERED_BODY);
expect(parsed.success).toBe(true);

const { client } = clientAnswering(LAYERED_BODY);

const layered = await client.layered<Record<string, unknown>>('object', 'showcase_project');

// Behaviour equivalence with the retired spelling: same envelope, same
// keys, `type` / `name` still dropped (the caller passed them in).
expect(layered).toEqual({
code: LAYERED_BODY.code,
overlay: LAYERED_BODY.overlay,
overlayScope: 'org',
effective: LAYERED_BODY.effective,
_diagnostics: LAYERED_BODY._diagnostics,
lock: 'no-delete',
lockReason: 'shipped by the crm package',
lockSource: 'package',
lockDocsUrl: 'https://docs.objectstack.ai/metadata/locks',
provenance: 'package',
packageId: 'crm',
packageVersion: '1.2.3',
editable: true,
deletable: false,
resettable: true,
});
});

it('reports an unknown item as an empty layered view (404)', async () => {
const { client } = clientAnswering({ error: 'not found' }, 404);

await expect(client.layered('object', 'nope')).resolves.toEqual({
code: null,
overlay: null,
overlayScope: null,
effective: null,
});
});

it('throws when the backend cannot serve the projection (501)', async () => {
// The one intentional behaviour delta of the move, and it is the server's
// call, not this client's: the retired flag FELL THROUGH to the plain item
// read on a backend whose protocol implementation had no layered support,
// answering `{ type, name, item }`. A dedicated path refuses to answer a
// different resource under this one's declared shape and returns 501
// `NOT_IMPLEMENTED` — which must surface, not degrade into a view whose
// `code` and `overlay` are blank for a reason the operator cannot see.
const { client } = clientAnswering(
{ error: 'Layered metadata view not supported by protocol implementation', code: 'NOT_IMPLEMENTED' },
501,
);

await expect(client.layered('object', 'showcase_project')).rejects.toThrow();
});
});
58 changes: 52 additions & 6 deletions packages/data-objectstack/src/metadata-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,14 @@ export interface MetadataDeleteOptions extends MetadataClientSaveOptions {
state?: 'active' | 'draft';
}

/** Layered view of a metadata item — Phase 3a `?layers=true`. */
/**
* Layered view of a metadata item — the body of
* `GET /meta/:type/:name/layers` (`GetMetaItemLayeredResponseSchema`).
*
* A subset by design: the response also carries `type` / `name`, which the
* caller already knows because it passed them in, so
* {@link MetadataClient.layered} does not hand them back.
*/
export interface MetadataLayered<T = unknown> {
/** Code-level (artifact) item; null if the item only exists as an overlay. */
code: T | null;
Expand DownExpand Up@@ -803,17 +810,49 @@ export class MetadataClient {
}

/**
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
* `code` (the artifact / fallback default), `overlay` (the saved
* customisation, if any), and `effective` (what the runtime sees).
* Get the 3-state layered view of a metadata item: `code` (the packaged
* artifact baseline), `overlay` (the tenant customisation row alone) and
* `effective` (the merged value the runtime sees).
*
* Reads **`GET /meta/:type/:name/layers`** — the path the framework declares
* for this projection, with a response schema of its own
* (`GetMetaItemLayeredResponseSchema`, objectstack#5882 ruling B). It used to
* be reached by hanging a `layers` flag on the ordinary item read, which made
* one route answer two unrelated representations while `packages/spec`
* declared only one of them. That spelling still answers this same body
* inside its deprecation window (the response carries RFC 9745
* `Deprecation: true` and an RFC 8288 `Link: rel="successor-version"` back to
* this path) and is scheduled for removal upstream, so nothing here may
* depend on it — the repo-wide ratchet is
* `scripts/__tests__/layered-read-declared-path-4016.test.ts`.
*
* The request is built here rather than delegated to `@objectstack/client`
* because the SDK expresses no layered read in EITHER spelling: the
* framework's REST route ledger records this route as `server-only`,
* "consumed by objectui over plain HTTP", and whether the SDK should express
* it is an open upstream product call.
*
* One behaviour delta rides along with the path, and it is the server's
* choice rather than ours: the retired flag FELL THROUGH to the plain item
* read on a backend whose protocol implementation had no layered support,
* answering the `{ type, name, item }` envelope. A dedicated path refuses to
* answer a different resource under this one's declared shape, so it returns
* 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error
* rather than a view with `code` and `overlay` silently blank.
*/
async layered<T = unknown>(
type: string,
name: string,
options: { packageId?: string } = {},
): Promise<MetadataLayered<T>> {
const pkg = options.packageId ? `&package=${encodeURIComponent(options.packageId)}` : '';
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?layers=true${pkg}`;
// ADR-0048 — `?package=` scopes resolution to one installed package (the
// editor passes the edited item's owning package, not the Studio app's).
// It leads the query string now that the flag it used to trail is gone:
// keeping the `&` would have appended it to the last PATH segment
// (`…/layers&package=crm`), which matches no route — a 404 that this
// method turns into an empty-but-successful layered view.
const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : '';
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`;
const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' });
if (res.status === 404) {
return { code: null, overlay: null, overlayScope: null, effective: null };
Expand All@@ -822,6 +861,13 @@ export class MetadataClient {
const body = (await res.json()) as MetadataLayered<T> & Record<string, unknown>;
const hasEnvelope =
body && (('code' in body) || ('overlay' in body) || ('effective' in body));
// Left standing, but no longer reachable from a conforming server: the only
// producer of a 200 body WITHOUT these keys was the retired flag's
// fall-through to the plain item read, and the declared path answers 501
// there. Kept out of this migration's scope on purpose — deleting it is a
// behaviour change for a non-conforming backend, not part of moving the
// request — and filed as objectui#4983 for removal once the upstream
// deprecation window closes.
if (!hasEnvelope) {
return {
code: null,
Expand Down
Loading
Loading