1717 * two-boot reading is `packages/cli/test/serve-config-boot-residual.integration.test.ts`
1818 * (row 4); these pins hold the engine half on a kernel.
1919 *
20- * ## What the shape of each pin is for
20+ * ## What each pin is for
2121 *
2222 * - The residual object is RESOLVED by the registry and OWNED by the stack's
2323 * manifest id — what the data door reads.
2424 * - CONTROL: the bodies' objects keep their own owners, and no package record
2525 * appears for the residual's id. The residual is not a package; when its id
2626 * names a body (a composed stack keeps one member's manifest) that body's
2727 * 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.
28+ * - A late artifact (after `start()`) reaches the metadata service too.
29+ * - The two classes whose registration would REFUSE a boot the residual rule
30+ * accepts — a name another package owns, a field naming a picklist no body
31+ * declares — are listed and not served, and the boot says so, naming each.
32+ * Both were measured refusing the boot when registered.
3033 *
3134 * Payloads are handed over the way `AppPlugin` hands them:
3235 * `{ ...bundle.manifest, ...bundle }`.
3336 */
3437
3538import { describe , it , expect , afterEach } from 'vitest' ;
36- import { ObjectKernel } from '@objectstack/core' ;
39+ import { ObjectKernel , type Plugin , type PluginContext } from '@objectstack/core' ;
3740import type { IMetadataService } from '@objectstack/spec/contracts' ;
3841import { ObjectQLPlugin } from './plugin.js' ;
3942import type { ObjectQL } from './engine.js' ;
@@ -45,23 +48,34 @@ const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService<ObjectQL>
4548const APP_ID = 'com.example.acme' ;
4649const SERVICE_ID = 'com.example.acme.service' ;
4750const RELEASE_ID = 'com.example.acme.release' ;
51+ const OTHER_ID = 'com.example.other' ;
4852
4953const caseObj = ( ) => ( { name : 'acme_case' , label : 'Case' , sharingModel : 'private' , fields : { subject : { type : 'text' , label : 'Subject' } } } ) ;
5054const accountObj = ( ) => ( { name : 'acme_account' , label : 'Account' , sharingModel : 'private' , fields : { name : { type : 'text' , label : 'Name' } } } ) ;
5155const noteObj = ( fields : Record < string , unknown > = { title : { type : 'text' , label : 'Title' } } ) =>
5256 ( { name : 'acme_note' , label : 'Note' , sharingModel : 'private' , fields } ) ;
57+ const statusList = ( ) => ( { name : 'acme_status' , label : 'Status' , options : [ { label : 'Open' , value : 'open' } ] } ) ;
58+ const statusField = { status : { name : 'status' , type : 'select' , label : 'Status' , picklist : 'acme_status' } } ;
5359const svc = { id : SERVICE_ID , name : 'service' , namespace : 'acme' , version : '2.4.0' , type : 'module' } ;
5460const app = { id : APP_ID , name : 'acme' , namespace : 'acme' , version : '1.0.0' , type : 'app' } ;
5561
5662/** What `AppPlugin.init` hands the `manifest` service. */
5763const payload = ( bundle : Record < string , unknown > ) => ( { ...( bundle . manifest as object ) , ...bundle } ) ;
5864
59- /** Two packages, a `manifest.id` naming neither, and a top-level `acme_note` no body declares. */
60- const residualStack = ( note = noteObj ( ) ) => payload ( {
65+ /**
66+ * Two packages, a `manifest.id` naming neither, and a top-level `acme_note` no
67+ * body declares. `extra` lands on the top level; `serviceExtra` on the service body.
68+ */
69+ const residualStack = (
70+ note = noteObj ( ) ,
71+ extra : Record < string , unknown > = { } ,
72+ serviceExtra : Record < string , unknown > = { } ,
73+ ) => payload ( {
6174 manifest : { ...app , id : RELEASE_ID } ,
6275 objects : [ caseObj ( ) , accountObj ( ) , note ] ,
76+ ...extra ,
6377 packages : [
64- { manifest : { ...svc , objects : [ caseObj ( ) ] } } ,
78+ { manifest : { ...svc , objects : [ caseObj ( ) ] , ... serviceExtra } } ,
6579 { manifest : { ...app , objects : [ accountObj ( ) ] } } ,
6680 ] ,
6781} ) ;
@@ -75,27 +89,68 @@ const acmePackageIds = (ql: ObjectQL): string[] =>
7589 ql . registry
7690 . getAllPackages ( )
7791 . map ( ( p ) => p . manifest ?. id )
78- . filter ( ( id : unknown ) : id is string => typeof id === 'string' && id . startsWith ( 'com.example.' ) ) ;
92+ . filter ( ( id : unknown ) : id is string => typeof id === 'string' && id . startsWith ( 'com.example.acme ' ) ) ;
7993
80- let kernel : ObjectKernel | undefined ;
94+ /**
95+ * The kernel hands every plugin ONE logger, so a plugin that inits first can
96+ * read what the engine says on it. Wrapping in place rather than replacing:
97+ * the engine's own calls still reach the real (silent) logger.
98+ */
99+ class WarnRecorder implements Plugin {
100+ name = 'test.residual-objects.warn-recorder' ;
101+ version = '1.0.0' ;
102+ readonly warnings : string [ ] = [ ] ;
103+ init = async ( ctx : PluginContext ) : Promise < void > => {
104+ const logger = ctx . logger as { warn : ( message : string , ...rest : unknown [ ] ) => void } ;
105+ const real = logger . warn . bind ( logger ) ;
106+ logger . warn = ( message : string , ...rest : unknown [ ] ) => {
107+ this . warnings . push ( String ( message ) ) ;
108+ real ( message , ...rest ) ;
109+ } ;
110+ } ;
111+ }
81112
82- async function bootKernel ( ) : Promise < ObjectKernel > {
83- kernel = new ObjectKernel ( { logger : { level : 'silent' } , gracefulShutdown : false } ) ;
113+ /** Registers `artifacts` through the `manifest` service in `init()` — a boot-time registration, as `AppPlugin` makes. */
114+ function registering ( name : string , artifacts : unknown [ ] , after : string [ ] = [ ] ) : Plugin {
115+ return {
116+ name,
117+ dependencies : [ 'com.objectstack.engine.objectql' , ...after ] ,
118+ init : async ( ctx : PluginContext ) => {
119+ const manifest = ctx . getService < ManifestService > ( 'manifest' ) ;
120+ for ( const artifact of artifacts ) await manifest . register ( artifact ) ;
121+ } ,
122+ } as Plugin ;
123+ }
124+
125+ const kernels : ObjectKernel [ ] = [ ] ;
126+
127+ /** Boot a kernel whose plugins register `plugins`' artifacts at boot; `recorder` sees every warning. */
128+ async function boot ( ...plugins : Plugin [ ] ) : Promise < { kernel : ObjectKernel ; recorder : WarnRecorder } > {
129+ const kernel = new ObjectKernel ( { logger : { level : 'silent' } , gracefulShutdown : false } ) ;
130+ kernels . push ( kernel ) ;
131+ const recorder = new WarnRecorder ( ) ;
132+ await kernel . use ( recorder ) ;
84133 await kernel . use ( new ObjectQLPlugin ( ) ) ;
134+ for ( const plugin of plugins ) await kernel . use ( plugin ) ;
85135 await kernel . bootstrap ( ) ;
86- return kernel ;
136+ return { kernel, recorder } ;
87137}
88138
139+ /** The engine's line naming residual objects it lists and does not serve. */
140+ const unservedLines = ( recorder : WarnRecorder ) : string [ ] =>
141+ recorder . warnings . filter ( ( w ) => w . includes ( 'NOT served by the data door' ) ) ;
142+
89143afterEach ( async ( ) => {
90- if ( kernel && kernel . getState ( ) === 'running' ) await kernel . shutdown ( ) ;
91- kernel = undefined ;
144+ while ( kernels . length ) {
145+ const kernel = kernels . pop ( ) ! ;
146+ if ( kernel . getState ( ) === 'running' ) await kernel . shutdown ( ) ;
147+ }
92148} ) ;
93149
94150describe ( 'ADR-0130 D4 — the manifest service registers the residual\'s objects' , ( ) => {
95151 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 ) ;
152+ const { kernel, recorder } = await boot ( registering ( 'app' , [ residualStack ( ) ] ) ) ;
153+ const ql = engineOf ( kernel ) ;
99154
100155 // Before: `acme_note` resolved to nothing — the data door's 404.
101156 expect ( ql . registry . resolveObject ( 'acme_note' ) ?. fields ?. title ) . toBeDefined ( ) ;
@@ -105,25 +160,24 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s objects
105160 acme_note : RELEASE_ID ,
106161 } ) ;
107162 expect ( ( ql . registry . resolveObject ( 'acme_note' ) as { _packageId ?: string } | undefined ) ?. _packageId ) . toBe ( RELEASE_ID ) ;
163+ expect ( unservedLines ( recorder ) ) . toEqual ( [ ] ) ;
108164 } ) ;
109165
110166 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 ] ) ;
167+ const { kernel } = await boot ( registering ( 'app' , [ residualStack ( ) ] ) ) ;
168+ expect ( acmePackageIds ( engineOf ( kernel ) ) . sort ( ) ) . toEqual ( [ APP_ID , SERVICE_ID ] ) ;
114169 } ) ;
115170
116171 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 ( {
172+ const { kernel } = await boot ( registering ( 'app' , [ payload ( {
119173 manifest : app ,
120174 objects : [ caseObj ( ) , accountObj ( ) , noteObj ( ) ] ,
121175 packages : [
122176 { manifest : { ...svc , objects : [ caseObj ( ) ] } } ,
123177 { manifest : { ...app , objects : [ accountObj ( ) ] } } ,
124178 ] ,
125- } ) ) ;
126- const ql = engineOf ( k ) ;
179+ } ) ] ) ) ;
180+ const ql = engineOf ( kernel ) ;
127181
128182 expect ( owners ( ql ) ) . toEqual ( { acme_account : APP_ID , acme_case : SERVICE_ID , acme_note : APP_ID } ) ;
129183 // The app body's record is the app body, not the residual: its own
@@ -134,58 +188,90 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s objects
134188 } ) ;
135189
136190 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 ( {
191+ const { kernel } = await boot ( registering ( 'app' , [ payload ( {
139192 manifest : { ...app , id : RELEASE_ID } ,
140193 objects : [ caseObj ( ) , accountObj ( ) ] ,
141194 packages : [
142195 { manifest : { ...svc , objects : [ caseObj ( ) ] } } ,
143196 { manifest : { ...app , objects : [ accountObj ( ) ] } } ,
144197 ] ,
145- } ) ) ;
146- const ql = engineOf ( k ) ;
198+ } ) ] ) ) ;
199+ const ql = engineOf ( kernel ) ;
147200 expect ( owners ( ql ) ) . toEqual ( { acme_account : APP_ID , acme_case : SERVICE_ID , acme_note : undefined } ) ;
148201 expect ( acmePackageIds ( ql ) . sort ( ) ) . toEqual ( [ APP_ID , SERVICE_ID ] ) ;
149202 } ) ;
150203
151204 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 ( {
205+ const { kernel } = await boot ( registering ( 'app' , [ payload ( {
154206 manifest : { ...app , id : RELEASE_ID } ,
155207 objects : [ noteObj ( ) ] ,
156208 packages : [ ] ,
157- } ) ) ;
158- const ql = engineOf ( k ) ;
209+ } ) ] ) ) ;
210+ const ql = engineOf ( kernel ) ;
159211 expect ( ql . registry . getObjectOwner ( 'acme_note' ) ?. packageId ) . toBe ( RELEASE_ID ) ;
160212 expect ( acmePackageIds ( ql ) ) . toEqual ( [ ] ) ;
161213 } ) ;
214+
215+ it ( 'a residual object whose field names a picklist a BODY declares is served with that list' , async ( ) => {
216+ const { kernel } = await boot ( registering ( 'app' , [
217+ residualStack ( noteObj ( statusField ) , { } , { picklists : [ statusList ( ) ] } ) ,
218+ ] ) ) ;
219+ const ql = engineOf ( kernel ) ;
220+ expect ( ql . registry . getObjectOwner ( 'acme_note' ) ?. packageId ) . toBe ( RELEASE_ID ) ;
221+ const options = ( ql . registry . getObject ( 'acme_note' ) ?. fields ?. status as { options ?: Array < { value : unknown } > } | undefined ) ?. options ;
222+ expect ( options ?. map ( ( o ) => o . value ) ) . toEqual ( [ 'open' ] ) ;
223+ } ) ;
224+ } ) ;
225+
226+ describe ( 'ADR-0130 D4 — the residual objects the engine lists and does not serve' , ( ) => {
227+ it ( 'an object whose name another package owns: the boot still boots, that package keeps it, and the boot names it' , async ( ) => {
228+ const other = {
229+ id : OTHER_ID , name : 'other' , version : '1.0.0' , type : 'app' ,
230+ objects : [ { name : 'acme_note' , label : 'Other note' , fields : { body : { type : 'text' , label : 'Body' } } } ] ,
231+ } ;
232+ // Registered as an object this stack does not own would be: by another
233+ // package, earlier in the same boot.
234+ const { kernel, recorder } = await boot (
235+ registering ( 'other' , [ other ] ) ,
236+ registering ( 'app' , [ residualStack ( ) ] , [ 'other' ] ) ,
237+ ) ;
238+ const ql = engineOf ( kernel ) ;
239+
240+ // Registering it would have refused the boot with OBJECT_OWNERSHIP_CONFLICT.
241+ expect ( owners ( ql ) ) . toEqual ( { acme_account : APP_ID , acme_case : SERVICE_ID , acme_note : OTHER_ID } ) ;
242+ expect ( ql . registry . resolveObject ( 'acme_note' ) ?. fields ?. body ) . toBeDefined ( ) ;
243+ const lines = unservedLines ( recorder ) ;
244+ expect ( lines ) . toHaveLength ( 1 ) ;
245+ expect ( lines [ 0 ] ) . toContain ( `stack '${ RELEASE_ID } '` ) ;
246+ expect ( lines [ 0 ] ) . toContain ( `'acme_note' (package '${ OTHER_ID } ' already owns that name)` ) ;
247+ } ) ;
248+
249+ it ( 'an object whose field names a picklist only the residual declares: the boot still boots, and the boot names it' , async ( ) => {
250+ const { kernel, recorder } = await boot ( registering ( 'app' , [
251+ residualStack ( noteObj ( statusField ) , { picklists : [ statusList ( ) ] } ) ,
252+ ] ) ) ;
253+ const ql = engineOf ( kernel ) ;
254+
255+ // Registering it would have refused the boot at `kernel:ready` (INVALID_METADATA,
256+ // the picklist audit): the residual's own list is metadata only.
257+ expect ( kernel . getState ( ) ) . toBe ( 'running' ) ;
258+ expect ( owners ( ql ) ) . toEqual ( { acme_account : APP_ID , acme_case : SERVICE_ID , acme_note : undefined } ) ;
259+ const lines = unservedLines ( recorder ) ;
260+ expect ( lines ) . toHaveLength ( 1 ) ;
261+ expect ( lines [ 0 ] ) . toContain (
262+ "'acme_note' (field 'status' names picklist 'acme_status', which no package body declares)" ,
263+ ) ;
264+ } ) ;
162265} ) ;
163266
164267describe ( 'ADR-0130 D4 — a residual arriving after start()' , ( ) => {
165268 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 ( ) ) ;
269+ const { kernel } = await boot ( ) ;
270+ await ( kernel . getService ( 'manifest' ) as ManifestService ) . register ( residualStack ( ) ) ;
168271
169- const metadata = k . getService < IMetadataService > ( 'metadata' ) ;
272+ expect ( engineOf ( kernel ) . registry . getObjectOwner ( 'acme_note' ) ?. packageId ) . toBe ( RELEASE_ID ) ;
273+ const metadata = kernel . getService < IMetadataService > ( 'metadata' ) ;
170274 const bridged = await metadata . getObject ( 'acme_note' ) as { _packageId ?: string } | undefined ;
171275 expect ( bridged ?. _packageId ) . toBe ( RELEASE_ID ) ;
172276 } ) ;
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- } ) ;
191277} ) ;
0 commit comments