@objectstack/metadata 3.0.10 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/metadata",
3
- "version": "3.0.10",
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.10",
33
- "@objectstack/spec": "3.0.10",
34
- "@objectstack/types": "3.0.10"
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",
@@ -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
  });