@objectstack/metadata 3.0.11 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +9 -0
- package/README.md +29 -0
- package/dist/index.d.mts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.js +150 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +150 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/metadata-manager.ts +182 -0
- package/src/metadata-service.test.ts +269 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/metadata",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Metadata loading, saving, and persistence for ObjectStack",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"js-yaml": "^4.1.0",
|
|
30
30
|
"chokidar": "^5.0.0",
|
|
31
31
|
"zod": "^4.3.6",
|
|
32
|
-
"@objectstack/core": "3.0
|
|
33
|
-
"@objectstack/
|
|
34
|
-
"@objectstack/
|
|
32
|
+
"@objectstack/core": "3.1.0",
|
|
33
|
+
"@objectstack/types": "3.1.0",
|
|
34
|
+
"@objectstack/spec": "3.1.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/js-yaml": "^4.0.9",
|
package/src/metadata-manager.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
MetadataSaveResult,
|
|
16
16
|
MetadataWatchEvent,
|
|
17
17
|
MetadataFormat,
|
|
18
|
+
PackagePublishResult,
|
|
18
19
|
} from '@objectstack/spec/system';
|
|
19
20
|
import type {
|
|
20
21
|
IMetadataService,
|
|
@@ -333,6 +334,187 @@ export class MetadataManager implements IMetadataService {
|
|
|
333
334
|
}
|
|
334
335
|
}
|
|
335
336
|
|
|
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
|
+
|
|
336
518
|
// ==========================================
|
|
337
519
|
// Query / Search
|
|
338
520
|
// ==========================================
|
|
@@ -693,4 +693,273 @@ describe('MetadataManager — IMetadataService Contract', () => {
|
|
|
693
693
|
return deps.then(result => expect(result).toHaveLength(1));
|
|
694
694
|
});
|
|
695
695
|
});
|
|
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
|
+
it('should fail when dependency is not found or not published', async () => {
|
|
777
|
+
await manager.register('view', 'opp_list', {
|
|
778
|
+
name: 'opp_list', label: 'Opp List', packageId: 'com.acme.dep',
|
|
779
|
+
metadata: { columns: ['name'] },
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Register a dependency pointing to a non-existent item
|
|
783
|
+
manager.addDependency({
|
|
784
|
+
sourceType: 'view',
|
|
785
|
+
sourceName: 'opp_list',
|
|
786
|
+
targetType: 'object',
|
|
787
|
+
targetName: 'opportunity',
|
|
788
|
+
kind: 'reference',
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const result = await manager.publishPackage('com.acme.dep', { validate: true });
|
|
792
|
+
expect(result.success).toBe(false);
|
|
793
|
+
expect(result.validationErrors).toBeDefined();
|
|
794
|
+
expect(result.validationErrors!.some(e => e.message.includes('opportunity'))).toBe(true);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('should pass dependency check when target is in the same package', async () => {
|
|
798
|
+
await manager.register('object', 'project', {
|
|
799
|
+
name: 'project', label: 'Project', packageId: 'com.acme.same',
|
|
800
|
+
metadata: { fields: ['name'] },
|
|
801
|
+
});
|
|
802
|
+
await manager.register('view', 'project_list', {
|
|
803
|
+
name: 'project_list', label: 'Project List', packageId: 'com.acme.same',
|
|
804
|
+
metadata: { columns: ['name'] },
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Dependency within the same package
|
|
808
|
+
manager.addDependency({
|
|
809
|
+
sourceType: 'view',
|
|
810
|
+
sourceName: 'project_list',
|
|
811
|
+
targetType: 'object',
|
|
812
|
+
targetName: 'project',
|
|
813
|
+
kind: 'reference',
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
const result = await manager.publishPackage('com.acme.same', { validate: true });
|
|
817
|
+
expect(result.success).toBe(true);
|
|
818
|
+
expect(result.itemsPublished).toBe(2);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('should pass dependency check when target is already published', async () => {
|
|
822
|
+
// Pre-existing published object (different package)
|
|
823
|
+
await manager.register('object', 'account', {
|
|
824
|
+
name: 'account', label: 'Account', packageId: 'com.acme.core',
|
|
825
|
+
publishedDefinition: { fields: ['name'] },
|
|
826
|
+
state: 'active',
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// View in a different package references the published object
|
|
830
|
+
await manager.register('view', 'account_list', {
|
|
831
|
+
name: 'account_list', label: 'Account List', packageId: 'com.acme.views',
|
|
832
|
+
metadata: { columns: ['name'] },
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
manager.addDependency({
|
|
836
|
+
sourceType: 'view',
|
|
837
|
+
sourceName: 'account_list',
|
|
838
|
+
targetType: 'object',
|
|
839
|
+
targetName: 'account',
|
|
840
|
+
kind: 'reference',
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const result = await manager.publishPackage('com.acme.views', { validate: true });
|
|
844
|
+
expect(result.success).toBe(true);
|
|
845
|
+
expect(result.itemsPublished).toBe(1);
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
describe('revertPackage', () => {
|
|
850
|
+
it('should revert to last published state', async () => {
|
|
851
|
+
// Register and publish
|
|
852
|
+
await manager.register('object', 'account', {
|
|
853
|
+
name: 'account', label: 'Account', packageId: 'crm',
|
|
854
|
+
metadata: { fields: ['name', 'email'] },
|
|
855
|
+
});
|
|
856
|
+
await manager.publishPackage('crm');
|
|
857
|
+
|
|
858
|
+
// Make edits after publish
|
|
859
|
+
const item = await manager.get('object', 'account') as any;
|
|
860
|
+
await manager.register('object', 'account', {
|
|
861
|
+
...item,
|
|
862
|
+
metadata: { fields: ['name', 'email', 'phone'] },
|
|
863
|
+
state: 'draft',
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// Verify edit was saved
|
|
867
|
+
const edited = await manager.get('object', 'account') as any;
|
|
868
|
+
expect(edited.metadata.fields).toContain('phone');
|
|
869
|
+
|
|
870
|
+
// Revert
|
|
871
|
+
await manager.revertPackage('crm');
|
|
872
|
+
|
|
873
|
+
// Verify reverted to published state
|
|
874
|
+
const reverted = await manager.get('object', 'account') as any;
|
|
875
|
+
expect(reverted.state).toBe('active');
|
|
876
|
+
expect(reverted.metadata).toEqual(reverted.publishedDefinition);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('should throw for non-existent package', async () => {
|
|
880
|
+
await expect(manager.revertPackage('nonexistent')).rejects.toThrow('No metadata items found');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('should throw for never-published package', async () => {
|
|
884
|
+
await manager.register('object', 'new_item', {
|
|
885
|
+
name: 'new_item', packageId: 'com.acme.new',
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
await expect(manager.revertPackage('com.acme.new')).rejects.toThrow('has never been published');
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
describe('getPublished', () => {
|
|
893
|
+
it('should return published definition when available', async () => {
|
|
894
|
+
await manager.register('object', 'account', {
|
|
895
|
+
name: 'account', label: 'Account', packageId: 'crm',
|
|
896
|
+
metadata: { fields: ['name'] },
|
|
897
|
+
});
|
|
898
|
+
await manager.publishPackage('crm');
|
|
899
|
+
|
|
900
|
+
// Edit after publish
|
|
901
|
+
const item = await manager.get('object', 'account') as any;
|
|
902
|
+
await manager.register('object', 'account', {
|
|
903
|
+
...item,
|
|
904
|
+
metadata: { fields: ['name', 'email', 'phone'] },
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// getPublished should return the published snapshot, not the edited version
|
|
908
|
+
const published = await manager.getPublished('object', 'account');
|
|
909
|
+
expect(published).toBeDefined();
|
|
910
|
+
// The published snapshot was taken from the original metadata
|
|
911
|
+
const pubAny = published as any;
|
|
912
|
+
expect(pubAny.fields).toBeDefined();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('should return current definition when never published', async () => {
|
|
916
|
+
await manager.register('object', 'contact', {
|
|
917
|
+
name: 'contact', label: 'Contact',
|
|
918
|
+
metadata: { fields: ['first_name'] },
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const published = await manager.getPublished('object', 'contact');
|
|
922
|
+
expect(published).toBeDefined();
|
|
923
|
+
// Falls back to metadata field
|
|
924
|
+
expect((published as any).fields).toEqual(['first_name']);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('should return undefined for non-existent item', async () => {
|
|
928
|
+
const result = await manager.getPublished('object', 'nonexistent');
|
|
929
|
+
expect(result).toBeUndefined();
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
describe('integration: edit → publish → edit → revert', () => {
|
|
934
|
+
it('should preserve published version through edit-revert cycle', async () => {
|
|
935
|
+
// Step 1: Initial setup
|
|
936
|
+
await manager.register('object', 'project', {
|
|
937
|
+
name: 'project', label: 'Project', packageId: 'pm',
|
|
938
|
+
metadata: { fields: ['name', 'status'] },
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// Step 2: Publish v1
|
|
942
|
+
const v1 = await manager.publishPackage('pm', { publishedBy: 'admin' });
|
|
943
|
+
expect(v1.success).toBe(true);
|
|
944
|
+
expect(v1.version).toBe(1);
|
|
945
|
+
|
|
946
|
+
// Step 3: Edit after publish
|
|
947
|
+
const item = await manager.get('object', 'project') as any;
|
|
948
|
+
await manager.register('object', 'project', {
|
|
949
|
+
...item,
|
|
950
|
+
metadata: { fields: ['name', 'status', 'priority'] },
|
|
951
|
+
state: 'draft',
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// Step 4: End user sees published version
|
|
955
|
+
const endUserView = await manager.getPublished('object', 'project') as any;
|
|
956
|
+
expect(endUserView.fields).toEqual(['name', 'status']);
|
|
957
|
+
|
|
958
|
+
// Step 5: Revert discards draft changes
|
|
959
|
+
await manager.revertPackage('pm');
|
|
960
|
+
const reverted = await manager.get('object', 'project') as any;
|
|
961
|
+
expect(reverted.state).toBe('active');
|
|
962
|
+
expect(reverted.metadata.fields).toEqual(['name', 'status']);
|
|
963
|
+
});
|
|
964
|
+
});
|
|
696
965
|
});
|