|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0130 D4 — the engine's `manifest` service registers the OBJECTS of a |
| 5 | + * multi-package stack's residual: top-level objects no package body declares, |
| 6 | + * under the id the residual rule names (`unclaimedTopLevel`, |
| 7 | + * `@objectstack/metadata`). |
| 8 | + * |
| 9 | + * ## The defect these pin |
| 10 | + * |
| 11 | + * The metadata door registers a stack's residual under the stack's own |
| 12 | + * `manifest.id` and the boot warns that every door will report that id as |
| 13 | + * the items' owner. The `manifest` service read the package BODIES only |
| 14 | + * (`resolveArtifactPackageOrder` answers `packages[]` when the key is |
| 15 | + * present), so a residual object never reached the SchemaRegistry: listed by |
| 16 | + * `GET /meta/object`, `404` on `/data/<name>`, through both boot doors. The |
| 17 | + * two-boot reading is `packages/cli/test/serve-config-boot-residual.integration.test.ts` |
| 18 | + * (row 4); these pins hold the engine half on a kernel. |
| 19 | + * |
| 20 | + * ## What the shape of each pin is for |
| 21 | + * |
| 22 | + * - The residual object is RESOLVED by the registry and OWNED by the stack's |
| 23 | + * manifest id — what the data door reads. |
| 24 | + * - CONTROL: the bodies' objects keep their own owners, and no package record |
| 25 | + * appears for the residual's id. The residual is not a package; when its id |
| 26 | + * names a body (a composed stack keeps one member's manifest) that body's |
| 27 | + * record must stay the body's. |
| 28 | + * - A late artifact (after `start()`) reaches the metadata service too, and is |
| 29 | + * judged against the sealed picklist vocabulary like a body. |
| 30 | + * |
| 31 | + * Payloads are handed over the way `AppPlugin` hands them: |
| 32 | + * `{ ...bundle.manifest, ...bundle }`. |
| 33 | + */ |
| 34 | + |
| 35 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 36 | +import { ObjectKernel } from '@objectstack/core'; |
| 37 | +import type { IMetadataService } from '@objectstack/spec/contracts'; |
| 38 | +import { ObjectQLPlugin } from './plugin.js'; |
| 39 | +import type { ObjectQL } from './engine.js'; |
| 40 | + |
| 41 | +type ManifestService = { register(m: unknown): void | Promise<void> }; |
| 42 | + |
| 43 | +const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService<ObjectQL>('objectql'); |
| 44 | + |
| 45 | +const APP_ID = 'com.example.acme'; |
| 46 | +const SERVICE_ID = 'com.example.acme.service'; |
| 47 | +const RELEASE_ID = 'com.example.acme.release'; |
| 48 | + |
| 49 | +const caseObj = () => ({ name: 'acme_case', label: 'Case', sharingModel: 'private', fields: { subject: { type: 'text', label: 'Subject' } } }); |
| 50 | +const accountObj = () => ({ name: 'acme_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }); |
| 51 | +const noteObj = (fields: Record<string, unknown> = { title: { type: 'text', label: 'Title' } }) => |
| 52 | + ({ name: 'acme_note', label: 'Note', sharingModel: 'private', fields }); |
| 53 | +const svc = { id: SERVICE_ID, name: 'service', namespace: 'acme', version: '2.4.0', type: 'module' }; |
| 54 | +const app = { id: APP_ID, name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' }; |
| 55 | + |
| 56 | +/** What `AppPlugin.init` hands the `manifest` service. */ |
| 57 | +const payload = (bundle: Record<string, unknown>) => ({ ...(bundle.manifest as object), ...bundle }); |
| 58 | + |
| 59 | +/** Two packages, a `manifest.id` naming neither, and a top-level `acme_note` no body declares. */ |
| 60 | +const residualStack = (note = noteObj()) => payload({ |
| 61 | + manifest: { ...app, id: RELEASE_ID }, |
| 62 | + objects: [caseObj(), accountObj(), note], |
| 63 | + packages: [ |
| 64 | + { manifest: { ...svc, objects: [caseObj()] } }, |
| 65 | + { manifest: { ...app, objects: [accountObj()] } }, |
| 66 | + ], |
| 67 | +}); |
| 68 | + |
| 69 | +const owners = (ql: ObjectQL): Record<string, string | undefined> => |
| 70 | + Object.fromEntries( |
| 71 | + ['acme_account', 'acme_case', 'acme_note'].map((name) => [name, ql.registry.getObjectOwner(name)?.packageId]), |
| 72 | + ); |
| 73 | + |
| 74 | +const acmePackageIds = (ql: ObjectQL): string[] => |
| 75 | + ql.registry |
| 76 | + .getAllPackages() |
| 77 | + .map((p) => p.manifest?.id) |
| 78 | + .filter((id: unknown): id is string => typeof id === 'string' && id.startsWith('com.example.')); |
| 79 | + |
| 80 | +let kernel: ObjectKernel | undefined; |
| 81 | + |
| 82 | +async function bootKernel(): Promise<ObjectKernel> { |
| 83 | + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); |
| 84 | + await kernel.use(new ObjectQLPlugin()); |
| 85 | + await kernel.bootstrap(); |
| 86 | + return kernel; |
| 87 | +} |
| 88 | + |
| 89 | +afterEach(async () => { |
| 90 | + if (kernel && kernel.getState() === 'running') await kernel.shutdown(); |
| 91 | + kernel = undefined; |
| 92 | +}); |
| 93 | + |
| 94 | +describe('ADR-0130 D4 — the manifest service registers the residual\'s objects', () => { |
| 95 | + it('registers a residual object under the stack\'s manifest id, beside the bodies\' own', async () => { |
| 96 | + const k = await bootKernel(); |
| 97 | + await (k.getService('manifest') as ManifestService).register(residualStack()); |
| 98 | + const ql = engineOf(k); |
| 99 | + |
| 100 | + // Before: `acme_note` resolved to nothing — the data door's 404. |
| 101 | + expect(ql.registry.resolveObject('acme_note')?.fields?.title).toBeDefined(); |
| 102 | + expect(owners(ql)).toEqual({ |
| 103 | + acme_account: APP_ID, |
| 104 | + acme_case: SERVICE_ID, |
| 105 | + acme_note: RELEASE_ID, |
| 106 | + }); |
| 107 | + expect((ql.registry.resolveObject('acme_note') as { _packageId?: string } | undefined)?._packageId).toBe(RELEASE_ID); |
| 108 | + }); |
| 109 | + |
| 110 | + it('installs no package record for the residual\'s id — the residual is not a package', async () => { |
| 111 | + const k = await bootKernel(); |
| 112 | + await (k.getService('manifest') as ManifestService).register(residualStack()); |
| 113 | + expect(acmePackageIds(engineOf(k)).sort()).toEqual([APP_ID, SERVICE_ID]); |
| 114 | + }); |
| 115 | + |
| 116 | + it('a composed stack whose manifest names a body: the residual takes that id, and the body keeps its own record', async () => { |
| 117 | + const k = await bootKernel(); |
| 118 | + await (k.getService('manifest') as ManifestService).register(payload({ |
| 119 | + manifest: app, |
| 120 | + objects: [caseObj(), accountObj(), noteObj()], |
| 121 | + packages: [ |
| 122 | + { manifest: { ...svc, objects: [caseObj()] } }, |
| 123 | + { manifest: { ...app, objects: [accountObj()] } }, |
| 124 | + ], |
| 125 | + })); |
| 126 | + const ql = engineOf(k); |
| 127 | + |
| 128 | + expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: APP_ID }); |
| 129 | + // The app body's record is the app body, not the residual: its own |
| 130 | + // objects, still exactly one. |
| 131 | + const record = ql.registry.getAllPackages().find((p) => p.manifest?.id === APP_ID); |
| 132 | + expect((record?.manifest as { objects?: Array<{ name?: string }> } | undefined)?.objects?.map((o) => o.name)) |
| 133 | + .toEqual(['acme_account']); |
| 134 | + }); |
| 135 | + |
| 136 | + it('CONTROL: a normally composed stack (the top level repeats every body) registers nothing beyond the bodies', async () => { |
| 137 | + const k = await bootKernel(); |
| 138 | + await (k.getService('manifest') as ManifestService).register(payload({ |
| 139 | + manifest: { ...app, id: RELEASE_ID }, |
| 140 | + objects: [caseObj(), accountObj()], |
| 141 | + packages: [ |
| 142 | + { manifest: { ...svc, objects: [caseObj()] } }, |
| 143 | + { manifest: { ...app, objects: [accountObj()] } }, |
| 144 | + ], |
| 145 | + })); |
| 146 | + const ql = engineOf(k); |
| 147 | + expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: undefined }); |
| 148 | + expect(acmePackageIds(ql).sort()).toEqual([APP_ID, SERVICE_ID]); |
| 149 | + }); |
| 150 | + |
| 151 | + it('`packages: []` beside a top-level object registers the object under the manifest id', async () => { |
| 152 | + const k = await bootKernel(); |
| 153 | + await (k.getService('manifest') as ManifestService).register(payload({ |
| 154 | + manifest: { ...app, id: RELEASE_ID }, |
| 155 | + objects: [noteObj()], |
| 156 | + packages: [], |
| 157 | + })); |
| 158 | + const ql = engineOf(k); |
| 159 | + expect(ql.registry.getObjectOwner('acme_note')?.packageId).toBe(RELEASE_ID); |
| 160 | + expect(acmePackageIds(ql)).toEqual([]); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe('ADR-0130 D4 — a residual arriving after start()', () => { |
| 165 | + it('reaches the metadata service under the stack\'s manifest id, as a body\'s objects do', async () => { |
| 166 | + const k = await bootKernel(); |
| 167 | + await (k.getService('manifest') as ManifestService).register(residualStack()); |
| 168 | + |
| 169 | + const metadata = k.getService<IMetadataService>('metadata'); |
| 170 | + const bridged = await metadata.getObject('acme_note') as { _packageId?: string } | undefined; |
| 171 | + expect(bridged?._packageId).toBe(RELEASE_ID); |
| 172 | + }); |
| 173 | + |
| 174 | + it('is judged against the sealed picklist vocabulary like a body, and a refusal registers nothing', async () => { |
| 175 | + const k = await bootKernel(); |
| 176 | + const note = noteObj({ status: { type: 'select', label: 'Status', picklist: 'acme_no_such_list' } }); |
| 177 | + |
| 178 | + let caught: (Error & { code?: string; status?: number }) | undefined; |
| 179 | + try { |
| 180 | + await (k.getService('manifest') as ManifestService).register(residualStack(note)); |
| 181 | + } catch (e) { |
| 182 | + caught = e as Error & { code?: string; status?: number }; |
| 183 | + } |
| 184 | + |
| 185 | + expect(caught?.code).toBe('INVALID_METADATA'); |
| 186 | + expect(caught?.status).toBe(422); |
| 187 | + expect(caught?.message).toContain('acme_no_such_list'); |
| 188 | + const ql = engineOf(k); |
| 189 | + expect(owners(ql)).toEqual({ acme_account: undefined, acme_case: undefined, acme_note: undefined }); |
| 190 | + }); |
| 191 | +}); |
0 commit comments