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
12 changes: 12 additions & 0 deletions .changeset/28093-residual-top-level-items-register.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/objectql': patch
---

fix(objectql): every item a composed stack declares only on its top level, beside package bodies that do not declare it, registers in the engine at boot the way a package body's item of that type does

Clause-②: yes (widening)
Metadata-protocol: none

The engine's `manifest` service registered a multi-package stack's residual (top-level items no package body declares, ADR-0130 D4) objects, connectors and flows under the stack's own `manifest.id`, and no other type. A residual permission set, position, capability, sharing rule, queue, assignment rule, app, view, page, dashboard, report, action, hook, agent, tool, skill, API endpoint, webhook, email template, doc and every other collection a package body registers was held by the metadata service alone, so a reader that asks the engine registry answered as if it were not declared. Among those readers is the metadata save door's managed-content check: a residual top-level security rule was not sealed against an environment save the way the same rule declared in a package body is; it now is.

Each such item now registers under the stack's manifest id and version, through the judges a body's item of that type passes: a view container is filed under its object and expanded into its views after the divergent-`name` judge, a `views:` entry that is not a container is refused as a body's is (422 `INVALID_METADATA`), a permission set whose `packageId` names another package is refused (ADR-0086 D3), an action is parsed against its schema, and the boot's later checks (the ownership-queue service check for queues and assignment rules, the bound-action target audit) now cover residual items too, so a residual queue on a composition without an ownership-queue service fails the boot with the prescription a body's does. Picklists stay metadata-only, as the residual objects rule already states. A top-level item a package body declares is still registered once, as that body's; stacks with no residual item, and single-package stacks, are unchanged.
158 changes: 157 additions & 1 deletion packages/objectql/src/manifest-service-residual-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
import type { IMetadataService } from '@objectstack/spec/contracts';
import { OWNERSHIP_QUEUE_SERVICE, type IMetadataService } from '@objectstack/spec/contracts';
import { ObjectQLPlugin } from './plugin.js';
import type { ObjectQL } from './engine.js';
import { viewContainerNameRefusal } from './view-container-name-refusal.js';

type ManifestService = { register(m: unknown): void | Promise<void> };

Expand Down Expand Up @@ -386,3 +387,158 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s flows',
expect(flowOwners(engineOf(kernel))).toEqual([['acme_escalate', SERVICE_ID, '2.4.0']]);
});
});

/**
* [ADR-0130 D4] EVERY other residual type a package body's collections take
* into the registry reaches it too, under the stack's manifest id and version:
* the engine registered a residual's objects, connectors and flows only, so a
* residual item of any other type was registered by the metadata door alone
* and every reader that asks the registry — the save door's managed-content
* seal among them — answered as if it were not declared. Two residual types
* keep a path of their own and are not in this population: objects (above) and
* picklists, which stay metadata-only for the reason the objects rule names.
*/
describe('ADR-0130 D4 — the manifest service registers every other residual type', () => {
/** The stack collection of each type, with a minimal named item. */
const GENERIC: Record<string, string> = {
apps: 'app', pages: 'page', dashboards: 'dashboard', reports: 'report', actions: 'action',
workflows: 'workflow', positions: 'position', permissions: 'permission', capabilities: 'capability',
sharingRules: 'sharing_rule', apis: 'api', webhooks: 'webhook', agents: 'agent', tools: 'tool',
skills: 'skill', ragPipelines: 'rag_pipeline', hooks: 'hook', mappings: 'mapping',
analyticsCubes: 'analytics_cube', emailTemplates: 'email_template', docs: 'doc', books: 'book',
};
const itemOf = (type: string) => ({ name: `acme_top_${type}`, label: `Top ${type}` });
/** The registry parses an action on registration, so the residual's action is a valid one too. */
const ITEM_EXTRA: Record<string, Record<string, unknown>> = { actions: { type: 'url', target: 'https://example.com' } };
const collectionsOf = (pick: (type: string) => boolean = () => true) => Object.fromEntries(
Object.entries(GENERIC).filter(([, type]) => pick(type))
.map(([field, type]) => [field, [{ ...itemOf(type), ...ITEM_EXTRA[field] }]]),
);
/**
* A package BODY is strictly parsed on registration, so the pins that put an
* item on a body take a schema-valid one: every security-domain type, and
* one of each other kind the body schema admits with a short item.
*/
const BODY_VALID: Record<string, Record<string, unknown>> = {
permissions: { objects: {} },
positions: {},
capabilities: {},
sharingRules: {
type: 'criteria', object: 'acme_case', condition: "record.subject == 'x'", accessLevel: 'read',
sharedWith: { type: 'position', value: 'acme_top_position' },
},
apps: {},
pages: {},
actions: { type: 'url', target: 'https://example.com' },
hooks: { object: 'acme_case', events: ['beforeInsert'] },
agents: { role: 'Triage', instructions: 'Triage cases.' },
webhooks: { object: 'acme_case', url: 'https://example.com/hook', triggers: ['create'] },
emailTemplates: { subject: 'Subject', bodyHtml: '<p>Body</p>' },
};
const bodyCollections = () => Object.fromEntries(
Object.entries(BODY_VALID).map(([field, extra]) => [field, [{ ...itemOf(GENERIC[field]), ...extra }]]),
);
const ownerOf = (ql: ObjectQL, type: string, name: string) => {
const item = ql.registry.getItem(type, name) as { _packageId?: string; _packageVersion?: string } | undefined;
return item ? [item._packageId, item._packageVersion] : undefined;
};
const namesOf = (ql: ObjectQL, type: string) =>
(ql.registry.listItems(type) as Array<{ name?: string }>).map((i) => i.name).filter((n) => n?.startsWith('acme_')).sort();

it.each(Object.values(GENERIC))('registers a residual %s under the stack\'s manifest id and version', async (type) => {
const { kernel } = await boot(registering('app', [residualStack(noteObj(), collectionsOf((t) => t === type))]));
expect(ownerOf(engineOf(kernel), type, `acme_top_${type}`)).toEqual([RELEASE_ID, '1.0.0']);
});

it('registers every one of them from one stack, and leaves the artifact\'s own entries as they arrived', async () => {
const collections = collectionsOf();
const { kernel } = await boot(registering('app', [residualStack(noteObj(), collections)]));
const ql = engineOf(kernel);
for (const type of Object.values(GENERIC)) {
expect(ownerOf(ql, type, `acme_top_${type}`), type).toEqual([RELEASE_ID, '1.0.0']);
}
for (const [field] of Object.entries(GENERIC)) {
expect((collections[field] as Array<{ _packageId?: string }>)[0]._packageId, field).toBeUndefined();
}
});

it('a residual view container registers under its object, expanded into its views, like a body\'s', async () => {
const container = { object: 'acme_note', list: { type: 'grid', columns: ['title'] } };
const { kernel } = await boot(registering('app', [residualStack(noteObj(), { views: [container] })]));
const ql = engineOf(kernel);
const views = ql.registry.listItems('view') as Array<{ name?: string; _packageId?: string }>;
const noteViews = views.filter((v) => v.name === 'acme_note' || v.name?.startsWith('acme_note.'));
expect(noteViews.map((v) => v.name)).toContain('acme_note');
expect(noteViews.length).toBeGreaterThan(1);
expect(new Set(noteViews.map((v) => v._packageId))).toEqual(new Set([RELEASE_ID]));
expect((container as { _packageId?: string })._packageId).toBeUndefined();
});

it('a residual view container whose own `name` disagrees with its object is refused, in the body seam\'s words', async () => {
const container = { name: 'notes_grid', object: 'acme_note', list: { type: 'grid', columns: ['title'] } };
const expected = viewContainerNameRefusal(container, 'manifest', RELEASE_ID);
expect(expected).toBeDefined();
await expect(boot(registering('app', [residualStack(noteObj(), { views: [container] })])))
.rejects.toThrow(expected!.message);
});

it('a residual view entry with no `name` is filed under its slot, as the body seam files it', async () => {
const entry = { object: 'acme_note', label: 'Notes' };
const { kernel } = await boot(registering('app', [residualStack(noteObj(), { views: [entry] })]));
expect(ownerOf(engineOf(kernel), 'view', 'acme_note')).toEqual([RELEASE_ID, '1.0.0']);
expect((entry as { name?: string }).name).toBeUndefined();
});

it.each([
['a flattened overlay with no `name`', { object: 'acme_note', type: 'grid', columns: ['title'] }, 'acme_note'],
['a ViewItem', { name: 'acme_note_item', object: 'acme_note', viewKind: 'list', config: { type: 'grid' } }, 'acme_note_item'],
])('a residual `views:` entry that is not a container (%s) is refused 422, in the body seam\'s words', async (_label, entry, name) => {
const refusal = await boot(registering('app', [residualStack(noteObj(), { views: [entry] })])).then(() => undefined, (e: unknown) => e);
expect(refusal).toMatchObject({ code: 'INVALID_METADATA', status: 422, httpStatus: 422 });
expect((refusal as Error).message).toContain(
`Invalid \`views:\` entry '${name}' from manifest '${RELEASE_ID}': the stack \`views:\` collection carries view CONTAINERS only.`,
);
});

it('a residual queue and assignment rule register when the ownership-queue service is composed', async () => {
const queues = { queues: [itemOf('queue')], assignmentRules: [itemOf('assignment_rule')] };
const queueService: Plugin = {
name: 'test.ownership-queue',
init: async (ctx: PluginContext) => { ctx.registerService(OWNERSHIP_QUEUE_SERVICE, {}); },
} as Plugin;
const { kernel } = await boot(queueService, registering('app', [residualStack(noteObj(), queues)]));
const ql = engineOf(kernel);
expect(ownerOf(ql, 'queue', 'acme_top_queue')).toEqual([RELEASE_ID, '1.0.0']);
expect(ownerOf(ql, 'assignment_rule', 'acme_top_assignment_rule')).toEqual([RELEASE_ID, '1.0.0']);
});

it('…and, like a body\'s, refuse the boot loudly when it is not', async () => {
const queues = { queues: [itemOf('queue')], assignmentRules: [itemOf('assignment_rule')] };
await expect(boot(registering('app', [residualStack(noteObj(), queues)])))
.rejects.toThrow(/declares queues \(1\) and assignmentRules \(1\), but no 'ownership-queue' service is registered/);
});

it('a residual permission set whose body `packageId` names another package is refused, as a body\'s is', async () => {
const set = { ...itemOf('permission'), objects: {}, packageId: OTHER_ID };
await expect(boot(registering('app', [residualStack(noteObj(), { permissions: [set] })])))
.rejects.toThrow(/Drop `packageId` from permission set 'acme_top_permission'/);
});

it('a top-level item a body repeats is the body\'s, registered once', async () => {
const { kernel } = await boot(registering('app', [residualStack(noteObj(), bodyCollections(), bodyCollections())]));
const ql = engineOf(kernel);
for (const type of Object.keys(BODY_VALID).map((field) => GENERIC[field])) {
expect(ownerOf(ql, type, `acme_top_${type}`), type).toEqual([SERVICE_ID, '2.4.0']);
expect(namesOf(ql, type), type).toEqual([`acme_top_${type}`]);
}
});

it('CONTROL: a stack with no residual item of these types registers exactly the bodies\' items', async () => {
const { kernel } = await boot(registering('app', [residualStack(noteObj(), {}, bodyCollections())]));
const ql = engineOf(kernel);
for (const type of Object.keys(BODY_VALID).map((field) => GENERIC[field])) {
expect(namesOf(ql, type), type).toEqual([`acme_top_${type}`]);
expect(ownerOf(ql, type, `acme_top_${type}`), type).toEqual([SERVICE_ID, '2.4.0']);
}
});
});
Loading
Loading