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
55 changes: 55 additions & 0 deletions .changeset/nested-plugin-view-container-expansion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@objectstack/objectql": patch
---

fix(objectql): a nested plugin expands an aggregated `views` container — its per-view items reached no registry

**This changes boot behaviour for packages that ship views through
`manifest.plugins[]`.** "Object has-many View" (ADR-0017 §2, §3.2) makes the
loader **dual-read**: an aggregated `defineView` container is registered under
the bare `<object>` key for back-compatible reads AND expanded into independent
`ViewItem`s under `<object>.<viewKey>`. Only the expanded items carry
`viewKind`, and `getViewsByObject()` filters on exactly that — so the expanded
layer, not the container, is what `GET /meta/view?object=`, the runtime view
switcher and Studio's package attribution actually read.

`engine.ts` reaches the registration seam from two entry points, and only the
manifest one expanded. One container measured through each:

```
via manifest → ['account', 'account.all_accounts', 'account.form']
via nested plugin → ['account']
```

No refusal and no diagnostic: a package whose views arrived through a nested
plugin registered the container and nothing else, so every reader of the
expanded layer saw an object with no views at all. After this change both seams
answer the same, and those packages' view switchers begin working. Anything that
has been compensating for the silence — a duplicate `views:` hoisted to the
top-level manifest — now finds the views already registered.

The direction was measured rather than assumed, because the divergence had two
coherent readings. ADR-0017 states the dual-read as a property of "the loader"
at load time, not of one entry point; the OTHER loader agrees with the manifest
seam (`MetadataPlugin`'s artifact/HMR path expands too, which is why the shared
implementation lives in `@objectstack/spec` — "so the two loaders cannot
drift"); every authored stack in the tree ships `views` at manifest top level,
so removing the manifest seam's expansion would take the switcher away from all
of them; and no in-tree package ships `views` through a nested plugin, so the
seam that GAINS behaviour here breaks nobody. One direction is load-bearing for
real consumers and the other is not.

So, as with #7049, the copies are gone rather than reconciled: both seams now
run one `registerMetadataCollections()` body. #7049 hoisted the shared
`METADATA_ARRAY_KEYS` and measured the loop bodies on the way past, recording
that they still differed in a per-key `debug` line, this view expansion, and a
warn-on-nameless-item — sharing the list made "which collections does a seam
see?" unanswerable-differently while leaving "what does a seam DO with a
collection both see?" answered in two places. Both remaining differences had the
same structure, a body copied then improved on one side only, so the body is
shared too: a nested plugin now also emits the skipping-a-nameless-item warning
it used to swallow. `engine-nested-plugin-collections.test.ts`'s `views`
exclusion row — the only one that was ever a behaviour difference rather than a
retired kind — is removed with the divergence.

Refs: #7163, #7049, #6242, #5870, ADR-0017, ADR-0010.
24 changes: 14 additions & 10 deletions packages/objectql/src/engine-nested-plugin-collections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,14 @@ describe('the two registration seams enumerate ONE collection list (#7049)', ()
* make the test pass — see each entry.
*/
const NOT_COMPARABLE: ReadonlyArray<readonly [key: string, why: string]> = [
// `views` has no top-level `name` and the manifest seam additionally expands
// an aggregated container into per-view items (ADR-0017); that expansion is
// a LOOP-BODY difference between the seams, not an enumeration difference,
// and closing it changes what a nested plugin serves. Measured while closing
// this card and filed as #7163 rather than folded in: one container
// registers `['account', 'account.all_accounts', 'account.form']` from a
// manifest and `['account']` from a nested plugin.
['views', 'manifest seam additionally expands aggregated containers (ADR-0017) — #7163'],
// `views` used to sit here: the manifest seam expanded an aggregated
// container into per-view items (ADR-0017) and the nested seam did not — a
// LOOP-BODY difference rather than an enumeration one, so this card filed it
// as #7163 instead of folding it in. #7163 closed it by sharing the body
// (`registerMetadataCollections()`), so `views` is comparable now and is
// back in `CANDIDATES` below; the aggregated-container half of its parity is
// pinned in `engine-nested-plugin-view-expansion.test.ts`.
//
// Retired kinds the loop still iterates; the schema rejects the keys long
// before either seam runs, so a fixture cannot exercise them (the gate
// carries them as an `extra` waiver row for the same reason).
Expand All @@ -184,7 +184,7 @@ describe('the two registration seams enumerate ONE collection list (#7049)', ()
* behaviour and not the same literal the implementation reads.
*/
const CANDIDATES = [
'actions', 'pages', 'dashboards', 'reports', 'datasets', 'themes',
'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes',
'flows', 'webhooks', 'jobs',
'permissions', 'capabilities', 'sharingRules',
'agents', 'tools', 'skills', 'apis',
Expand Down Expand Up @@ -215,8 +215,12 @@ describe('the two registration seams enumerate ONE collection list (#7049)', ()

it('records why each excluded collection is not comparable, rather than dropping it silently', () => {
for (const [, why] of NOT_COMPARABLE) expect(why.length).toBeGreaterThan(0);
// `views` left this list in #7163 — the only entry that was ever excluded
// for a BEHAVIOUR difference rather than a retired-kind one. Every survivor
// is a kind the schema rejects before either seam runs.
expect(NOT_COMPARABLE.map(([k]) => k)).toEqual([
'views', 'workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines',
'workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines',
]);
expect(NOT_COMPARABLE.map(([k]) => k)).not.toContain('views');
});
});
231 changes: 231 additions & 0 deletions packages/objectql/src/engine-nested-plugin-view-expansion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#7163] A nested plugin EXPANDS an aggregated view container, exactly as a
* manifest does.
*
* ## What was broken
*
* "Object has-many View" (ADR-0017 §2, §3.2) makes the loader **dual-read**: an
* aggregated `defineView` container is registered under the bare `<object>` key
* for back-compatible reads AND expanded into independent `ViewItem`s under
* `<object>.<viewKey>`. Only the expanded items carry `viewKind`, and
* `getViewsByObject()` filters on exactly that — so the expanded layer, not the
* container, is what `GET /meta/view?object=`, the runtime view switcher and
* Studio's package attribution actually read.
*
* `engine.ts` reaches the registration seam from two entry points. #7049 hoisted
* the shared `METADATA_ARRAY_KEYS` so both seams ENUMERATE `views`, but only the
* manifest seam's loop body expanded:
*
* via manifest → ['account', 'account.all_accounts', 'account.form']
* via nested plugin → ['account']
*
* No refusal and no diagnostic — a package shipping its views through
* `manifest.plugins[]` simply had no views as far as every reader of the
* expanded layer was concerned.
*
* ## Why the nested seam gained the expansion, rather than the manifest seam
* ## losing it
*
* The divergence had two coherent readings and the card deliberately picked
* neither. Measured on `main` before choosing:
*
* - ADR-0017 §2 states the dual-read as a property of "the loader" at load
* time, not of one entry point, and §3.2 spells out that it registers BOTH.
* - The OTHER loader agrees with the manifest seam: `MetadataPlugin`'s artifact
* /HMR path expands too (`packages/metadata/src/plugin.ts`), which is why
* the shared implementation was pushed down into `@objectstack/spec` in the
* first place — "so the two loaders cannot drift" (`ui/view.zod.ts`).
* - Every authored stack in the tree ships `views` at manifest TOP level
* (~51 files: `examples/app-crm`, `app-todo`, `app-showcase`,
* `packages/qa/downstream-contract`, …). Removing the manifest seam's
* expansion would take the view switcher away from all of them.
* - No in-tree package ships `views` through a nested plugin — swept across
* `examples/`, `apps/` and `packages/`, zero hits. So the seam that gains
* behaviour here breaks nobody in-tree, while the seam that would lose it
* breaks everybody.
*
* One direction is load-bearing for real consumers and the other is not, so the
* nested seam catches up — the same direction #7049 took for the enumeration.
*
* Refs: #7163, #7049 (the enumeration half + the exclusion row this retires),
* ADR-0017 (Object has-many View), ADR-0010 (provenance envelope).
*/

import { describe, it, expect } from 'vitest';
import { ObjectQL } from './engine';

const PKG = 'com.acme.sales';

/**
* The card's measured fixture: one aggregated container for `account`, with a
* named list view and a default form. Shaped like `examples/app-crm`'s view
* modules — the container carries no top-level `name`, so its registry key is
* resolved from `list.data.object` (ADR-0017 §3.1: `ViewSchema` has no `name`).
*
* The default `list` deliberately RESTATES `listViews.all_accounts` verbatim —
* the common "default == the named view" authoring pattern the expander
* collapses by structural signature. That is what makes the expansion exactly
* the card's measured `['account', 'account.all_accounts', 'account.form']`
* rather than carrying a separate `account.default`.
*/
function accountContainer() {
const allAccounts = {
label: 'All Accounts',
type: 'grid',
data: { provider: 'object', object: 'account' },
columns: [{ field: 'name' }],
};
return {
list: { ...allAccounts },
listViews: { all_accounts: { ...allAccounts } },
form: {
type: 'simple',
data: { provider: 'object', object: 'account' },
sections: [{ label: 'Info', fields: [{ field: 'name' }] }],
},
};
}

/** The same container declared directly on the manifest — the reference path. */
function viaManifest() {
return { id: PKG, name: 'sales', views: [accountContainer()] };
}

/** …and arriving ONLY through a nested plugin (`manifest.plugins[]`). */
function viaNestedPlugin() {
return { id: PKG, name: 'sales', plugins: [{ name: 'sales-nested', views: [accountContainer()] }] };
}

function viewItems(engine: ObjectQL): any[] {
return (engine.registry.listItems<any>('view') ?? [])
.map((i: any) => i?.content ?? i)
.filter(Boolean);
}

function viewNames(engine: ObjectQL): string[] {
return viewItems(engine).map((v: any) => v.name).sort();
}

function boot(manifest: unknown): ObjectQL {
const engine = new ObjectQL();
engine.registerApp(manifest as any);
return engine;
}

describe('aggregated view container — the two seams register the SAME thing (#7163)', () => {
it('expands a nested plugin\'s container into per-view items', () => {
// Before #7163 this was exactly `['account']` — the container alone.
expect(viewNames(boot(viaNestedPlugin()))).toEqual([
'account',
'account.all_accounts',
'account.form',
]);
});

it('registers identically whether the container arrives via manifest or nested plugin', () => {
// The parity pin — the card's whole point. Stated as manifest-vs-nested
// equality rather than against a literal, so a change to the expansion
// rules moves BOTH seams or fails here.
expect(viewNames(boot(viaNestedPlugin()))).toEqual(viewNames(boot(viaManifest())));
});

it('keeps the bare <object> container registered alongside the expansion (ADR-0017 dual-read)', () => {
// Back-compat half: expanding must not replace the container. Both seams.
for (const manifest of [viaManifest(), viaNestedPlugin()]) {
const names = viewNames(boot(manifest));
expect(names).toContain('account');
expect(names.filter((n) => n.startsWith('account.'))).not.toHaveLength(0);
}
});
});

describe('the expanded per-view identities a nested plugin now produces (#7163)', () => {
const items = viewItems(boot(viaNestedPlugin()));
const byName = Object.fromEntries(items.map((v: any) => [v.name, v]));

it('gives every expanded item the `viewKind` `getViewsByObject()` filters on', () => {
// This is the property the whole card turns on: the container carries NO
// `viewKind`, so a registry holding only the container answers empty to
// `getViewsByObject()` / `GET /meta/view?object=` — silently.
expect(byName['account'].viewKind).toBeUndefined();
expect(byName['account.all_accounts'].viewKind).toBe('list');
expect(byName['account.form'].viewKind).toBe('form');

const readable = items.filter((v: any) => v.viewKind && v.object === 'account');
expect(readable.map((v: any) => v.name).sort()).toEqual([
'account.all_accounts',
'account.form',
]);
});

it('binds each expanded item to its object and stamps scope=package', () => {
for (const name of ['account.all_accounts', 'account.form']) {
expect(byName[name].object).toBe('account');
expect(byName[name].scope).toBe('package');
}
});

it('carries the container\'s config through to the expanded item', () => {
expect(byName['account.all_accounts'].config.type).toBe('grid');
expect(byName['account.all_accounts'].label).toBe('All Accounts');
expect(byName['account.all_accounts'].config.columns).toEqual([{ field: 'name' }]);
expect(byName['account.form'].config.sections[0].label).toBe('Info');
// The named entry absorbed the structurally identical default `list`, so it
// is the declared default of its family.
expect(byName['account.all_accounts'].isDefault).toBe(true);
});

it('stamps ADR-0010 provenance on the expanded items, owned by the PARENT package', () => {
// A nested plugin contributes under its parent's ownership — the parent
// already claimed the namespace (same rule #7049 pinned for the four
// collections). The expansion must not bypass that stamp.
for (const name of ['account', 'account.all_accounts', 'account.form']) {
expect(byName[name]._packageId, `'${name}' reached the registry unstamped`).toBe(PKG);
expect(byName[name]._provenance).toBe('package');
}
});

it('produces the same expanded identities the manifest seam produces', () => {
const fromManifest = Object.fromEntries(
viewItems(boot(viaManifest())).map((v: any) => [v.name, v]),
);
for (const name of ['account.all_accounts', 'account.form']) {
expect(byName[name].viewKind).toBe(fromManifest[name].viewKind);
expect(byName[name].object).toBe(fromManifest[name].object);
expect(byName[name].isDefault).toBe(fromManifest[name].isDefault);
expect(byName[name].order).toBe(fromManifest[name].order);
expect(byName[name].config).toEqual(fromManifest[name].config);
}
});
});

describe('control — a NON-aggregated view is unchanged by this card (#7163)', () => {
/**
* The fix is scoped by `isAggregatedViewContainer`, which is false for an
* already-independent `ViewItem` (it carries `viewKind`). Such a view must
* register exactly once, under its own name, through BOTH seams — no
* expansion, no new keys. This is what says the change is additive and only
* on the container shape.
*/
const viewItem = {
name: 'account.hot',
object: 'account',
viewKind: 'list',
config: { type: 'grid', columns: [{ field: 'name' }] },
};

it('registers a standalone ViewItem identically from both seams, with no expansion', () => {
const direct = boot({ id: PKG, name: 'sales', views: [viewItem] });
const nested = boot({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] });

expect(viewNames(nested)).toEqual(['account.hot']);
expect(viewNames(nested)).toEqual(viewNames(direct));
});

it('leaves a container-free manifest with no view items at all', () => {
expect(viewNames(boot({ id: PKG, name: 'sales' }))).toEqual([]);
expect(viewNames(boot({ id: PKG, name: 'sales', plugins: [{ name: 'p' }] }))).toEqual([]);
});
});
Loading
Loading