diff --git a/.changeset/object-extension-fold-property-class-sweep.md b/.changeset/object-extension-fold-property-class-sweep.md new file mode 100644 index 0000000000..6c7dfaa704 --- /dev/null +++ b/.changeset/object-extension-fold-property-class-sweep.md @@ -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. diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts new file mode 100644 index 0000000000..33d1953ba3 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -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, 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 => { + 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'); + }); +}); diff --git a/packages/rest/src/meta-object-extension-property-classes.test.ts b/packages/rest/src/meta-object-extension-property-classes.test.ts new file mode 100644 index 0000000000..28a64402bc --- /dev/null +++ b/packages/rest/src/meta-object-extension-property-classes.test.ts @@ -0,0 +1,468 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#8037] EVERY property the object-extension fold touches, classified by merge +// kind and measured on every read that serves an object schema. +// +// This is the third card in one family, and the first two were let through by +// the same blind spot rather than by two unrelated oversights: +// +// #7556 (PR #8015) taught the by-name read to fold `objectExtensions` onto the +// MetadataService body, reconciling the two reads on `fields`. Its pin compares +// FIELD NAMES. +// #8027 (PR #8045) then found `validations` and `indexes` DUPLICATED, because +// `mergeObjectDefinitions` concatenates them and the fold was not idempotent — +// invisible to a field-name pin, because the field spread is idempotent. +// #8037 arrived next, about `label`. +// +// `mergeObjectDefinitions` handles its properties in THREE ways, so a fold has +// three distinct failure modes and a field-name pin sees exactly one of them: +// +// | property | merge kind | idempotent? | +// |--------------|-----------------------------|-------------| +// | fields | key-keyed spread | yes | +// | validations | CONCATENATED | no (#8027) | +// | indexes | CONCATENATED | no (#8027) | +// | label | scalar, last-writer-wins | yes | +// | pluralLabel | scalar, last-writer-wins | yes | +// | description | scalar, last-writer-wins | yes | +// +// That is the WHOLE set — `mergeObjectDefinitions` names these 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"). So this file pins all six +// rather than the one the card was filed about: the two prior defects were each +// a property class nobody was measuring, and the cheapest way to stop paying for +// a fourth is to measure the classes instead of the instances. +// +// ⭐ WHAT THIS FILE ESTABLISHED ABOUT #8037. The fold is UNIFORM: on every host +// shape below, every read agrees with the registry's resolved schema (ADR-0029 +// D9.2) on all six properties, scalars included. The divergence the card +// reports — list/by-name serving `Account` while `?layers=true` serves +// `Account (Success Overlay)` — is NOT produced here and cannot be: it is +// produced one layer up, by i18n. `translateObject` resolves each of the three +// scalars as `catalog ?? document`, the two translated reads therefore serve the +// owner package's catalog entry in place of whatever the fold resolved, and +// `?layers=true` is deliberately not translated ("this is a diagnostic"). See +// `packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts`, +// which reproduces that on a real catalog and holds the escalation. +// +// Keeping the two apart is the point. If the scalar divergence were pinned here, +// on a harness with no i18n service, it would be pinned against a cause this +// layer does not contain — and the next card in the family would be filed +// against the fold again. +// +// Existing pins this file sits beside, both of which must stay green: +// `meta-object-extension-agreement.test.ts` (#8015, fields) and +// `meta-object-overlay-extension-fold.test.ts` (#8045, idempotency). + +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 owner's declaration. Every scalar is populated, because a scalar the base + * leaves `undefined` cannot show whether the extender OVERWROTE it or merely + * filled it in. + */ +const OWNER_DECLARATION = { + name: 'showcase_account', + label: 'Account', + pluralLabel: 'Accounts', + description: 'A company the org delivers projects for.', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + industry: { name: 'industry', label: 'Industry', type: 'text' }, + }, + validations: [ + { name: 'owner_rule', type: 'script', message: 'Name is required', condition: 'record.name == null' }, + ], + indexes: [{ name: 'owner_idx', fields: ['name'] }], +}; + +/** + * The extension. It contributes in ALL THREE merge kinds at once — that is the + * fixture's whole job. + * + * #8045's fixture deliberately declared NO `label`, and said so: an extender's + * scalars are last-writer-wins, so a relabelling extension overrides the + * tenant's overlay label, and its author kept that out of scope rather than + * assert it either way. #8037 is exactly the seam that left, so here the + * extension overrides all three scalars — with the real showcase's own + * `label: 'Account (Success Overlay)'`, which ships on `main` today. + */ +const EXTENSION_DECLARATION = { + name: 'showcase_account', + label: 'Account (Success Overlay)', + pluralLabel: 'Accounts (Success Overlay)', + description: 'Customer-success overlay for accounts.', + 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' }, + }, + validations: [ + { name: 'ext_rule', type: 'script', message: 'CSAT is 0-5', condition: 'record.csat_score > 5' }, + ], + indexes: [{ name: 'ext_idx', fields: ['loyalty_tier'] }], +}; + +/** An object NOTHING extends — the control that keeps every other payload honest. */ +const UNEXTENDED_DECLARATION = { + name: 'showcase_task', + label: 'Task', + pluralLabel: 'Tasks', + description: 'A unit of work inside a project.', + fields: { title: { name: 'title', label: 'Title', type: 'text' } }, + validations: [ + { name: 'task_rule', type: 'script', message: 'Title is required', condition: 'record.title == null' }, + ], + indexes: [{ name: 'task_idx', fields: ['title'] }], +}; + +/** The three scalar props, as one list, so no case can quietly check only `label`. */ +const SCALAR_PROPS = ['label', 'pluralLabel', 'description'] as const; + +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 body, tolerant of both wire shapes. */ +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(); +} + +/** + * The columns the platform injects into every served object body + * (`governServedItem` / #6562). Filtered off BOTH sides of every comparison, so + * each pin is about the AUTHORED field set — what an `extend` contributor + * contributes to. + */ +const PLATFORM_COLUMNS: readonly string[] = [ + 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', + 'organization_id', 'owner_id', 'owning_business_unit_id', +]; + +function declaredFieldsOf(item: unknown): string[] { + return fieldNamesOf(item).filter((n) => !PLATFORM_COLUMNS.includes(n)); +} + +/** + * `validations` / `indexes` entry names IN ORDER, duplicates preserved. Order + * and multiplicity are the whole point: a second fold shows up here and nowhere + * else, which is how #8027 escaped #7556's pin. + */ +function entryNames(item: unknown, key: 'validations' | 'indexes'): string[] { + const list = (item as Record | null | undefined)?.[key]; + return Array.isArray(list) ? list.map((v) => String((v as { name?: unknown })?.name)) : []; +} + +function scalarsOf(item: unknown): Record { + const o = (item ?? {}) as Record; + return Object.fromEntries(SCALAR_PROPS.map((k) => [k, o[k]])); +} + +/** Every property class of one served body, as a single comparable value. */ +function propertiesOf(item: unknown) { + return { + fields: declaredFieldsOf(item), + validations: entryNames(item, 'validations'), + indexes: entryNames(item, 'indexes'), + ...scalarsOf(item), + }; +} + +/** How this host's `metadata` service was populated. */ +type ServiceMode = + /** Artifact ingest: owner declaration only; extensions live in their own collection. */ + | 'artifact' + /** In-process boot: `bridgeObjectsToMetadataService` seeds it from the MERGED registry. */ + | 'bridged' + /** No `metadata` service at all — the read falls through to the registry. */ + | 'absent'; + +/** What, if anything, `sys_metadata` holds for the object. */ +type OverlayMode = + /** No row. The ordinary shape, and the one that must stay byte-identical. */ + | 'none' + /** A row that customises the label — the tenant edit #8027 is about. */ + | 'customised' + /** A row byte-identical to the owner's declaration. */ + | 'verbatim' + /** ⭐ A row already through the fold — what a Studio GET → edit → PUT persists. */ + | 'prefolded'; + +interface Host { + listed: unknown; + byName: unknown; + layerCode: unknown; + layerOverlay: unknown; + layerEffective: unknown; + /** The registry's resolution OF THIS HOST'S BASE — what D9.2 defines as correct. */ + registryResolved: unknown; + storedRow: unknown; +} + +/** + * Boot a REST server over the REAL protocol over a REAL registry and read all + * three surfaces off it: the list route, the by-name route, and `?layers=true`. + */ +async function measure(opts: { + serviceMode?: ServiceMode; + overlay?: OverlayMode; + extended?: boolean; +} = {}): Promise { + const serviceMode = opts.serviceMode ?? 'artifact'; + const overlayMode = opts.overlay ?? 'none'; + 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, + ); + } + + let storedBody: Record | undefined; + if (overlayMode === 'customised') { + storedBody = { ...clone(declaration), label: 'Customer' }; + } else if (overlayMode === 'verbatim') { + storedBody = clone(declaration); + } else if (overlayMode === 'prefolded') { + storedBody = registry.foldObjectExtendersOnto(objectName, clone(declaration)) as Record; + } + + const rows = storedBody === undefined ? [] : [{ + id: 'row_1', + type: 'object', + name: objectName, + state: 'active', + organization_id: null, + package_id: null, + metadata: JSON.stringify(storedBody), + }]; + const matches = (r: Record, w: Record) => + Object.entries(w).every(([k, v]) => (r[k] ?? null) === (v ?? null)); + + const engine = { + registry, + find: async (table: string, q: { where: Record }) => + table === 'sys_metadata' ? rows.filter((r) => matches(r, q.where)) : [], + findOne: async (table: string, q: { where: Record }) => + table === 'sys_metadata' ? rows.find((r) => matches(r, q.where)) : undefined, + }; + + const services = new Map(); + if (serviceMode !== 'absent') { + const body = 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 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' }, + ); + + return { + listed: listItems.find((o) => o?.name === objectName), + byName: singleBody?.item, + layerCode: layeredBody?.code, + layerOverlay: layeredBody?.overlay, + layerEffective: layeredBody?.effective, + registryResolved: registry.foldObjectExtendersOnto( + objectName, clone(storedBody ?? declaration), + ), + storedRow: storedBody, + }; +} + +/** Every host shape, so no case is measured on one boot and generalised from it. */ +const SERVICE_MODES: readonly ServiceMode[] = ['artifact', 'bridged', 'absent']; +const OVERLAY_MODES: readonly OverlayMode[] = ['none', 'customised', 'verbatim', 'prefolded']; + +describe('[#8037] the extension fold, by property class', () => { + // ── §0: the fixture genuinely exercises what the cases below claim ── + + it('ANTI-VACUITY: the extension differs from the base in all three merge kinds', () => { + // Without this, a fold that silently did nothing would satisfy every + // agreement assertion in this file. #7556's pin passed throughout #8027 + // for the neighbouring reason — its fixture contributed only the one + // property class whose merge is idempotent. + for (const key of SCALAR_PROPS) { + expect(EXTENSION_DECLARATION[key]).toBeDefined(); + expect(EXTENSION_DECLARATION[key]).not.toBe(OWNER_DECLARATION[key]); + } + // The concatenating pair contributes entries the base does not have… + expect(EXTENSION_DECLARATION.validations.map((v) => v.name)) + .not.toEqual(OWNER_DECLARATION.validations.map((v) => v.name)); + expect(EXTENSION_DECLARATION.indexes.map((i) => i.name)) + .not.toEqual(OWNER_DECLARATION.indexes.map((i) => i.name)); + // …and the spread contributes names the base does not declare. + for (const f of Object.keys(EXTENSION_DECLARATION.fields)) { + expect(Object.keys(OWNER_DECLARATION.fields)).not.toContain(f); + } + }); + + it('ANTI-VACUITY: the fold actually moves every property class off the base', async () => { + const host = await measure(); + const resolved = host.registryResolved as Record; + + // Scalars moved to the extender's values… + for (const key of SCALAR_PROPS) { + expect(resolved[key]).toBe(EXTENSION_DECLARATION[key]); + expect(resolved[key]).not.toBe(OWNER_DECLARATION[key]); + } + // …the concatenating pair carries BOTH contributors' entries… + expect(entryNames(resolved, 'validations')).toEqual(['owner_rule', 'ext_rule']); + expect(entryNames(resolved, 'indexes')).toEqual(['owner_idx', 'ext_idx']); + // …and the spread carries both field sets. + expect(declaredFieldsOf(resolved)).toEqual( + [...Object.keys(OWNER_DECLARATION.fields), ...Object.keys(EXTENSION_DECLARATION.fields)].sort(), + ); + }); + + // ── §1: the load-bearing sweep — every class, every read, every host ── + + for (const serviceMode of SERVICE_MODES) { + for (const overlay of OVERLAY_MODES) { + it(`every read agrees with the registry's resolved schema on all six properties [service=${serviceMode} overlay=${overlay}]`, async () => { + const host = await measure({ serviceMode, overlay }); + const expected = propertiesOf(host.registryResolved); + + // ⭐ Asserted against D9.2 — what the object SHOULD resolve to — + // and never against whatever another route happens to say. Both + // prior defects had the two routes agreeing with each other on a + // body that was already wrong, which is precisely why a + // `byName === listed` pin stayed green through them. + expect(propertiesOf(host.byName)).toEqual(expected); + expect(propertiesOf(host.listed)).toEqual(expected); + expect(propertiesOf(host.layerEffective)).toEqual(expected); + }); + } + } + + // ── §2: #8045's idempotency, which this card must not regress ── + + for (const host of ['prefolded', 'bridged'] as const) { + it(`does not duplicate the concatenated classes on an already-folded base [${host}]`, async () => { + const measured = host === 'prefolded' + ? await measure({ overlay: 'prefolded' }) + : await measure({ serviceMode: 'bridged' }); + + // The base genuinely carries the extender's entries already — + // without this the case proves nothing. + const base = host === 'prefolded' ? measured.storedRow : measured.registryResolved; + expect(entryNames(base, 'validations')).toContain('ext_rule'); + expect(entryNames(base, 'indexes')).toContain('ext_idx'); + + for (const read of [measured.byName, measured.listed, measured.layerEffective]) { + expect(entryNames(read, 'validations')).toEqual(['owner_rule', 'ext_rule']); + expect(entryNames(read, 'indexes')).toEqual(['owner_idx', 'ext_idx']); + } + }); + } + + it('returns an unfolded base BY REFERENCE when nothing extends the name', () => { + // #8045's other half: the ordinary payload pays a comparison and no copy. + const registry = new SchemaRegistry(); + registry.registerObject(clone(UNEXTENDED_DECLARATION) as never, 'showcase', undefined, 'own'); + const base = clone(UNEXTENDED_DECLARATION); + expect(registry.foldObjectExtendersOnto('showcase_task', base)).toBe(base); + }); + + // ── §3: the fold is not applied to every payload ── + + it('an object with no extension contributor serialises byte-identically', async () => { + for (const serviceMode of SERVICE_MODES) { + const host = await measure({ serviceMode, extended: false }); + + // Byte-identity against the DECLARATION, not against another read: + // the fold must be a no-op here, so the served scalars and both + // concatenated lists are the owner's own, unchanged. + for (const read of [host.byName, host.listed, host.layerEffective]) { + expect(scalarsOf(read)).toEqual(scalarsOf(UNEXTENDED_DECLARATION)); + expect(entryNames(read, 'validations')).toEqual(['task_rule']); + expect(entryNames(read, 'indexes')).toEqual(['task_idx']); + for (const f of Object.keys(EXTENSION_DECLARATION.fields)) { + expect(declaredFieldsOf(read)).not.toContain(f); + } + } + } + }); + + // ── §4: the overlay layer reports only what the TENANT customised ── + + it('leaves `layers.overlay` the tenant\'s own row — an extension is not a customisation', async () => { + const host = await measure({ overlay: 'customised' }); + + // ⛔ The boundary #7556 drew and #8045 re-affirmed. Studio's diff tab + // reads this layer, so an extension appearing in it would report a + // customisation the tenant never made — in EVERY property class, not + // just the fields #8045 checked. + expect(host.layerOverlay).toEqual(host.storedRow); + for (const key of SCALAR_PROPS) { + expect((host.layerOverlay as Record)[key]) + .not.toBe(EXTENSION_DECLARATION[key]); + } + expect(entryNames(host.layerOverlay, 'validations')).not.toContain('ext_rule'); + expect(entryNames(host.layerOverlay, 'indexes')).not.toContain('ext_idx'); + for (const f of Object.keys(EXTENSION_DECLARATION.fields)) { + expect(fieldNamesOf(host.layerOverlay)).not.toContain(f); + } + }); +});