@@ -15,6 +15,7 @@ import type {
1515 MetadataSaveResult ,
1616 MetadataWatchEvent ,
1717 MetadataFormat ,
18+ PackagePublishResult ,
1819} from '@objectstack/spec/system' ;
1920import type {
2021 IMetadataService ,
@@ -333,6 +334,187 @@ export class MetadataManager implements IMetadataService {
333334 }
334335 }
335336
337+ /**
338+ * Publish an entire package:
339+ * 1. Validate all draft items
340+ * 2. Snapshot all items in the package (publishedDefinition = clone(metadata))
341+ * 3. Increment version
342+ * 4. Set all items state → active
343+ */
344+ async publishPackage ( packageId : string , options ?: {
345+ changeNote ?: string ;
346+ publishedBy ?: string ;
347+ validate ?: boolean ;
348+ } ) : Promise < PackagePublishResult > {
349+ const now = new Date ( ) . toISOString ( ) ;
350+ const shouldValidate = options ?. validate !== false ;
351+ const publishedBy = options ?. publishedBy ;
352+
353+ // Collect all items belonging to this package
354+ const packageItems : Array < { type : string ; name : string ; data : any } > = [ ] ;
355+ for ( const [ type , typeStore ] of this . registry ) {
356+ for ( const [ name , data ] of typeStore ) {
357+ const meta = data as any ;
358+ if ( meta ?. packageId === packageId || meta ?. package === packageId ) {
359+ packageItems . push ( { type, name, data : meta } ) ;
360+ }
361+ }
362+ }
363+
364+ if ( packageItems . length === 0 ) {
365+ return {
366+ success : false ,
367+ packageId,
368+ version : 0 ,
369+ publishedAt : now ,
370+ itemsPublished : 0 ,
371+ validationErrors : [ { type : '' , name : '' , message : `No metadata items found for package '${ packageId } '` } ] ,
372+ } ;
373+ }
374+
375+ // Validation pass
376+ if ( shouldValidate ) {
377+ const validationErrors : Array < { type : string ; name : string ; message : string } > = [ ] ;
378+
379+ // Schema validation
380+ for ( const item of packageItems ) {
381+ const result = await this . validate ( item . type , item . data ) ;
382+ if ( ! result . valid && result . errors ) {
383+ for ( const err of result . errors ) {
384+ validationErrors . push ( {
385+ type : item . type ,
386+ name : item . name ,
387+ message : err . message ,
388+ } ) ;
389+ }
390+ }
391+ }
392+
393+ // Dependency validation: referenced items must be in the same package or already published
394+ const packageItemKeys = new Set ( packageItems . map ( i => `${ i . type } :${ i . name } ` ) ) ;
395+ for ( const item of packageItems ) {
396+ const deps = await this . getDependencies ( item . type , item . name ) ;
397+ for ( const dep of deps ) {
398+ const depKey = `${ dep . targetType } :${ dep . targetName } ` ;
399+ // Skip if the dependency is within this package
400+ if ( packageItemKeys . has ( depKey ) ) continue ;
401+ // Check if the dependency exists and has been published
402+ const depItem = await this . get ( dep . targetType , dep . targetName ) ;
403+ if ( ! depItem ) {
404+ validationErrors . push ( {
405+ type : item . type ,
406+ name : item . name ,
407+ message : `Dependency '${ dep . targetType } :${ dep . targetName } ' not found` ,
408+ } ) ;
409+ } else {
410+ const depMeta = depItem as any ;
411+ if ( depMeta . publishedDefinition === undefined && depMeta . state !== 'active' ) {
412+ validationErrors . push ( {
413+ type : item . type ,
414+ name : item . name ,
415+ message : `Dependency '${ dep . targetType } :${ dep . targetName } ' is not published` ,
416+ } ) ;
417+ }
418+ }
419+ }
420+ }
421+
422+ if ( validationErrors . length > 0 ) {
423+ return {
424+ success : false ,
425+ packageId,
426+ version : 0 ,
427+ publishedAt : now ,
428+ itemsPublished : 0 ,
429+ validationErrors,
430+ } ;
431+ }
432+ }
433+
434+ // Determine the next version by finding the max current version across items
435+ let maxVersion = 0 ;
436+ for ( const item of packageItems ) {
437+ const v = typeof item . data . version === 'number' ? item . data . version : 0 ;
438+ if ( v > maxVersion ) maxVersion = v ;
439+ }
440+ const newVersion = maxVersion + 1 ;
441+
442+ // Snapshot and update all items
443+ for ( const item of packageItems ) {
444+ const updated = {
445+ ...item . data ,
446+ publishedDefinition : structuredClone ( item . data . metadata ?? item . data ) ,
447+ publishedAt : now ,
448+ publishedBy : publishedBy ?? item . data . publishedBy ,
449+ version : newVersion ,
450+ state : 'active' ,
451+ } ;
452+ await this . register ( item . type , item . name , updated ) ;
453+ }
454+
455+ return {
456+ success : true ,
457+ packageId,
458+ version : newVersion ,
459+ publishedAt : now ,
460+ itemsPublished : packageItems . length ,
461+ } ;
462+ }
463+
464+ /**
465+ * Revert entire package to last published state.
466+ * Restores all metadata definitions from their published snapshots.
467+ */
468+ async revertPackage ( packageId : string ) : Promise < void > {
469+ const packageItems : Array < { type : string ; name : string ; data : any } > = [ ] ;
470+ for ( const [ type , typeStore ] of this . registry ) {
471+ for ( const [ name , data ] of typeStore ) {
472+ const meta = data as any ;
473+ if ( meta ?. packageId === packageId || meta ?. package === packageId ) {
474+ packageItems . push ( { type, name, data : meta } ) ;
475+ }
476+ }
477+ }
478+
479+ if ( packageItems . length === 0 ) {
480+ throw new Error ( `No metadata items found for package '${ packageId } '` ) ;
481+ }
482+
483+ // Check that at least one item has a published snapshot
484+ const hasPublished = packageItems . some ( item => item . data . publishedDefinition !== undefined ) ;
485+ if ( ! hasPublished ) {
486+ throw new Error ( `Package '${ packageId } ' has never been published` ) ;
487+ }
488+
489+ for ( const item of packageItems ) {
490+ if ( item . data . publishedDefinition !== undefined ) {
491+ const reverted = {
492+ ...item . data ,
493+ metadata : structuredClone ( item . data . publishedDefinition ) ,
494+ state : 'active' ,
495+ } ;
496+ await this . register ( item . type , item . name , reverted ) ;
497+ }
498+ }
499+ }
500+
501+ /**
502+ * Get the published version of any metadata item (for runtime serving).
503+ * Returns publishedDefinition if exists, else current definition.
504+ */
505+ async getPublished ( type : string , name : string ) : Promise < unknown | undefined > {
506+ const item = await this . get ( type , name ) ;
507+ if ( ! item ) return undefined ;
508+
509+ const meta = item as any ;
510+ if ( meta . publishedDefinition !== undefined ) {
511+ return meta . publishedDefinition ;
512+ }
513+
514+ // Fall back to current definition (metadata field or the item itself)
515+ return meta . metadata ?? item ;
516+ }
517+
336518 // ==========================================
337519 // Query / Search
338520 // ==========================================
0 commit comments