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
62 changes: 62 additions & 0 deletions .changeset/object-extension-fold-property-class-sweep.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
"@objectstack/rest": patch
---

test(rest,dogfood): enumerate every property the object-extension fold touches, and locate #8037's divergence in i18n rather than in the fold (#8037)

Third card in one family. #7556 (PR #8015) reconciled the by-name and list reads
on `fields`; #8027 (PR #8045) then found `validations`/`indexes` duplicated,
invisible to #8015's pin because it compares FIELD NAMES and the field spread is
idempotent. #8037 arrived next, about `label`.

**The enumeration, because the instances keep arriving.**
`mergeObjectDefinitions` names six keys and copies nothing else — which
`ObjectExtensionSchema`'s own guidance states from the other side ("the merge
carries `fields`, `label`, `pluralLabel`, `description`, `validations` and
`indexes` only") — in three merge kinds:

| property | merge kind | idempotent? |
|---|---|---|
| `fields` | key-keyed spread | yes |
| `validations` | CONCATENATED | no (#8027) |
| `indexes` | CONCATENATED | no (#8027) |
| `label` / `pluralLabel` / `description` | scalar, last-writer-wins | yes |

So a fold has three distinct failure modes and a field-name pin sees one.
`meta-object-extension-property-classes.test.ts` sweeps all six across twelve
host shapes (artifact/bridged/absent × no-row/customised/verbatim/prefolded),
asserting each read against the REGISTRY'S RESOLVED SCHEMA (ADR-0029 D9.2)
rather than against another route — both prior defects had the two routes
agreeing with each other on a body that was already wrong.

**#8037 is not a fold defect.** Traced through a real artifact-ingest boot,
`foldObjectExtendersOnto` is called on the by-name read and on the layered read
with the same base and returns the same body to both, `label` included. The
sweep holds the same result from the other side: on all twelve shapes every read
agrees with D9.2 on all six properties. The divergence is produced one layer up.
`translateObject` resolves each of the three scalars as `catalog ?? document`,
and the showcase's own catalog declares `objects.showcase_account.label =
"Account"`. The list and by-name reads are translated, so the catalog entry
replaces whatever the fold resolved; `?layers=true` is deliberately not
translated ("this is a diagnostic"). Hence "onto `?layers=true` only".

**The extension is the milder half.** The catalog is keyed by object name and
resolved ahead of the document, so it defeats the TENANT's customisation too: an
admin who renames the object through the ordinary Studio round-trip gets a
`layers.overlay` carrying `"Customer"` and both reads every writable form
derives from still serving `"Account"`. That is the scenario #8027/#8045 were
about. Escalated rather than decided here — the issue itself asks for a design
ruling, and both candidate directions change behaviour well outside this card's
region.

**No behaviour change.** Tests only; `mergeObjectDefinitions`,
`foldObjectExtendersOnto`, `getMetaItem` and `getMetaItemLayered` are untouched,
so #8045's idempotency, the `layers.overlay` boundary and byte-identity for
unextended objects all stand as they were.

Reverse-verified per arm (A: #8045's subtraction disabled; B: the card's
proposed "fold drops scalars"; C: #7556's fold disabled). Arm B — the easy half
the card asked for — is invisible to BOTH existing pins and is caught only by
this file's anti-vacuity case: dropping scalars makes the three reads agree by
deleting a documented `ObjectExtensionSchema` feature, and leaves the tenant
rename defect untouched.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#8037] One object, three reads, three different labels — measured on a stack
// booted the way a DEPLOYED runtime boots.
//
// GET /meta/object (list) → "Account"
// GET /meta/object/showcase_account → "Account"
// GET /meta/object/showcase_account?layers=true → "Account (Success Overlay)"
//
// ⭐ WHERE THE DIVERGENCE IS NOT. It is not in the fold. Traced through a real
// boot, `foldObjectExtendersOnto` is called on the by-name read and on the
// layered read with the same base and returns the same body to both — label
// included ("Account" in, "Account (Success Overlay)" out, on BOTH). The
// property-class sweep in
// `packages/rest/src/meta-object-extension-property-classes.test.ts` holds that
// from the other side: on twelve host shapes every read agrees with the
// registry's resolved schema on all six properties `mergeObjectDefinitions`
// touches. A fix applied to the fold would therefore be applied to the one
// layer that is behaving.
//
// ⭐ WHERE IT IS. `translateObject` (packages/spec/src/system/i18n-resolver.ts)
// resolves each of the three scalar props as `catalog ?? document`:
//
// const label = lookupObjectField(bundle, objectName, 'label', opts) ?? doc.label;
//
// The showcase's own catalog declares `objects.showcase_account.label = "Account"`.
// The list and by-name reads are translated, so the catalog entry REPLACES
// whatever the fold resolved. `?layers=true` is deliberately not translated
// ("Not translated and not cached, both deliberately: this is a diagnostic"),
// so it alone shows the folded value. Hence "onto `?layers=true` only".
//
// ⛔ AND THE EXTENSION IS THE MILDER HALF. The catalog is keyed by object name
// and resolved AHEAD of the document, so it does not defeat only a code-declared
// extension override — it defeats the TENANT's own customisation too. The final
// case below renames the object through the ordinary Studio round-trip and the
// rename reaches `layers.overlay` and nothing else: both reads every writable
// form derives from keep serving the packaged catalog string. That is the
// scenario #8027/#8045 were entirely about ("an admin renaming the object's
// label in Studio"), and it is why this file escalates rather than pinning a
// preference — see the report on #8037.
//
// The `it.fails` cases below are the invariants that SHOULD hold, quarantined in
// the repo's existing xfail idiom (see `field-zoo-roundtrip.dogfood.test.ts`).
// They pass while the defect stands and turn RED the moment it is fixed, which
// is what makes them a handover rather than a pin of current behaviour.

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';

/** What the showcase's `objectExtensions` entry declares on `main` today. */
const EXTENSION_LABEL = 'Account (Success Overlay)';
/** What the showcase's `en` catalog declares for the same object. */
const CATALOG_LABEL = 'Account';

const labelOf = (item: unknown): unknown =>
(item as { label?: unknown } | null | undefined)?.label;

describe('dogfood: the object-extension fold and the i18n catalog disagree on scalars (#8037)', () => {
let stack: VerifyStack;
let token: string;
let tempDir: string;
let priorWritable: string | undefined;

beforeAll(async () => {
// The last case performs a tenant customisation of an `object`, which is
// not overlay-writable by default (`NOT_OVERRIDABLE`). This is the same
// switch a deployment flips to let Studio customise object metadata.
priorWritable = process.env.OS_METADATA_WRITABLE;
process.env.OS_METADATA_WRITABLE = 'object';

tempDir = mkdtempSync(join(tmpdir(), 'os-8037-scalar-'));
const artifactPath = join(tempDir, 'objectstack.json');
// The real `objectstack build` lowering, for the same reason #7556's
// dogfood file uses it: `JSON.stringify(stack)` drops callables silently.
writeBuildShapedArtifact(showcaseStack as unknown as Record<string, unknown>, artifactPath);

// Boots from a COMPILED ARTIFACT, whose `objects` and `objectExtensions`
// are separate collections — the deployment shape, and the only one on
// which this family of defects is observable at all.
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 });
if (priorWritable === undefined) delete process.env.OS_METADATA_WRITABLE;
else process.env.OS_METADATA_WRITABLE = priorWritable;
});

const listedLabel = async (): Promise<unknown> => {
const res = await stack.apiAs(token, 'GET', '/meta/object');
expect(res.status).toBe(200);
const body: any = await res.json();
const items = (Array.isArray(body)
? body
: (body?.items ?? body?.data ?? [])) as Array<{ name?: string }>;
return labelOf(items.find((o) => o?.name === 'showcase_account'));
};

it('the premise: the extension declares a label, and the catalog declares a different one', async () => {
// Both halves ship on `main`. Neither is a fixture — if either changes,
// every case below stops meaning what it says, and this fails first.
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true');
expect(res.status).toBe(200);
const body: any = await res.json();
expect(labelOf(body?.code)).toBe(EXTENSION_LABEL);
expect(await listedLabel()).toBe(CATALOG_LABEL);
expect(EXTENSION_LABEL).not.toBe(CATALOG_LABEL);
});

it('the fold itself is uniform — it reaches BOTH layers of the diagnostic', async () => {
// The half that is working, pinned so a future fix cannot "resolve" the
// divergence by unfolding the layered read and calling the three reads
// agreed. `effective` is `overlay ?? code`, and the showcase customises
// nothing at this point, so both layers carry the extension.
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true');
const body: any = await res.json();
expect(labelOf(body?.code)).toBe(EXTENSION_LABEL);
expect(labelOf(body?.effective)).toBe(EXTENSION_LABEL);
expect(body?.overlay ?? null).toBeNull();
});

it('the two translated reads agree with EACH OTHER — the divergence is not between them', async () => {
const res = await stack.apiAs(token, 'GET', '/meta/object/showcase_account');
const body: any = await res.json();
expect(labelOf(body?.item)).toBe(await listedLabel());
});

it.fails('SHOULD: all three reads of one object serve one label', async () => {
const single = await stack.apiAs(token, 'GET', '/meta/object/showcase_account');
const singleBody: any = await single.json();
const layered = await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true');
const layeredBody: any = await layered.json();

// `effective` is documented as "what `getMetaItem` would return". It is
// not, and this is the sentence that stops being true.
expect(labelOf(layeredBody?.effective)).toBe(labelOf(singleBody?.item));
expect(labelOf(layeredBody?.effective)).toBe(await listedLabel());
});

it.fails('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => {
// The ordinary Studio round-trip: GET the served document, rename it,
// PUT it back. The write path persists the request body verbatim
// (ADR-0005 §Validation), so this is exactly what an admin's save stores.
const before: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json();
const put = await stack.apiAs(token, 'PUT', '/meta/object/showcase_account', {
...(before?.item ?? {}), label: 'Customer',
});
expect(put.status).toBeLessThan(400);

const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json();
// The row stored the rename — the customisation is real and readable…
expect(labelOf(layered?.overlay)).toBe('Customer');

// …and neither read that a writable form derives from ever shows it.
const after: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json();
expect(labelOf(after?.item)).toBe('Customer');
expect(await listedLabel()).toBe('Customer');
});
});
Loading
Loading