diff --git a/.changeset/21334-view-container-cross-package-default.md b/.changeset/21334-view-container-cross-package-default.md new file mode 100644 index 00000000000..6797400a880 --- /dev/null +++ b/.changeset/21334-view-container-cross-package-default.md @@ -0,0 +1,31 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a view container saved for an object another package ships no longer replaces that package's views or its default + +Clause-②: no + +- **What was wrong.** A runtime view container expands each member to `.`. A `list` that names no key becomes `.default`, a `form` becomes `.form`, and every member that names a key uses that key. Saved under another name, in another package or in none, for an object a code package ships, those expansions replaced that package's views of the same names on `GET /api/v1/meta/view?object=`. The replacements were still stamped with the shipping package's `_packageId` and `_provenance: 'package'`. + - On an environment-scoped kernel, the by-name read `GET /api/v1/meta/view/` kept the packaged view, so the two reads disagreed. + - On an unscoped kernel, the by-name read served the replacement too, for a container saved into a package or environment-wide. + - The container's own default kept `isDefault: true`. It either replaced the object's default view or stood beside it as a second list default. +- **What it does now.** For an object a code package ships, a container that belongs to another package, or to none, expands every member under its own name: + - a `list` that names no key becomes `.`; + - every other member becomes `..`. That covers a `list` that names its key, each `listViews` and `formViews` entry, and `form`. + + None of these views carries `isDefault`. Every name the shipping package serves answers its packaged view on both reads, unchanged, and the only `isDefault` views the object lists are the shipping package's. +- **One exception.** When the shipping package itself serves `.` (a container named after one of that package's keys), the container's default list becomes `..` instead. +- **A container with no name of its own** expands nothing on such an object. +- **What these views carry.** The container's own package as `_packageId` (none for a package-less container), and no other package's `_provenance` or protection envelope. +- **What stays.** Three kinds of container expand exactly as before, `isDefault` included: + - a container bound to the package that ships the object; + - a package-less overlay of that package's own container, saved under that container's name; + - a container on an object no code package ships. + + A write to `.default` by its own name still overrides it on both reads. +- **What changes for a caller.** Such a container's views are now served under new names: + - its default list as `.`, instead of `.default`; + - each keyed member as `..`, instead of `.`. + + A navigation `viewName` or a form-action `target` that used an old name to reach one of these views now reaches the shipping package's view. Use the new name instead. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d303b58b49..237a0530d7a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8363,6 +8363,13 @@ export class ObjectStackProtocolImplementation implements // through), and re-deriving here from the row this call just // read is a byte-identical, never-stale restatement of the // same items — not a duplicate. + // + // [#21334] An upsert by name replaces whatever the merge seated + // under that name, so the name has to be one the container may + // write. {@link expandRuntimeViewContainer} decides it: on + // another package's object every name a container expands + // derives from the container's own name, never one of that + // package's `.` names. if (isView) { const byName = new Map(); for (const it of items as any[]) { @@ -16131,6 +16138,45 @@ export class ObjectStackProtocolImplementation implements * expanded under the WRONG key or not at all. The three-deep fallback is * kept, unchanged, for every container written before this field was * consulted here. + * + * ## A container on ANOTHER package's object (#21334) + * + * The spec names every expanded view `.`: a bare `list` (one + * that names no key) takes `.default`, a `form` `.form`, + * and each named member its own key. For a container of the object's own + * package those are that package's names, and it still expands there. For + * a container saved under any other name, in another package or in none, + * on an object a code package owns, they are the OTHER package's names: + * ADR-0005 keys an overlay by its own name, and ADR-0126 rules out a silent + * override, so a row named `x` may not replace an item named + * `.`. Both callers set each expansion by name — the list's + * inline pass over the merged items, the registry's bare key — so the + * expansion used to replace the packaged view on the object door (and, on + * an unscoped kernel, on the by-name read too), wearing the shadowed + * artifact's `_packageId` and protection. + * + * So, on another package's object, every name the container expands + * derives from its own name (triage's ruling, and the seat's answer that + * extends it to the keyed members): the bare `list` is + * `.`, and every other member is + * `..` — the spec's own key rule and its + * in-container de-duplication, run under the container's name (see + * {@link expandUnderOwnName}). The qualified forms are the ones the spec + * accepts for a ViewItem (`ViewItemNameSchema`); the flat container name on + * an expanded item is refused there, so the list door would serve it + * badged invalid. The view container contract (`view.zod.ts`, ADR-0017 + * §3.2) names the container after its object and states no arm for a name + * another package owns, which is why the arm taken is the container's own + * name rather than a refusal at save. + * + * Such a container adds views to the object; it never declares the + * object's default, so none of its views carries `isDefault` — the + * switcher's default stays the owning package's (the by-name override and + * a user's own saved default are the routes that change it). + * + * Every expanded item carries the container's OWN package and, where that + * package ships an artifact of the same name, that artifact's envelope — + * never the envelope of an artifact another package ships. */ private expandRuntimeViewContainer( type: string, @@ -16146,25 +16192,138 @@ export class ObjectStackProtocolImplementation implements ?? container?.form?.data?.object ?? (typeof container.name === 'string' ? container.name : undefined); if (!viewObject) return []; + const ownPackageId = this.runtimeViewContainerPackage(type, container, options); + const crossPackage = this.isAnotherPackagesObject(viewObject, ownPackageId); + const expanded: ReadonlyArray> = crossPackage + ? this.expandUnderOwnName(type, viewObject, container, ownPackageId) + : (expandViewContainer(viewObject, container) as unknown as Record[]); const out: Record[] = []; - for (const vi of expandViewContainer(viewObject, container)) { + for (const vi of expanded) { // Carry the container's package provenance onto each expanded item // so the package-disable filter and ADR-0048 artifact scoping judge // them by the same owner the container has. - const item: Record = { ...(vi as any) }; - if (container._packageId !== undefined && item._packageId === undefined) { - item._packageId = container._packageId; - } - const viArtifact = this.lookupArtifactItem( - type, - vi.name, - (item._packageId as string | undefined) ?? options.packageId ?? undefined, - ); - out.push(mergeArtifactProtection(item, viArtifact) as Record); + const item: Record = { ...vi }; + // [#21334] Not the object's default: its owning package's is. + if (crossPackage) delete item.isDefault; + if (ownPackageId !== undefined) item._packageId = ownPackageId; + // [#21334] Only the container's own package's artifact lends its + // envelope; another package's artifact of this name is not this + // item's to wear. + const viArtifact = ownPackageId === undefined + ? undefined + : this.lookupArtifactItem(type, String(item.name), ownPackageId); + const ownArtifact = (viArtifact as { _packageId?: unknown } | undefined)?._packageId === ownPackageId + ? viArtifact + : undefined; + out.push(mergeArtifactProtection(item, ownArtifact) as Record); } return out; } + /** + * [#21334] The package a runtime view container row belongs to: the + * package its row is bound to, else — for a package-less row that is the + * name-keyed overlay of a packaged item (ADR-0005), such as a tenant's + * overlay of a package's `` container — the package of the + * artifact it overlays, which is the slot the package-aware merge seats + * it in. `undefined` for a package-less row that overlays nothing. + */ + private runtimeViewContainerPackage( + type: string, + container: Record, + options: { packageId?: string | null }, + ): string | undefined { + for (const bound of [container._packageId, options.packageId]) { + if (typeof bound === 'string' && bound !== '' && bound !== 'sys_metadata') return bound; + } + if (typeof container.name !== 'string' || container.name === '') return undefined; + const overlaid = (this.lookupArtifactItem(type, container.name) as { _packageId?: unknown } | undefined) + ?._packageId; + return typeof overlaid === 'string' && overlaid !== '' ? overlaid : undefined; + } + + /** + * [#21334] True when a code package owns `object` and it is not + * `ownPackageId`. The discriminator is `getPackagedObjectOwner`, the same + * "does a code package ship this?" test {@link classifyObjectContribution} + * asks. A runtime-authored object has no packaged owner, so a container on + * one keeps today's `.` names; so does a registry that cannot + * answer. + */ + private isAnotherPackagesObject(object: string, ownPackageId: string | undefined): boolean { + const registry: any = (this.engine as any)?.registry; + const owner = typeof registry?.getPackagedObjectOwner === 'function' + ? registry.getPackagedObjectOwner(object) + : undefined; + const ownerPackageId: unknown = owner?.packageId; + return typeof ownerPackageId === 'string' && ownerPackageId !== '' && ownerPackageId !== ownPackageId; + } + + /** + * [#21334] Expand a container on another package's object under its own + * name. The spec's expander runs with `.` as its + * base, so every member it knows — today a named `list`, `listViews`, + * `formViews`, `form` — comes out as `..`, + * de-duplicated by the spec's own rule, and a member kind the spec adds + * later is placed the same way. Each item's `object` is set back to the + * object it binds. + * + * The bare `list` is lent the container's name as its key, then served as + * `.` itself, its `config` the list as authored. + * Where the owning package ships that very name (a container named after + * one of that package's keys), it stays at the spelling the spec gave it, + * `..`, so it never takes the + * packaged view's name. + * + * A container with no name of its own has nothing to expand under, and + * expands nothing. + */ + private expandUnderOwnName( + type: string, + object: string, + container: Record, + ownPackageId: string | undefined, + ): Record[] { + const ownName = typeof container.name === 'string' && container.name !== '' ? container.name : undefined; + if (ownName === undefined) return []; + const under = `${object}.${ownName}`; + const expandAt = under; + const list = container.list; + const bare = !!list && typeof list === 'object' && !(typeof list.name === 'string' && list.name !== ''); + const source = bare ? { ...container, list: { ...list, name: ownName } } : container; + const bareSpelled = `${expandAt}.${ownName}`; + const underIsShipped = this.isShippedByAnotherPackage(type, under, ownPackageId); + return expandViewContainer(expandAt, source).map((vi) => { + const item: Record = { ...vi, object }; + const name = String(item.name); + const fromBare = bare + && item.viewKind === 'list' + && item.config?.name === ownName + && name.startsWith(bareSpelled) + && /^(_\d+)?$/.test(name.slice(bareSpelled.length)); + if (fromBare) { + const authored = { ...item.config }; + delete authored.name; + item.config = authored; + if (!underIsShipped) { + item.name = under; + delete item._diagnostics; + } + } + return item; + }); + } + + /** + * [#21334] True when a code package other than `ownPackageId` ships an + * artifact named `name` — the one case where the bare list's own name is + * not free to take. + */ + private isShippedByAnotherPackage(type: string, name: string, ownPackageId: string | undefined): boolean { + const shipped = (this.lookupArtifactItem(type, name) as { _packageId?: unknown } | undefined)?._packageId; + return typeof shipped === 'string' && shipped !== '' && shipped !== ownPackageId; + } + /** * [#7736] Register an aggregated `defineView` container's expansion * ({@link expandRuntimeViewContainer}) into the SchemaRegistry — at the diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 52f810d47b2..11997b725cb 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -28,7 +28,8 @@ * choke point all three runtime hydration callers share. */ import { describe, expect, it } from 'vitest'; -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { expandViewContainer, ViewSchema } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './index.js'; interface Row { @@ -395,3 +396,380 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served }); }); }); + +/** + * #21334 — a container on ANOTHER package's object must not take any of that + * package's names, nor its default. + * + * The spec names every expanded view `.` (a bare `list` takes + * `.default`). A container saved under any other name — in another + * package, or in none — for an object a code package ships used to expand + * there and REPLACE the packaged views on the object door + * (`GET /meta/view?object=`), wearing the shadowed artifact's `_packageId`, + * while the by-name read kept the packaged item on an environment-scoped kernel + * (and served the shadow too on an unscoped one, through the registry's bare + * key). ADR-0005 keys an overlay by its own name and ADR-0126 rules out a silent + * override. Triage's ruling takes the arm "expand under the container's own + * name"; the seat's answer extends it to every member of the container, and + * rules that such a container never declares the object's default. + * + * The registry double here is the real `SchemaRegistry`'s shape where this card + * turns on it: loader entries under `:`, a hydrated row under + * the bare name, `getItem`'s bare-slot-first precedence, `getArtifactItem`'s + * package-scoped code-artifact lookup, and `getPackagedObjectOwner`. The + * packaged views are produced by the spec's own `expandViewContainer`, as the + * source registrars produce them. The cold-boot proof over the real showcase + * composition, through the REST doors, is + * `view-container-cross-package-default.dogfood.test.ts`. + */ +describe('#21334 a container on another package\'s object never takes that package\'s names or its default', () => { + const SHOWCASE = 'com.example.showcase'; + const REPAIR = 'com.example.repairassets'; + const TASK = 'showcase_task'; + const DEFAULT = `${TASK}.default`; + const ORG = 'org_acme'; + const data = { provider: 'object', object: TASK }; + const PACKAGED_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress'] + .map((field) => ({ field })); + /** + * What the showcase ships for `showcase_task`: a `defineView` container with + * one member of every kind the expander knows, so every probe below aims at + * a name the package really ships. + */ + const packagedTaskViews = { + list: { label: 'All Tasks', type: 'grid', data, columns: PACKAGED_COLUMNS }, + listViews: { + in_progress: { label: 'In Progress', type: 'grid', data, columns: PACKAGED_COLUMNS.slice(0, 4) }, + }, + form: { type: 'simple', sections: [{ label: 'Main', fields: ['title'] }] }, + formViews: { + edit: { type: 'tabbed', sections: [{ label: 'Edit', fields: ['title', 'status'] }] }, + }, + }; + const PACKAGED = expandViewContainer(TASK, packagedTaskViews).map((vi) => ({ ...(vi as any) })); + /** The card's own probe body, verbatim. */ + const probe = (name: string) => ({ name, object: TASK, list: { type: 'grid', columns: ['title', 'status'] } }); + + function faithfulRegistry() { + const byType = new Map>>(); + const objects = new Map }>(); + const collection = (type: string) => { + if (!byType.has(type)) byType.set(type, new Map()); + return byType.get(type)!; + }; + return { + registerItem(type: string, item: Record, keyField = 'name', packageId?: string) { + const name = String(item[keyField]); + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + collection(type).set(`${packageId}:${name}`, item); + } else { + collection(type).set(name, item); + } + }, + listItems(type: string, packageId?: string) { + const all = [...(byType.get(type)?.values() ?? [])]; + return packageId ? all.filter((it) => it._packageId === packageId) : all; + }, + getItem(type: string, name: string, packageId?: string) { + const entries = byType.get(type); + if (!entries) return undefined; + const direct = entries.get(name); + if (direct) return direct; + if (packageId) { + const local = entries.get(`${packageId}:${name}`); + if (local) return local; + } + for (const [key, item] of entries) if (key.endsWith(`:${name}`)) return item; + return undefined; + }, + getArtifactItem(type: string, name: string, packageId?: string) { + const entries = [...(byType.get(type)?.entries() ?? [])]; + const scoped = entries.filter(([key, it]) => key.endsWith(`:${name}`) && isCodeArtifactBody(it)); + const local = packageId ? scoped.find(([, it]) => it._packageId === packageId) : undefined; + if (local) return local[1]; + if (scoped[0]) return scoped[0][1]; + const bare = byType.get(type)?.get(name); + return bare && isCodeArtifactBody(bare) ? bare : undefined; + }, + shipObject(name: string, packageId: string) { + objects.set(name, { packageId, ownership: 'own', definition: { name, _packageId: packageId } }); + }, + getPackagedObjectOwner: (name: string) => objects.get(name), + getObject: (name: string) => objects.get(name)?.definition, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + isObjectPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + } + + /** The stub engine above, with the showcase's packaged task views in a faithful registry. */ + function showcaseHarness(environmentId?: string) { + const stub = makeStubEngine(); + const registry = faithfulRegistry(); + registry.shipObject(TASK, SHOWCASE); + registry.registerItem('view', { ...packagedTaskViews, name: TASK }, 'name', SHOWCASE); + for (const vi of expandViewContainer(TASK, packagedTaskViews)) { + registry.registerItem('view', { ...(vi as any) }, 'name', SHOWCASE); + } + stub.engine.registry = registry; + const protocol = new ObjectStackProtocolImplementation(stub.engine, undefined, environmentId); + return { ...stub, registry, protocol }; + } + + type Protocol = ObjectStackProtocolImplementation; + const scoped = (organizationId?: string) => (organizationId ? { organizationId } : {}); + const objectDoor = async (protocol: Protocol, organizationId?: string) => + switcherMatches(((await protocol.getMetaItems({ type: 'view', ...scoped(organizationId) } as any)) as any).items, TASK); + const byNameDoor = async (protocol: Protocol, name: string, organizationId?: string) => + ((await protocol.getMetaItem({ type: 'view', name, ...scoped(organizationId) } as any)) as any).item; + const named = (items: any[], name: string) => items.filter((v) => v.name === name); + /** The packaged default, as the source registrar registered it. */ + const expectPackagedDefault = (v: any) => { + expect(v?.label).toBe('All Tasks'); + expect(v?.config?.columns).toEqual(PACKAGED_COLUMNS); + expect(v?._packageId).toBe(SHOWCASE); + expect(v?.isDefault).toBe(true); + }; + /** (a) Every name the showcase ships answers the packaged view, once, on BOTH doors — the same row. */ + const expectEveryPackagedNameIntact = async (protocol: Protocol, organizationId?: string) => { + const served = await objectDoor(protocol, organizationId); + for (const shipped of PACKAGED) { + const listed = named(served, shipped.name); + expect(listed, `exactly one item answers ${shipped.name} on the object door`).toHaveLength(1); + expect({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }) + .toEqual({ label: shipped.label, config: shipped.config, _packageId: SHOWCASE }); + const read = await byNameDoor(protocol, shipped.name, organizationId); + expect({ label: read?.label, config: read?.config, _packageId: read?._packageId }) + .toEqual({ label: shipped.label, config: shipped.config, _packageId: SHOWCASE }); + } + }; + /** (c) The object's only defaults are the ones its owning package declares. */ + const expectOnlyPackagedDefaults = (served: any[]) => { + for (const viewKind of ['list', 'form']) { + expect( + served.filter((v) => v.viewKind === viewKind && v.isDefault).map((v) => v.name), + `the ${viewKind} default stays the owning package's`, + ).toEqual(PACKAGED.filter((v) => v.viewKind === viewKind && v.isDefault).map((v) => v.name)); + } + }; + + /** + * Every member kind the spec's expander places, derived FROM the expander: + * each top-level key of the container schema is offered a single view and a + * record of views, and a key that yields an expanded item is a member kind. + * A single-view member is enumerated twice — bare, and naming its own key — + * when its own schema declares `name` (a `list` does; a `form` does not, so + * a named `form` is not authorable). A kind the spec adds later shows up + * here, and the enumeration below fails until it is placed. + */ + function memberKindsOfTheExpander(): string[] { + const shape = (ViewSchema as unknown as { shape?: Record }).shape ?? {}; + const slots = Object.keys(shape); + expect(slots.length, 'the container schema\'s own keys are readable').toBeGreaterThan(0); + const declaresName = (schema: unknown): boolean => { + let s: any = schema; + for (let i = 0; i < 6 && s; i++) { + if (s.shape) return 'name' in s.shape; + s = s._zod?.def?.innerType ?? s._def?.innerType ?? (typeof s.unwrap === 'function' ? s.unwrap() : undefined); + } + return false; + }; + const view = { type: 'grid', label: 'probe' }; + const kinds: string[] = []; + for (const slot of slots) { + if (expandViewContainer('o', { [slot]: { ...view } }).length > 0) { + kinds.push(slot); + if (declaresName(shape[slot])) kinds.push(`${slot}#named`); + } else if (expandViewContainer('o', { [slot]: { k: { ...view } } }).some((vi) => vi.name === 'o.k')) { + kinds.push(`${slot}.*`); + } + } + return kinds.sort(); + } + + const OWN = 'os_qa_probe'; + const listView = { type: 'grid', columns: ['title', 'status'] }; + const formView = { type: 'simple', sections: [{ label: 'Probe', fields: ['title'] }] }; + /** + * One case per member kind: a container on `showcase_task` with ONLY that + * member, whose key aims at a name the showcase ships, and the one name the + * member must be served under instead. + */ + const MEMBER_CASES: Record; authored: unknown; shadows: string; servedAs: string }> = { + list: { member: { list: listView }, authored: listView, shadows: DEFAULT, servedAs: `${TASK}.${OWN}` }, + 'list#named': { + member: { list: { ...listView, name: 'in_progress' } }, authored: { ...listView, name: 'in_progress' }, + shadows: `${TASK}.in_progress`, servedAs: `${TASK}.${OWN}.in_progress`, + }, + 'listViews.*': { + member: { listViews: { in_progress: listView } }, authored: listView, + shadows: `${TASK}.in_progress`, servedAs: `${TASK}.${OWN}.in_progress`, + }, + form: { member: { form: formView }, authored: formView, shadows: `${TASK}.form`, servedAs: `${TASK}.${OWN}.form` }, + 'formViews.*': { + member: { formViews: { edit: formView } }, authored: formView, + shadows: `${TASK}.edit`, servedAs: `${TASK}.${OWN}.edit`, + }, + }; + + it('the enumeration covers every member kind the spec\'s expander places, and nothing else', () => { + expect(Object.keys(MEMBER_CASES).sort()).toEqual(memberKindsOfTheExpander()); + // Each probe really aims at a shipped name: without the fix it IS that name. + for (const [kind, c] of Object.entries(MEMBER_CASES)) { + const names = expandViewContainer(TASK, c.member).map((vi) => vi.name); + expect(names, kind).toEqual([c.shadows]); + expect(PACKAGED.map((v) => v.name), kind).toContain(c.shadows); + } + }); + + const KERNELS = [ + ['an environment-scoped kernel (the standalone stack stamps env_local)', 'env_local'], + ['an unscoped kernel (write-through hydrates the registry)', undefined], + ] as const; + const CONTAINERS = [ + { arm: 'package-scoped (saved into another writable package)', packageId: REPAIR, organizationId: undefined, ownPackage: REPAIR }, + { arm: 'package-less, environment-wide', packageId: undefined, organizationId: undefined, ownPackage: undefined }, + { arm: 'package-less, organization-scoped', packageId: undefined, organizationId: ORG, ownPackage: undefined }, + ] as const; + const save = (protocol: Protocol, name: string, item: unknown, c: (typeof CONTAINERS)[number]) => + protocol.saveMetaItem({ + type: 'view', name, item, + ...(c.packageId ? { packageId: c.packageId } : {}), + ...scoped(c.organizationId), + } as any); + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const c of CONTAINERS) { + for (const [kind, m] of Object.entries(MEMBER_CASES)) { + it(`${c.arm}, member ${kind}: no packaged name is replaced on either door, its own name is served with its own package, and it claims no default`, async () => { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + + // (a) + await expectEveryPackagedNameIntact(protocol, c.organizationId); + // (b) + const served = await objectDoor(protocol, c.organizationId); + const own = served.filter((v) => String(v.name).startsWith(`${TASK}.${OWN}`)); + expect(own.map((v) => v.name)).toEqual([m.servedAs]); + expect(own[0].object).toBe(TASK); + expect(own[0]._packageId).toBe(c.ownPackage); + expect(own[0]._provenance, 'never the packaged artifact\'s provenance').not.toBe('package'); + expect(own[0]._diagnostics?.valid, 'a qualified ViewItem name the spec accepts').toBe(true); + expect(own[0].config, 'the member as authored, nothing lent left on it').toEqual(m.authored); + // (c) + expect(own[0].isDefault).toBeUndefined(); + expectOnlyPackagedDefaults(served); + // The by-name door answers the container's own name with its row. + const row = await byNameDoor(protocol, OWN, c.organizationId); + expect(row?.object).toBe(TASK); + }); + } + + it(`${c.arm}: the card's probe — the packaged default unchanged on BOTH doors, and the object keeps ONE list default`, async () => { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, 'os_qa_shadow_probe', probe('os_qa_shadow_probe'), c); + + const listed = named(await objectDoor(protocol, c.organizationId), DEFAULT); + expect(listed, 'exactly one item answers .default on the object door').toHaveLength(1); + expectPackagedDefault(listed[0]); + const read = await byNameDoor(protocol, DEFAULT, c.organizationId); + expectPackagedDefault(read); + expect({ label: read.label, config: read.config, _packageId: read._packageId }) + .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); + const served = await objectDoor(protocol, c.organizationId); + expect(served.filter((v) => v.viewKind === 'list' && v.isDefault).map((v) => v.name)).toEqual([DEFAULT]); + expect(named(served, `${TASK}.os_qa_shadow_probe`)[0]?.config).toEqual(probe('os_qa_shadow_probe').list); + }); + } + + it('CONTROL — a container of the object\'s OWN package still expands to .default, as its default', async () => { + const { protocol, rows } = showcaseHarness(environmentId); + // A row bound to the package that owns the object (an installed + // package's own stored view), written straight to the store. + rows.set('own-pkg-row', { + id: 'r_own', type: 'view', name: 'os_qa_same_pkg', organization_id: null, + package_id: SHOWCASE, state: 'active', metadata: JSON.stringify(probe('os_qa_same_pkg')), + }); + + const listed = named(await objectDoor(protocol), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].config).toEqual(probe('os_qa_same_pkg').list); + expect(listed[0]._packageId).toBe(SHOWCASE); + expect(listed[0].isDefault).toBe(true); + expect(named(await objectDoor(protocol), `${TASK}.os_qa_same_pkg`)).toEqual([]); + }); + + it('CONTROL — a package-less overlay OF the package\'s own container keeps expanding to .default', async () => { + const { protocol } = showcaseHarness(environmentId); + // Name-keyed (ADR-0005): the row IS the overlay of the showcase's + // `showcase_task` container, so it stands in that package's slot. + const overlay = { name: TASK, list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] } }; + await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any); + + const listed = named(await objectDoor(protocol), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Customized'); + expect(listed[0]._packageId).toBe(SHOWCASE); + expect(listed[0].isDefault).toBe(true); + expect((await objectDoor(protocol)).filter((v) => String(v.name).startsWith(`${TASK}.${TASK}`))).toEqual([]); + }); + + it('CONTROL — the sanctioned override (a write to .default by name) is served on both doors', async () => { + const { protocol } = showcaseHarness(environmentId); + const override = { + name: DEFAULT, object: TASK, viewKind: 'list', label: 'Overridden', + config: { type: 'grid', data, columns: [{ field: 'title' }] }, + }; + await protocol.saveMetaItem({ type: 'view', name: DEFAULT, item: override } as any); + + const listed = named(await objectDoor(protocol), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Overridden'); + expect((await byNameDoor(protocol, DEFAULT)).label).toBe('Overridden'); + }); + }); + } + + it('the bare list keeps the spec\'s in-container de-duplication when a keyed view already takes the container\'s name', async () => { + const { protocol } = showcaseHarness('env_local'); + const container = { + name: 'probe_x', object: TASK, + list: { type: 'grid', columns: ['title', 'status'] }, + listViews: { probe_x: { label: 'Keyed', type: 'grid', columns: ['title'] } }, + }; + await protocol.saveMetaItem({ type: 'view', name: 'probe_x', item: container, packageId: REPAIR } as any); + + const served = await objectDoor(protocol); + expect(named(served, `${TASK}.probe_x.probe_x`)[0]?.label).toBe('Keyed'); + expect(named(served, `${TASK}.probe_x`)[0]?.config).toEqual(container.list); + await expectEveryPackagedNameIntact(protocol); + }); + + it('a container named after a key the owning package ships keeps its bare list off that name', async () => { + const { protocol } = showcaseHarness('env_local'); + await protocol.saveMetaItem({ type: 'view', name: 'in_progress', item: probe('in_progress'), packageId: REPAIR } as any); + + const served = await objectDoor(protocol); + expect(named(served, `${TASK}.in_progress.in_progress`)[0]?.config).toEqual(probe('in_progress').list); + expect(named(served, `${TASK}.in_progress.in_progress`)[0]?._packageId).toBe(REPAIR); + await expectEveryPackagedNameIntact(protocol); + }); + + it('a stored container with no name of its own expands nothing on another package\'s object', async () => { + const { protocol, rows } = showcaseHarness('env_local'); + const nameless = { object: TASK, list: { type: 'grid', columns: ['title'] }, listViews: { in_progress: { label: 'Mine', type: 'grid', columns: ['title'] } } }; + rows.set('nameless-row', { + id: 'r_nameless', type: 'view', name: 'os_qa_nameless', organization_id: null, + package_id: REPAIR, state: 'active', metadata: JSON.stringify(nameless), + }); + + const served = await objectDoor(protocol); + expect(served.filter((v) => v._packageId === REPAIR)).toEqual([]); + await expectEveryPackagedNameIntact(protocol); + }); +}); diff --git a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts new file mode 100644 index 00000000000..15cf7439c29 --- /dev/null +++ b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21334, ADR-0005, ADR-0126] A runtime view container with a bare `list`, +// saved for an object another package ships, must not replace that package's +// `.default` on the object door — over the real showcase composition, +// through the REST doors, following the card's own steps. +// +// ## What was broken +// +// The spec names a bare `list` `.default`. A container saved under any +// other name (here `os_qa_shadow_probe`, in a Studio-created package, and +// `os_qa_shadow_probe2`, in none) for `showcase_task` expanded there, and the +// list read set the expansion by name over the merged items. The object door +// then answered `showcase_task.default` with the probe's two columns, still +// stamped `_packageId: com.example.showcase`. On the standalone stack (an +// environment-scoped kernel) the by-name read kept the packaged item, so the +// two doors disagreed; on this harness's unscoped kernel the registry's bare +// key carried the shadow too, so both doors served it, and it outlived the +// container's own delete. +// +// ## The ruling these cases pin (triage's) +// +// The expansion of a bare `list` never produces a name another package owns: +// on another package's object it expands under the container's own name — +// `.`, the spec's qualified ViewItem spelling. The two +// doors answer the same row for `.default`: the packaged one, +// unchanged. The seat's answer extends it: every keyed member expands under +// `..`, and such a container never claims the +// object's default, so the object keeps ONE list default — the showcase's. Control: the sanctioned override, a write to +// `showcase_task.default` by name, still reaches both doors — run FIRST, on +// the pristine stack, so it never reads another case's residue. +// +// The same-package control (a container of the object's own package still +// expands to `.default`) and the environment-scoped topology are pinned +// in-process, in `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJECT = 'showcase_task'; +const DEFAULT = `${OBJECT}.default`; +const SHOWCASE = 'com.example.showcase'; +const REPAIR = 'com.example.repairassets'; +const PROBE_COLUMNS = ['title', 'status']; +const probe = (name: string) => ({ name, object: OBJECT, list: { type: 'grid', columns: PROBE_COLUMNS } }); + +interface ViewRow { + name: string; + label?: unknown; + isDefault?: boolean; + viewKind?: string; + _diagnostics?: { valid?: boolean }; + _packageId?: string; + _provenance?: string; + config?: { columns?: unknown[] }; + list?: unknown; +} + +describe('dogfood: a bare-list container on another package\'s object leaves its .default alone (#21334)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + /** `GET /api/v1/meta/view?object=showcase_task` — the object door. */ + const objectDoor = async (): Promise => { + const res = await stack.apiAs(token, 'GET', `/meta/view?object=${OBJECT}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { items?: ViewRow[] } | ViewRow[]; + return Array.isArray(body) ? body : (body.items ?? []); + }; + /** `GET /api/v1/meta/view/:name` — the by-name read. */ + const byName = async (name: string): Promise => { + const res = await stack.apiAs(token, 'GET', `/meta/view/${name}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { item?: ViewRow } & ViewRow; + return body.item ?? body; + }; + const named = (rows: ViewRow[], name: string) => rows.filter((r) => r.name === name); + + /** The packaged default, as the showcase ships it (`src/ui/views/task.view.ts`). */ + const expectPackagedDefault = (row: ViewRow | undefined) => { + expect(row?.label).toBe('All Tasks'); + expect(row?.config?.columns).toHaveLength(7); + expect(row?._packageId).toBe(SHOWCASE); + expect(row?.isDefault).toBe(true); + }; + /** Both doors answer exactly one, packaged, identical `showcase_task.default`. */ + const expectDefaultUnchangedOnBothDoors = async () => { + const listed = named(await objectDoor(), DEFAULT); + expect(listed, 'exactly one item answers showcase_task.default on the object door').toHaveLength(1); + expectPackagedDefault(listed[0]); + const read = await byName(DEFAULT); + expectPackagedDefault(read); + expect({ label: read.label, config: read.config, _packageId: read._packageId }) + .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); + }; + + /** The object keeps ONE list default — the showcase's own. */ + const expectOnlyThePackagedListDefault = async () => { + const defaults = (await objectDoor()).filter((r) => r.viewKind === 'list' && r.isDefault); + expect(defaults.map((r) => r.name), 'one list default, the packaged one').toEqual([DEFAULT]); + }; + + it('control, first: the sanctioned override — a write to showcase_task.default by name — reaches both doors', async () => { + // First, on the pristine stack, so no other case's residue can decide it. + const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, { + name: DEFAULT, + object: OBJECT, + viewKind: 'list', + label: 'Overridden', + config: { type: 'grid', columns: [{ field: 'title' }] }, + }); + expect(saved.status).toBe(200); + + const listed = named(await objectDoor(), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Overridden'); + const read = await byName(DEFAULT); + expect(read.label).toBe('Overridden'); + expect(read.config).toEqual(listed[0].config); + + // Deleting the override hands both doors back the packaged default. + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/${DEFAULT}`); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); + + it('steps 1–7: a container saved into another package never replaces the packaged default, on either door', async () => { + // 1. Baseline. + await expectDefaultUnchangedOnBothDoors(); + + // 2. A Studio-created package to author into. + const created = await stack.apiAs(token, 'POST', '/packages', { + manifest: { id: REPAIR, name: 'Repair assets', version: '0.1.0', type: 'app', namespace: 'repair' }, + }); + expect(created.status).toBe(201); + + // 3. The card's probe, a bare-list container for showcase_task, as a draft. + const saved = await stack.apiAs( + token, 'PUT', `/meta/view/os_qa_shadow_probe?mode=draft&package=${REPAIR}`, probe('os_qa_shadow_probe'), + ); + expect(saved.status).toBe(200); + + // 4. Publish it. + const published = await stack.apiAs(token, 'POST', `/packages/${REPAIR}/publish-drafts`, {}); + expect(published.status).toBe(200); + const receipt = (await published.json()) as { data?: { outcome?: string; publishedCount?: number } }; + expect(receipt.data?.outcome).toBe('published'); + expect(receipt.data?.publishedCount).toBe(1); + + // 5 + 6. The packaged default is unchanged on BOTH doors, and they answer + // the same row; the probe's own view is served under its own name. + await expectDefaultUnchangedOnBothDoors(); + const own = named(await objectDoor(), `${OBJECT}.os_qa_shadow_probe`); + expect(own).toHaveLength(1); + expect(own[0].config?.columns).toEqual(PROBE_COLUMNS); + expect(own[0]._packageId).toBe(REPAIR); + expect(own[0]._provenance).not.toBe('package'); + expect(own[0].isDefault, 'a container on another package\'s object claims no default').toBeUndefined(); + await expectOnlyThePackagedListDefault(); + // The by-name read answers the container's own name with its row. + expect((await byName('os_qa_shadow_probe')).list).toEqual(probe('os_qa_shadow_probe').list); + + // 7. Delete the probe: the packaged default is still what both doors answer. + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/os_qa_shadow_probe?package=${REPAIR}`); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); + + it('step 8: the same with no package — a package-less container leaves the packaged default alone', async () => { + const saved = await stack.apiAs(token, 'PUT', '/meta/view/os_qa_shadow_probe2', probe('os_qa_shadow_probe2')); + expect(saved.status).toBe(200); + + await expectDefaultUnchangedOnBothDoors(); + const own = named(await objectDoor(), `${OBJECT}.os_qa_shadow_probe2`); + expect(own).toHaveLength(1); + expect(own[0].config?.columns).toEqual(PROBE_COLUMNS); + expect(own[0]._packageId, 'a package-less container lends its view no package').toBeUndefined(); + expect(own[0].isDefault).toBeUndefined(); + await expectOnlyThePackagedListDefault(); + + const deleted = await stack.apiAs(token, 'DELETE', '/meta/view/os_qa_shadow_probe2'); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); + + it('a keyed member (listViews.in_progress) leaves the packaged showcase_task.in_progress alone, on both doors', async () => { + const SHIPPED = `${OBJECT}.in_progress`; + const before = named(await objectDoor(), SHIPPED); + expect(before).toHaveLength(1); + expect(before[0]._packageId).toBe(SHOWCASE); + const readBefore = await byName(SHIPPED); + + const saved = await stack.apiAs(token, 'PUT', `/meta/view/os_qa_keyed_probe?package=${REPAIR}`, { + name: 'os_qa_keyed_probe', + object: OBJECT, + listViews: { in_progress: { label: 'Probe keyed', type: 'grid', columns: ['title'] } }, + }); + expect(saved.status).toBe(200); + + const after = named(await objectDoor(), SHIPPED); + expect(after, 'exactly one item answers showcase_task.in_progress on the object door').toHaveLength(1); + expect({ label: after[0].label, config: after[0].config, _packageId: after[0]._packageId }) + .toEqual({ label: before[0].label, config: before[0].config, _packageId: SHOWCASE }); + const readAfter = await byName(SHIPPED); + expect({ label: readAfter.label, config: readAfter.config, _packageId: readAfter._packageId }) + .toEqual({ label: readBefore.label, config: readBefore.config, _packageId: SHOWCASE }); + + // Served under the container's own name, carrying its own package. + const own = named(await objectDoor(), `${OBJECT}.os_qa_keyed_probe.in_progress`); + expect(own).toHaveLength(1); + expect(own[0].label).toBe('Probe keyed'); + expect(own[0]._packageId).toBe(REPAIR); + expect(own[0]._provenance).not.toBe('package'); + expect(own[0]._diagnostics?.valid).toBe(true); + expect(own[0].isDefault).toBeUndefined(); + await expectOnlyThePackagedListDefault(); + + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/os_qa_keyed_probe?package=${REPAIR}`); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); +});