Skip to content

Commit 1af3247

Browse files
Copilothotlong
andcommitted
feat(metadata): implement publishPackage, revertPackage, getPublished in MetadataManager with tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 6bef205 commit 1af3247

2 files changed

Lines changed: 347 additions & 0 deletions

File tree

packages/metadata/src/metadata-manager.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
MetadataSaveResult,
1616
MetadataWatchEvent,
1717
MetadataFormat,
18+
PackagePublishResult,
1819
} from '@objectstack/spec/system';
1920
import type {
2021
IMetadataService,
@@ -333,6 +334,155 @@ 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+
for (const item of packageItems) {
379+
const result = await this.validate(item.type, item.data);
380+
if (!result.valid && result.errors) {
381+
for (const err of result.errors) {
382+
validationErrors.push({
383+
type: item.type,
384+
name: item.name,
385+
message: err.message,
386+
});
387+
}
388+
}
389+
}
390+
if (validationErrors.length > 0) {
391+
return {
392+
success: false,
393+
packageId,
394+
version: 0,
395+
publishedAt: now,
396+
itemsPublished: 0,
397+
validationErrors,
398+
};
399+
}
400+
}
401+
402+
// Determine the next version by finding the max current version across items
403+
let maxVersion = 0;
404+
for (const item of packageItems) {
405+
const v = typeof item.data.version === 'number' ? item.data.version : 0;
406+
if (v > maxVersion) maxVersion = v;
407+
}
408+
const newVersion = maxVersion + 1;
409+
410+
// Snapshot and update all items
411+
for (const item of packageItems) {
412+
const updated = {
413+
...item.data,
414+
publishedDefinition: JSON.parse(JSON.stringify(item.data.metadata ?? item.data)),
415+
publishedAt: now,
416+
publishedBy: publishedBy ?? item.data.publishedBy,
417+
version: newVersion,
418+
state: 'active',
419+
};
420+
await this.register(item.type, item.name, updated);
421+
}
422+
423+
return {
424+
success: true,
425+
packageId,
426+
version: newVersion,
427+
publishedAt: now,
428+
itemsPublished: packageItems.length,
429+
};
430+
}
431+
432+
/**
433+
* Revert entire package to last published state.
434+
* Restores all metadata definitions from their published snapshots.
435+
*/
436+
async revertPackage(packageId: string): Promise<void> {
437+
const packageItems: Array<{ type: string; name: string; data: any }> = [];
438+
for (const [type, typeStore] of this.registry) {
439+
for (const [name, data] of typeStore) {
440+
const meta = data as any;
441+
if (meta?.packageId === packageId || meta?.package === packageId) {
442+
packageItems.push({ type, name, data: meta });
443+
}
444+
}
445+
}
446+
447+
if (packageItems.length === 0) {
448+
throw new Error(`No metadata items found for package '${packageId}'`);
449+
}
450+
451+
// Check that at least one item has a published snapshot
452+
const hasPublished = packageItems.some(item => item.data.publishedDefinition !== undefined);
453+
if (!hasPublished) {
454+
throw new Error(`Package '${packageId}' has never been published`);
455+
}
456+
457+
for (const item of packageItems) {
458+
if (item.data.publishedDefinition !== undefined) {
459+
const reverted = {
460+
...item.data,
461+
metadata: JSON.parse(JSON.stringify(item.data.publishedDefinition)),
462+
state: 'active',
463+
};
464+
await this.register(item.type, item.name, reverted);
465+
}
466+
}
467+
}
468+
469+
/**
470+
* Get the published version of any metadata item (for runtime serving).
471+
* Returns publishedDefinition if exists, else current definition.
472+
*/
473+
async getPublished(type: string, name: string): Promise<unknown | undefined> {
474+
const item = await this.get(type, name);
475+
if (!item) return undefined;
476+
477+
const meta = item as any;
478+
if (meta.publishedDefinition !== undefined) {
479+
return meta.publishedDefinition;
480+
}
481+
482+
// Fall back to current definition (metadata field or the item itself)
483+
return meta.metadata ?? item;
484+
}
485+
336486
// ==========================================
337487
// Query / Search
338488
// ==========================================

packages/metadata/src/metadata-service.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,4 +693,201 @@ describe('MetadataManager — IMetadataService Contract', () => {
693693
return deps.then(result => expect(result).toHaveLength(1));
694694
});
695695
});
696+
697+
// ==========================================
698+
// Package Publish / Revert / getPublished
699+
// ==========================================
700+
701+
describe('publishPackage', () => {
702+
it('should publish all items in a package', async () => {
703+
await manager.register('object', 'opportunity', {
704+
name: 'opportunity', label: 'Opportunity', packageId: 'com.acme.crm', state: 'draft',
705+
metadata: { fields: ['name', 'amount'] },
706+
});
707+
await manager.register('view', 'opp_list', {
708+
name: 'opp_list', label: 'Opp List', packageId: 'com.acme.crm', state: 'draft',
709+
metadata: { columns: ['name', 'amount'] },
710+
});
711+
712+
const result = await manager.publishPackage('com.acme.crm', { publishedBy: 'admin' });
713+
714+
expect(result.success).toBe(true);
715+
expect(result.packageId).toBe('com.acme.crm');
716+
expect(result.version).toBe(1);
717+
expect(result.itemsPublished).toBe(2);
718+
expect(result.publishedAt).toBeDefined();
719+
720+
// Verify items are now active with published snapshots
721+
const obj = await manager.get('object', 'opportunity') as any;
722+
expect(obj.state).toBe('active');
723+
expect(obj.publishedDefinition).toBeDefined();
724+
expect(obj.publishedBy).toBe('admin');
725+
expect(obj.publishedAt).toBeDefined();
726+
727+
const view = await manager.get('view', 'opp_list') as any;
728+
expect(view.state).toBe('active');
729+
expect(view.publishedDefinition).toBeDefined();
730+
});
731+
732+
it('should increment version on each publish', async () => {
733+
await manager.register('object', 'account', {
734+
name: 'account', packageId: 'crm', state: 'draft', version: 0,
735+
metadata: { fields: ['name'] },
736+
});
737+
738+
const first = await manager.publishPackage('crm');
739+
expect(first.version).toBe(1);
740+
741+
const second = await manager.publishPackage('crm');
742+
expect(second.version).toBe(2);
743+
});
744+
745+
it('should fail for empty package', async () => {
746+
const result = await manager.publishPackage('nonexistent');
747+
expect(result.success).toBe(false);
748+
expect(result.itemsPublished).toBe(0);
749+
expect(result.validationErrors).toBeDefined();
750+
});
751+
752+
it('should fail validation when items are invalid', async () => {
753+
// Register an item without a name (will fail validate)
754+
await manager.register('object', 'bad_item', {
755+
packageId: 'com.acme.bad', state: 'draft',
756+
metadata: {},
757+
});
758+
759+
const result = await manager.publishPackage('com.acme.bad', { validate: true });
760+
expect(result.success).toBe(false);
761+
expect(result.validationErrors).toBeDefined();
762+
expect(result.validationErrors!.length).toBeGreaterThan(0);
763+
});
764+
765+
it('should skip validation when validate=false', async () => {
766+
await manager.register('object', 'skip_val', {
767+
packageId: 'com.acme.skip', state: 'draft',
768+
metadata: {},
769+
});
770+
771+
const result = await manager.publishPackage('com.acme.skip', { validate: false });
772+
expect(result.success).toBe(true);
773+
expect(result.itemsPublished).toBe(1);
774+
});
775+
});
776+
777+
describe('revertPackage', () => {
778+
it('should revert to last published state', async () => {
779+
// Register and publish
780+
await manager.register('object', 'account', {
781+
name: 'account', label: 'Account', packageId: 'crm',
782+
metadata: { fields: ['name', 'email'] },
783+
});
784+
await manager.publishPackage('crm');
785+
786+
// Make edits after publish
787+
const item = await manager.get('object', 'account') as any;
788+
await manager.register('object', 'account', {
789+
...item,
790+
metadata: { fields: ['name', 'email', 'phone'] },
791+
state: 'draft',
792+
});
793+
794+
// Verify edit was saved
795+
const edited = await manager.get('object', 'account') as any;
796+
expect(edited.metadata.fields).toContain('phone');
797+
798+
// Revert
799+
await manager.revertPackage('crm');
800+
801+
// Verify reverted to published state
802+
const reverted = await manager.get('object', 'account') as any;
803+
expect(reverted.state).toBe('active');
804+
expect(reverted.metadata).toEqual(reverted.publishedDefinition);
805+
});
806+
807+
it('should throw for non-existent package', async () => {
808+
await expect(manager.revertPackage('nonexistent')).rejects.toThrow('No metadata items found');
809+
});
810+
811+
it('should throw for never-published package', async () => {
812+
await manager.register('object', 'new_item', {
813+
name: 'new_item', packageId: 'com.acme.new',
814+
});
815+
816+
await expect(manager.revertPackage('com.acme.new')).rejects.toThrow('has never been published');
817+
});
818+
});
819+
820+
describe('getPublished', () => {
821+
it('should return published definition when available', async () => {
822+
await manager.register('object', 'account', {
823+
name: 'account', label: 'Account', packageId: 'crm',
824+
metadata: { fields: ['name'] },
825+
});
826+
await manager.publishPackage('crm');
827+
828+
// Edit after publish
829+
const item = await manager.get('object', 'account') as any;
830+
await manager.register('object', 'account', {
831+
...item,
832+
metadata: { fields: ['name', 'email', 'phone'] },
833+
});
834+
835+
// getPublished should return the published snapshot, not the edited version
836+
const published = await manager.getPublished('object', 'account');
837+
expect(published).toBeDefined();
838+
// The published snapshot was taken from the original metadata
839+
const pubAny = published as any;
840+
expect(pubAny.fields).toBeDefined();
841+
});
842+
843+
it('should return current definition when never published', async () => {
844+
await manager.register('object', 'contact', {
845+
name: 'contact', label: 'Contact',
846+
metadata: { fields: ['first_name'] },
847+
});
848+
849+
const published = await manager.getPublished('object', 'contact');
850+
expect(published).toBeDefined();
851+
// Falls back to metadata field
852+
expect((published as any).fields).toEqual(['first_name']);
853+
});
854+
855+
it('should return undefined for non-existent item', async () => {
856+
const result = await manager.getPublished('object', 'nonexistent');
857+
expect(result).toBeUndefined();
858+
});
859+
});
860+
861+
describe('integration: edit → publish → edit → revert', () => {
862+
it('should preserve published version through edit-revert cycle', async () => {
863+
// Step 1: Initial setup
864+
await manager.register('object', 'project', {
865+
name: 'project', label: 'Project', packageId: 'pm',
866+
metadata: { fields: ['name', 'status'] },
867+
});
868+
869+
// Step 2: Publish v1
870+
const v1 = await manager.publishPackage('pm', { publishedBy: 'admin' });
871+
expect(v1.success).toBe(true);
872+
expect(v1.version).toBe(1);
873+
874+
// Step 3: Edit after publish
875+
const item = await manager.get('object', 'project') as any;
876+
await manager.register('object', 'project', {
877+
...item,
878+
metadata: { fields: ['name', 'status', 'priority'] },
879+
state: 'draft',
880+
});
881+
882+
// Step 4: End user sees published version
883+
const endUserView = await manager.getPublished('object', 'project') as any;
884+
expect(endUserView.fields).toEqual(['name', 'status']);
885+
886+
// Step 5: Revert discards draft changes
887+
await manager.revertPackage('pm');
888+
const reverted = await manager.get('object', 'project') as any;
889+
expect(reverted.state).toBe('active');
890+
expect(reverted.metadata.fields).toEqual(['name', 'status']);
891+
});
892+
});
696893
});

0 commit comments

Comments
 (0)