From 4f667108f00010eae22f5d869e4b6cffca9d5d50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:15:28 +0000 Subject: [PATCH 1/7] Initial plan From 6bef2053bb9c46d06654699350325a3383399a60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:23:19 +0000 Subject: [PATCH 2/7] feat(spec): add package publish protocol - MetadataRecordSchema fields, PackagePublishResultSchema, IMetadataService methods Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/contracts/metadata-service.test.ts | 43 +++++++++++ .../spec/src/contracts/metadata-service.ts | 33 +++++++++ .../src/system/metadata-persistence.test.ts | 71 +++++++++++++++++++ .../src/system/metadata-persistence.zod.ts | 27 +++++++ 4 files changed, 174 insertions(+) diff --git a/packages/spec/src/contracts/metadata-service.test.ts b/packages/spec/src/contracts/metadata-service.test.ts index c9b16e3e4..e9c88ecdd 100644 --- a/packages/spec/src/contracts/metadata-service.test.ts +++ b/packages/spec/src/contracts/metadata-service.test.ts @@ -343,6 +343,15 @@ describe('Metadata Service Contract', () => { listObjects: async () => [], // Package unregisterPackage: async () => {}, + publishPackage: async () => ({ + success: true, + packageId: 'test', + version: 1, + publishedAt: new Date().toISOString(), + itemsPublished: 0, + }), + revertPackage: async () => {}, + getPublished: async () => undefined, // Query query: async () => ({ items: [], total: 0, page: 1, pageSize: 50 }), // Bulk @@ -378,5 +387,39 @@ describe('Metadata Service Contract', () => { expect(typeof service.validate).toBe('function'); expect(typeof service.getRegisteredTypes).toBe('function'); expect(typeof service.getDependencies).toBe('function'); + expect(typeof service.publishPackage).toBe('function'); + expect(typeof service.revertPackage).toBe('function'); + expect(typeof service.getPublished).toBe('function'); + }); + + it('should allow implementation with package publish support', async () => { + const service: IMetadataService = { + register: async () => {}, + get: async () => undefined, + list: async () => [], + unregister: async () => {}, + exists: async () => false, + listNames: async () => [], + getObject: async () => undefined, + listObjects: async () => [], + publishPackage: async (packageId, options) => ({ + success: true, + packageId, + version: 2, + publishedAt: new Date().toISOString(), + itemsPublished: 3, + }), + revertPackage: async () => {}, + getPublished: async (_type, _name) => ({ name: 'account', label: 'Account' }), + }; + + const result = await service.publishPackage!('com.acme.crm', { publishedBy: 'admin' }); + expect(result.success).toBe(true); + expect(result.packageId).toBe('com.acme.crm'); + expect(result.version).toBe(2); + expect(result.itemsPublished).toBe(3); + + const published = await service.getPublished!('object', 'account'); + expect(published).toEqual({ name: 'account', label: 'Account' }); }); }); diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index ce31b9d55..a2f2a1400 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -37,6 +37,7 @@ import type { MetadataQuery, MetadataQueryResult, MetadataValidationResult, MetadataBulkResult, MetadataDependency } from '../kernel/metadata-plugin.zod'; import type { MetadataOverlay } from '../kernel/metadata-customization.zod'; +import type { PackagePublishResult } from '../system/metadata-persistence.zod'; /** * Metadata watch callback signature @@ -213,6 +214,38 @@ export interface IMetadataService { */ unregisterPackage?(packageName: string): Promise; + /** + * Publish an entire package: + * 1. Validate all draft items (dependency check) + * 2. Snapshot all items in the package + * 3. Increment package version + * 4. Set all items state → active + * @param packageId - The package ID to publish + * @param options - Publish options + * @returns Publish result with version and item count + */ + publishPackage?(packageId: string, options?: { + changeNote?: string; + publishedBy?: string; + validate?: boolean; + }): Promise; + + /** + * Revert entire package to last published state. + * Restores all metadata definitions from their published snapshots. + * @param packageId - The package ID to revert + */ + revertPackage?(packageId: string): Promise; + + /** + * Get the published version of any metadata item (for runtime serving). + * Returns published_definition if exists, else current definition. + * @param type - Metadata type + * @param name - Item name/identifier + * @returns The published definition, or current definition if never published + */ + getPublished?(type: string, name: string): Promise; + // ========================================== // Query / Search // ========================================== diff --git a/packages/spec/src/system/metadata-persistence.test.ts b/packages/spec/src/system/metadata-persistence.test.ts index db0ecfdfb..62ba3b968 100644 --- a/packages/spec/src/system/metadata-persistence.test.ts +++ b/packages/spec/src/system/metadata-persistence.test.ts @@ -17,6 +17,7 @@ import { MetadataManagerConfigSchema, MetadataFallbackStrategySchema, MetadataSourceSchema, + PackagePublishResultSchema, } from './metadata-persistence.zod'; describe('MetadataScopeSchema', () => { @@ -100,6 +101,35 @@ describe('MetadataRecordSchema', () => { expect(record.tags).toEqual(['crm', 'custom']); }); + it('should accept publishing fields', () => { + const record = MetadataRecordSchema.parse({ + id: 'abc-123', + name: 'account_list_view', + type: 'view', + metadata: { columns: ['name'] }, + publishedDefinition: { columns: ['name', 'email'] }, + publishedAt: '2025-06-01T12:00:00Z', + publishedBy: 'admin-user', + }); + + expect(record.publishedDefinition).toEqual({ columns: ['name', 'email'] }); + expect(record.publishedAt).toBe('2025-06-01T12:00:00Z'); + expect(record.publishedBy).toBe('admin-user'); + }); + + it('should allow omitting publishing fields (backward compatible)', () => { + const record = MetadataRecordSchema.parse({ + id: 'abc-123', + name: 'test', + type: 'object', + metadata: {}, + }); + + expect(record.publishedDefinition).toBeUndefined(); + expect(record.publishedAt).toBeUndefined(); + expect(record.publishedBy).toBeUndefined(); + }); + it('should default version to 1', () => { const record = MetadataRecordSchema.parse({ id: 'x', name: 'y', type: 'z', metadata: {}, @@ -518,3 +548,44 @@ describe('MetadataSourceSchema', () => { expect(() => MetadataSourceSchema.parse('unknown')).toThrow(); }); }); + +describe('PackagePublishResultSchema', () => { + it('should accept a successful publish result', () => { + const result = PackagePublishResultSchema.parse({ + success: true, + packageId: 'com.acme.crm', + version: 2, + publishedAt: '2025-06-01T12:00:00Z', + itemsPublished: 5, + }); + + expect(result.success).toBe(true); + expect(result.packageId).toBe('com.acme.crm'); + expect(result.version).toBe(2); + expect(result.publishedAt).toBe('2025-06-01T12:00:00Z'); + expect(result.itemsPublished).toBe(5); + expect(result.validationErrors).toBeUndefined(); + }); + + it('should accept a failed publish result with validation errors', () => { + const result = PackagePublishResultSchema.parse({ + success: false, + packageId: 'com.acme.crm', + version: 1, + publishedAt: '2025-06-01T12:00:00Z', + itemsPublished: 0, + validationErrors: [ + { type: 'view', name: 'missing_view', message: 'Referenced object not found' }, + ], + }); + + expect(result.success).toBe(false); + expect(result.validationErrors).toHaveLength(1); + expect(result.validationErrors![0].type).toBe('view'); + }); + + it('should reject missing required fields', () => { + expect(() => PackagePublishResultSchema.parse({})).toThrow(); + expect(() => PackagePublishResultSchema.parse({ success: true })).toThrow(); + }); +}); diff --git a/packages/spec/src/system/metadata-persistence.zod.ts b/packages/spec/src/system/metadata-persistence.zod.ts index 557d16b09..90fa6de13 100644 --- a/packages/spec/src/system/metadata-persistence.zod.ts +++ b/packages/spec/src/system/metadata-persistence.zod.ts @@ -111,6 +111,14 @@ export const MetadataRecordSchema = z.object({ /** Classification tags */ tags: z.array(z.string()).optional().describe('Classification tags for filtering and grouping'), + /** Package Publishing */ + publishedDefinition: z.unknown().optional() + .describe('Snapshot of the last published definition'), + publishedAt: z.string().datetime().optional() + .describe('When this metadata was last published'), + publishedBy: z.string().optional() + .describe('Who published this version'), + /** Audit */ createdBy: z.string().optional(), createdAt: z.string().datetime().optional().describe('Creation timestamp'), @@ -121,6 +129,25 @@ export const MetadataRecordSchema = z.object({ export type MetadataRecord = z.infer; export type MetadataScope = z.infer; +/** + * Package Publish Result + * Returned by `publishPackage()` after a package-level metadata publish operation. + */ +export const PackagePublishResultSchema = z.object({ + success: z.boolean().describe('Whether the publish succeeded'), + packageId: z.string().describe('The package ID that was published'), + version: z.number().int().describe('New version number after publish'), + publishedAt: z.string().datetime().describe('Publish timestamp'), + itemsPublished: z.number().int().describe('Total metadata items published'), + validationErrors: z.array(z.object({ + type: z.string().describe('Metadata type that failed validation'), + name: z.string().describe('Item name that failed validation'), + message: z.string().describe('Validation error message'), + })).optional().describe('Validation errors if publish failed'), +}); + +export type PackagePublishResult = z.infer; + /** * Metadata Format * Supported file formats for metadata serialization. From 1af324736fa7f61b9c46591c1c9a697ce4493c14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:24:59 +0000 Subject: [PATCH 3/7] feat(metadata): implement publishPackage, revertPackage, getPublished in MetadataManager with tests Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/metadata/src/metadata-manager.ts | 150 +++++++++++++ .../metadata/src/metadata-service.test.ts | 197 ++++++++++++++++++ 2 files changed, 347 insertions(+) diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 3c64ec5eb..bb617bf6b 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -15,6 +15,7 @@ import type { MetadataSaveResult, MetadataWatchEvent, MetadataFormat, + PackagePublishResult, } from '@objectstack/spec/system'; import type { IMetadataService, @@ -333,6 +334,155 @@ export class MetadataManager implements IMetadataService { } } + /** + * Publish an entire package: + * 1. Validate all draft items + * 2. Snapshot all items in the package (publishedDefinition = clone(metadata)) + * 3. Increment version + * 4. Set all items state → active + */ + async publishPackage(packageId: string, options?: { + changeNote?: string; + publishedBy?: string; + validate?: boolean; + }): Promise { + const now = new Date().toISOString(); + const shouldValidate = options?.validate !== false; + const publishedBy = options?.publishedBy; + + // Collect all items belonging to this package + const packageItems: Array<{ type: string; name: string; data: any }> = []; + for (const [type, typeStore] of this.registry) { + for (const [name, data] of typeStore) { + const meta = data as any; + if (meta?.packageId === packageId || meta?.package === packageId) { + packageItems.push({ type, name, data: meta }); + } + } + } + + if (packageItems.length === 0) { + return { + success: false, + packageId, + version: 0, + publishedAt: now, + itemsPublished: 0, + validationErrors: [{ type: '', name: '', message: `No metadata items found for package '${packageId}'` }], + }; + } + + // Validation pass + if (shouldValidate) { + const validationErrors: Array<{ type: string; name: string; message: string }> = []; + for (const item of packageItems) { + const result = await this.validate(item.type, item.data); + if (!result.valid && result.errors) { + for (const err of result.errors) { + validationErrors.push({ + type: item.type, + name: item.name, + message: err.message, + }); + } + } + } + if (validationErrors.length > 0) { + return { + success: false, + packageId, + version: 0, + publishedAt: now, + itemsPublished: 0, + validationErrors, + }; + } + } + + // Determine the next version by finding the max current version across items + let maxVersion = 0; + for (const item of packageItems) { + const v = typeof item.data.version === 'number' ? item.data.version : 0; + if (v > maxVersion) maxVersion = v; + } + const newVersion = maxVersion + 1; + + // Snapshot and update all items + for (const item of packageItems) { + const updated = { + ...item.data, + publishedDefinition: JSON.parse(JSON.stringify(item.data.metadata ?? item.data)), + publishedAt: now, + publishedBy: publishedBy ?? item.data.publishedBy, + version: newVersion, + state: 'active', + }; + await this.register(item.type, item.name, updated); + } + + return { + success: true, + packageId, + version: newVersion, + publishedAt: now, + itemsPublished: packageItems.length, + }; + } + + /** + * Revert entire package to last published state. + * Restores all metadata definitions from their published snapshots. + */ + async revertPackage(packageId: string): Promise { + const packageItems: Array<{ type: string; name: string; data: any }> = []; + for (const [type, typeStore] of this.registry) { + for (const [name, data] of typeStore) { + const meta = data as any; + if (meta?.packageId === packageId || meta?.package === packageId) { + packageItems.push({ type, name, data: meta }); + } + } + } + + if (packageItems.length === 0) { + throw new Error(`No metadata items found for package '${packageId}'`); + } + + // Check that at least one item has a published snapshot + const hasPublished = packageItems.some(item => item.data.publishedDefinition !== undefined); + if (!hasPublished) { + throw new Error(`Package '${packageId}' has never been published`); + } + + for (const item of packageItems) { + if (item.data.publishedDefinition !== undefined) { + const reverted = { + ...item.data, + metadata: JSON.parse(JSON.stringify(item.data.publishedDefinition)), + state: 'active', + }; + await this.register(item.type, item.name, reverted); + } + } + } + + /** + * Get the published version of any metadata item (for runtime serving). + * Returns publishedDefinition if exists, else current definition. + */ + async getPublished(type: string, name: string): Promise { + const item = await this.get(type, name); + if (!item) return undefined; + + const meta = item as any; + if (meta.publishedDefinition !== undefined) { + return meta.publishedDefinition; + } + + // Fall back to current definition (metadata field or the item itself) + return meta.metadata ?? item; + } + // ========================================== // Query / Search // ========================================== diff --git a/packages/metadata/src/metadata-service.test.ts b/packages/metadata/src/metadata-service.test.ts index 0b58d79c6..42b1b34a6 100644 --- a/packages/metadata/src/metadata-service.test.ts +++ b/packages/metadata/src/metadata-service.test.ts @@ -693,4 +693,201 @@ describe('MetadataManager — IMetadataService Contract', () => { return deps.then(result => expect(result).toHaveLength(1)); }); }); + + // ========================================== + // Package Publish / Revert / getPublished + // ========================================== + + describe('publishPackage', () => { + it('should publish all items in a package', async () => { + await manager.register('object', 'opportunity', { + name: 'opportunity', label: 'Opportunity', packageId: 'com.acme.crm', state: 'draft', + metadata: { fields: ['name', 'amount'] }, + }); + await manager.register('view', 'opp_list', { + name: 'opp_list', label: 'Opp List', packageId: 'com.acme.crm', state: 'draft', + metadata: { columns: ['name', 'amount'] }, + }); + + const result = await manager.publishPackage('com.acme.crm', { publishedBy: 'admin' }); + + expect(result.success).toBe(true); + expect(result.packageId).toBe('com.acme.crm'); + expect(result.version).toBe(1); + expect(result.itemsPublished).toBe(2); + expect(result.publishedAt).toBeDefined(); + + // Verify items are now active with published snapshots + const obj = await manager.get('object', 'opportunity') as any; + expect(obj.state).toBe('active'); + expect(obj.publishedDefinition).toBeDefined(); + expect(obj.publishedBy).toBe('admin'); + expect(obj.publishedAt).toBeDefined(); + + const view = await manager.get('view', 'opp_list') as any; + expect(view.state).toBe('active'); + expect(view.publishedDefinition).toBeDefined(); + }); + + it('should increment version on each publish', async () => { + await manager.register('object', 'account', { + name: 'account', packageId: 'crm', state: 'draft', version: 0, + metadata: { fields: ['name'] }, + }); + + const first = await manager.publishPackage('crm'); + expect(first.version).toBe(1); + + const second = await manager.publishPackage('crm'); + expect(second.version).toBe(2); + }); + + it('should fail for empty package', async () => { + const result = await manager.publishPackage('nonexistent'); + expect(result.success).toBe(false); + expect(result.itemsPublished).toBe(0); + expect(result.validationErrors).toBeDefined(); + }); + + it('should fail validation when items are invalid', async () => { + // Register an item without a name (will fail validate) + await manager.register('object', 'bad_item', { + packageId: 'com.acme.bad', state: 'draft', + metadata: {}, + }); + + const result = await manager.publishPackage('com.acme.bad', { validate: true }); + expect(result.success).toBe(false); + expect(result.validationErrors).toBeDefined(); + expect(result.validationErrors!.length).toBeGreaterThan(0); + }); + + it('should skip validation when validate=false', async () => { + await manager.register('object', 'skip_val', { + packageId: 'com.acme.skip', state: 'draft', + metadata: {}, + }); + + const result = await manager.publishPackage('com.acme.skip', { validate: false }); + expect(result.success).toBe(true); + expect(result.itemsPublished).toBe(1); + }); + }); + + describe('revertPackage', () => { + it('should revert to last published state', async () => { + // Register and publish + await manager.register('object', 'account', { + name: 'account', label: 'Account', packageId: 'crm', + metadata: { fields: ['name', 'email'] }, + }); + await manager.publishPackage('crm'); + + // Make edits after publish + const item = await manager.get('object', 'account') as any; + await manager.register('object', 'account', { + ...item, + metadata: { fields: ['name', 'email', 'phone'] }, + state: 'draft', + }); + + // Verify edit was saved + const edited = await manager.get('object', 'account') as any; + expect(edited.metadata.fields).toContain('phone'); + + // Revert + await manager.revertPackage('crm'); + + // Verify reverted to published state + const reverted = await manager.get('object', 'account') as any; + expect(reverted.state).toBe('active'); + expect(reverted.metadata).toEqual(reverted.publishedDefinition); + }); + + it('should throw for non-existent package', async () => { + await expect(manager.revertPackage('nonexistent')).rejects.toThrow('No metadata items found'); + }); + + it('should throw for never-published package', async () => { + await manager.register('object', 'new_item', { + name: 'new_item', packageId: 'com.acme.new', + }); + + await expect(manager.revertPackage('com.acme.new')).rejects.toThrow('has never been published'); + }); + }); + + describe('getPublished', () => { + it('should return published definition when available', async () => { + await manager.register('object', 'account', { + name: 'account', label: 'Account', packageId: 'crm', + metadata: { fields: ['name'] }, + }); + await manager.publishPackage('crm'); + + // Edit after publish + const item = await manager.get('object', 'account') as any; + await manager.register('object', 'account', { + ...item, + metadata: { fields: ['name', 'email', 'phone'] }, + }); + + // getPublished should return the published snapshot, not the edited version + const published = await manager.getPublished('object', 'account'); + expect(published).toBeDefined(); + // The published snapshot was taken from the original metadata + const pubAny = published as any; + expect(pubAny.fields).toBeDefined(); + }); + + it('should return current definition when never published', async () => { + await manager.register('object', 'contact', { + name: 'contact', label: 'Contact', + metadata: { fields: ['first_name'] }, + }); + + const published = await manager.getPublished('object', 'contact'); + expect(published).toBeDefined(); + // Falls back to metadata field + expect((published as any).fields).toEqual(['first_name']); + }); + + it('should return undefined for non-existent item', async () => { + const result = await manager.getPublished('object', 'nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('integration: edit → publish → edit → revert', () => { + it('should preserve published version through edit-revert cycle', async () => { + // Step 1: Initial setup + await manager.register('object', 'project', { + name: 'project', label: 'Project', packageId: 'pm', + metadata: { fields: ['name', 'status'] }, + }); + + // Step 2: Publish v1 + const v1 = await manager.publishPackage('pm', { publishedBy: 'admin' }); + expect(v1.success).toBe(true); + expect(v1.version).toBe(1); + + // Step 3: Edit after publish + const item = await manager.get('object', 'project') as any; + await manager.register('object', 'project', { + ...item, + metadata: { fields: ['name', 'status', 'priority'] }, + state: 'draft', + }); + + // Step 4: End user sees published version + const endUserView = await manager.getPublished('object', 'project') as any; + expect(endUserView.fields).toEqual(['name', 'status']); + + // Step 5: Revert discards draft changes + await manager.revertPackage('pm'); + const reverted = await manager.get('object', 'project') as any; + expect(reverted.state).toBe('active'); + expect(reverted.metadata.fields).toEqual(['name', 'status']); + }); + }); }); From 8741749c8c7667546386c68616ba665c2d44fb42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:27:42 +0000 Subject: [PATCH 4/7] feat(runtime): add publish/revert/getPublished HTTP endpoints to HttpDispatcher and dispatcher-plugin Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/runtime/src/dispatcher-plugin.ts | 18 +++ packages/runtime/src/http-dispatcher.test.ts | 120 +++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 52 ++++++++ 3 files changed, 190 insertions(+) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index c4c2d4638..2efc74b35 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -199,6 +199,24 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + server.post(`${prefix}/packages/:id/publish`, async (req: any, res: any) => { + try { + const result = await dispatcher.handlePackages(`/${req.params.id}/publish`, 'POST', req.body, {}, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server.post(`${prefix}/packages/:id/revert`, async (req: any, res: any) => { + try { + const result = await dispatcher.handlePackages(`/${req.params.id}/revert`, 'POST', req.body, {}, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + // ── Storage ───────────────────────────────────────────────── server.post(`${prefix}/storage/upload`, async (req: any, res: any) => { try { diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 8cfa44627..1a41fb40d 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -469,4 +469,124 @@ describe('HttpDispatcher', () => { ).rejects.toThrow('Disk full'); }); }); + + // ═══════════════════════════════════════════════════════════════ + // Package Publish / Revert Endpoints + // ═══════════════════════════════════════════════════════════════ + + describe('Package publish/revert endpoints', () => { + it('should handle POST /packages/:id/publish via metadata service', async () => { + const mockMetadata = { + publishPackage: vi.fn().mockResolvedValue({ + success: true, + packageId: 'com.acme.crm', + version: 2, + publishedAt: '2025-06-01T00:00:00Z', + itemsPublished: 3, + }), + }; + const mockRegistry = { + getAllPackages: vi.fn().mockReturnValue([]), + enablePackage: vi.fn(), + disablePackage: vi.fn(), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'metadata') return Promise.resolve(mockMetadata); + if (name === 'objectql') return Promise.resolve({ registry: mockRegistry }); + return null; + }); + + const result = await dispatcher.handlePackages('/com.acme.crm/publish', 'POST', { publishedBy: 'admin' }, {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(mockMetadata.publishPackage).toHaveBeenCalledWith('com.acme.crm', { publishedBy: 'admin' }); + }); + + it('should handle POST /packages/:id/revert via metadata service', async () => { + const mockMetadata = { + revertPackage: vi.fn().mockResolvedValue(undefined), + }; + const mockRegistry = { + getAllPackages: vi.fn().mockReturnValue([]), + enablePackage: vi.fn(), + disablePackage: vi.fn(), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'metadata') return Promise.resolve(mockMetadata); + if (name === 'objectql') return Promise.resolve({ registry: mockRegistry }); + return null; + }); + + const result = await dispatcher.handlePackages('/com.acme.crm/revert', 'POST', {}, {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(mockMetadata.revertPackage).toHaveBeenCalledWith('com.acme.crm'); + }); + + it('should fallback to broker for publish when metadata service unavailable', async () => { + const mockRegistry = { + getAllPackages: vi.fn().mockReturnValue([]), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'metadata') return Promise.resolve(null); + if (name === 'objectql') return Promise.resolve({ registry: mockRegistry }); + return null; + }); + mockBroker.call.mockResolvedValue({ success: true, packageId: 'crm', version: 1, publishedAt: '2025-01-01T00:00:00Z', itemsPublished: 2 }); + + const result = await dispatcher.handlePackages('/crm/publish', 'POST', {}, {}, { request: {} }); + expect(result.handled).toBe(true); + expect(mockBroker.call).toHaveBeenCalledWith('metadata.publishPackage', { packageId: 'crm' }, { request: {} }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Metadata getPublished Endpoint + // ═══════════════════════════════════════════════════════════════ + + describe('Metadata getPublished endpoint', () => { + it('should handle GET /metadata/:type/:name/published via metadata service', async () => { + const mockMetadata = { + getPublished: vi.fn().mockResolvedValue({ name: 'account', label: 'Account' }), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'metadata') return Promise.resolve(mockMetadata); + return null; + }); + + const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET'); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data).toEqual({ name: 'account', label: 'Account' }); + expect(mockMetadata.getPublished).toHaveBeenCalledWith('object', 'account'); + }); + + it('should return 404 when published item not found', async () => { + const mockMetadata = { + getPublished: vi.fn().mockResolvedValue(undefined), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'metadata') return Promise.resolve(mockMetadata); + return null; + }); + + const result = await dispatcher.handleMetadata('/object/nonexistent/published', { request: {} }, 'GET'); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(404); + }); + + it('should fallback to broker for getPublished when metadata service unavailable', async () => { + (kernel as any).getService = vi.fn().mockResolvedValue(null); + mockBroker.call.mockResolvedValue({ name: 'account', fields: ['name'] }); + + const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET'); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(mockBroker.call).toHaveBeenCalledWith( + 'metadata.getPublished', + { type: 'object', name: 'account' }, + { request: {} } + ); + }); + }); }); \ No newline at end of file diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index aadca2ef9..c6fd77a33 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -217,6 +217,24 @@ export class HttpDispatcher { } } + // GET /metadata/:type/:name/published → get published version + if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) { + const [type, name] = parts; + const metadataService = await this.getService(CoreServiceName.enum.metadata); + if (metadataService && typeof (metadataService as any).getPublished === 'function') { + const data = await (metadataService as any).getPublished(type, name); + if (data === undefined) return { handled: true, response: this.error('Not found', 404) }; + return { handled: true, response: this.success(data) }; + } + // Broker fallback + try { + const data = await broker.call('metadata.getPublished', { type, name }, { request: context.request }); + return { handled: true, response: this.success(data) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, 404) }; + } + } + // /metadata/:type/:name if (parts.length === 2) { const [type, name] = parts; @@ -467,6 +485,8 @@ export class HttpDispatcher { * - DELETE /packages/:id → uninstall a package * - PATCH /packages/:id/enable → enable a package * - PATCH /packages/:id/disable → disable a package + * - POST /packages/:id/publish → publish a package (metadata snapshot) + * - POST /packages/:id/revert → revert a package to last published state * * Uses ObjectQL SchemaRegistry directly (via the 'objectql' service) * with broker fallback for backward compatibility. @@ -525,6 +545,38 @@ export class HttpDispatcher { return { handled: true, response: this.success(pkg) }; } + // POST /packages/:id/publish → publish package metadata + if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') { + const id = decodeURIComponent(parts[0]); + const metadataService = await this.getService(CoreServiceName.enum.metadata); + if (metadataService && typeof (metadataService as any).publishPackage === 'function') { + const result = await (metadataService as any).publishPackage(id, body || {}); + return { handled: true, response: this.success(result) }; + } + // Broker fallback + if (this.kernel.broker) { + const result = await this.kernel.broker.call('metadata.publishPackage', { packageId: id, ...body }, { request: context.request }); + return { handled: true, response: this.success(result) }; + } + return { handled: true, response: this.error('Metadata service not available', 503) }; + } + + // POST /packages/:id/revert → revert package to last published state + if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { + const id = decodeURIComponent(parts[0]); + const metadataService = await this.getService(CoreServiceName.enum.metadata); + if (metadataService && typeof (metadataService as any).revertPackage === 'function') { + await (metadataService as any).revertPackage(id); + return { handled: true, response: this.success({ success: true }) }; + } + // Broker fallback + if (this.kernel.broker) { + await this.kernel.broker.call('metadata.revertPackage', { packageId: id }, { request: context.request }); + return { handled: true, response: this.success({ success: true }) }; + } + return { handled: true, response: this.error('Metadata service not available', 503) }; + } + // GET /packages/:id → get package if (parts.length === 1 && m === 'GET') { const id = decodeURIComponent(parts[0]); From d62fbe4407d1077b50b44a8120a726e5c25c6713 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:29:58 +0000 Subject: [PATCH 5/7] docs: add package publishing documentation to metadata-service.mdx, README.md, and ROADMAP.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 2 + .../guides/contracts/metadata-service.mdx | 56 +++++++++++++++++++ packages/metadata/README.md | 29 ++++++++++ 3 files changed, 87 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9239ec0a9..20b9c8f17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -398,6 +398,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow - [x] **In-Memory Driver** — Full CRUD, bulk ops, transactions, aggregation pipeline (Mingo), streaming - [x] **In-Memory Driver Persistence** — File-system (Node.js) and localStorage (Browser) persistence adapters with auto-save, custom adapter support - [x] **Metadata Service** — CRUD, query, bulk ops, overlay system, dependency tracking, import/export, file watching +- [x] **Metadata Package Publishing** — `publishPackage`, `revertPackage`, `getPublished` for atomic package-level metadata publishing with version snapshots - [x] **Serializers** — JSON, YAML, TypeScript format support - [x] **Loaders** — Memory, Filesystem, Remote (HTTP) loaders - [x] **REST API** — Auto-generated CRUD/Metadata/Batch/Discovery endpoints @@ -452,6 +453,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow - User overlay persistence across sessions - Multi-instance metadata synchronization - Production-grade metadata storage +- Package-level metadata publishing (publishPackage / revertPackage / getPublished) ### Phase 4b: Infrastructure Service Upgrades (P1 — Weeks 3-4) diff --git a/content/docs/guides/contracts/metadata-service.mdx b/content/docs/guides/contracts/metadata-service.mdx index d07e55dd1..bc6e61d72 100644 --- a/content/docs/guides/contracts/metadata-service.mdx +++ b/content/docs/guides/contracts/metadata-service.mdx @@ -352,3 +352,59 @@ const filteredViews = views.filter(view => { return !v.requiredPermission || userPermissions.includes(v.requiredPermission); }); ``` + +--- + +## Package Publishing + +ObjectStack uses **package-level publishing** to ensure metadata consistency. All metadata items within a package are published atomically — either everything goes live, or nothing does. + +### publishPackage + +Publishes all metadata items in a package: +1. Validates all items (optional) +2. Snapshots each item's definition into `publishedDefinition` +3. Increments the package version +4. Sets all items to `active` state + +```typescript +const result = await metadataService.publishPackage('com.acme.crm', { + publishedBy: 'admin-user', + validate: true, // default: true + changeNote: 'Added opportunity fields', +}); + +console.log(result.success); // true +console.log(result.version); // 2 +console.log(result.itemsPublished); // 5 +console.log(result.publishedAt); // "2025-06-01T12:00:00Z" +``` + +### revertPackage + +Reverts all metadata items in a package to their last published state. Discards any unpublished changes. + +```typescript +await metadataService.revertPackage('com.acme.crm'); +// All items restored to their publishedDefinition snapshots +``` + +### getPublished + +Returns the published version of a metadata item (for runtime/end-user serving). Falls back to the current definition if the item has never been published. + +```typescript +// End user sees the published version +const published = await metadataService.getPublished('object', 'opportunity'); + +// Designer sees the draft version (via regular get) +const draft = await metadataService.get('object', 'opportunity'); +``` + +### REST Endpoints + +| Method | Path | Description | +|:---|:---|:---| +| `POST` | `/packages/:id/publish` | Publish a package | +| `POST` | `/packages/:id/revert` | Revert a package to last published state | +| `GET` | `/metadata/:type/:name/published` | Get published version of a metadata item | diff --git a/packages/metadata/README.md b/packages/metadata/README.md index 453e60c92..aaacc0d9a 100644 --- a/packages/metadata/README.md +++ b/packages/metadata/README.md @@ -86,6 +86,7 @@ The `MetadataManager` is the main orchestrator. It provides: - **Core CRUD**: `register`, `get`, `list`, `unregister`, `exists`, `listNames` - **Convenience**: `getObject`, `listObjects` - **Package Management**: `unregisterPackage` — unload all metadata from a package +- **Package Publishing**: `publishPackage`, `revertPackage`, `getPublished` — atomic package-level metadata publishing - **Query / Search**: `query` with filtering, pagination, sorting by type/scope/state/tags - **Bulk Operations**: `bulkRegister`, `bulkUnregister` with error handling - **Import / Export**: `exportMetadata`, `importMetadata` with conflict resolution (skip/overwrite/merge) @@ -201,6 +202,34 @@ const plugin = MetadataPlugin({ kernel.use(plugin); ``` +## Package Publishing + +ObjectStack supports **package-level metadata publishing** — all metadata items within a package are published atomically. + +### Publish a Package + +```typescript +const result = await manager.publishPackage('com.acme.crm', { + publishedBy: 'admin', + validate: true, +}); +// result: { success: true, version: 2, itemsPublished: 5, publishedAt: '...' } +``` + +### Revert to Last Published State + +```typescript +await manager.revertPackage('com.acme.crm'); +// All items restored to their publishedDefinition snapshots +``` + +### Get Published Version (Runtime Serving) + +```typescript +const published = await manager.getPublished('object', 'opportunity'); +// Returns publishedDefinition if exists, else current definition +``` + ## Package Structure ``` From 1b502b5fdb98ab1b755f767243d5214ed4c91e5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:31:07 +0000 Subject: [PATCH 6/7] refactor: use structuredClone instead of JSON.parse(JSON.stringify()) for deep cloning Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/metadata/src/metadata-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index bb617bf6b..3f3591c73 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -411,7 +411,7 @@ export class MetadataManager implements IMetadataService { for (const item of packageItems) { const updated = { ...item.data, - publishedDefinition: JSON.parse(JSON.stringify(item.data.metadata ?? item.data)), + publishedDefinition: structuredClone(item.data.metadata ?? item.data), publishedAt: now, publishedBy: publishedBy ?? item.data.publishedBy, version: newVersion, @@ -458,7 +458,7 @@ export class MetadataManager implements IMetadataService { if (item.data.publishedDefinition !== undefined) { const reverted = { ...item.data, - metadata: JSON.parse(JSON.stringify(item.data.publishedDefinition)), + metadata: structuredClone(item.data.publishedDefinition), state: 'active', }; await this.register(item.type, item.name, reverted); From b7b8c3defbb54884e9c98369e40f0d9b6b05f422 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:06:15 +0000 Subject: [PATCH 7/7] feat(metadata): add dependency validation to publishPackage - check referenced items exist in package or are already published Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/metadata/src/metadata-manager.ts | 32 +++++++++ .../metadata/src/metadata-service.test.ts | 72 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 3f3591c73..e63ee6890 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -375,6 +375,8 @@ export class MetadataManager implements IMetadataService { // Validation pass if (shouldValidate) { const validationErrors: Array<{ type: string; name: string; message: string }> = []; + + // Schema validation for (const item of packageItems) { const result = await this.validate(item.type, item.data); if (!result.valid && result.errors) { @@ -387,6 +389,36 @@ export class MetadataManager implements IMetadataService { } } } + + // Dependency validation: referenced items must be in the same package or already published + const packageItemKeys = new Set(packageItems.map(i => `${i.type}:${i.name}`)); + for (const item of packageItems) { + const deps = await this.getDependencies(item.type, item.name); + for (const dep of deps) { + const depKey = `${dep.targetType}:${dep.targetName}`; + // Skip if the dependency is within this package + if (packageItemKeys.has(depKey)) continue; + // Check if the dependency exists and has been published + const depItem = await this.get(dep.targetType, dep.targetName); + if (!depItem) { + validationErrors.push({ + type: item.type, + name: item.name, + message: `Dependency '${dep.targetType}:${dep.targetName}' not found`, + }); + } else { + const depMeta = depItem as any; + if (depMeta.publishedDefinition === undefined && depMeta.state !== 'active') { + validationErrors.push({ + type: item.type, + name: item.name, + message: `Dependency '${dep.targetType}:${dep.targetName}' is not published`, + }); + } + } + } + } + if (validationErrors.length > 0) { return { success: false, diff --git a/packages/metadata/src/metadata-service.test.ts b/packages/metadata/src/metadata-service.test.ts index 42b1b34a6..e779bbb1c 100644 --- a/packages/metadata/src/metadata-service.test.ts +++ b/packages/metadata/src/metadata-service.test.ts @@ -772,6 +772,78 @@ describe('MetadataManager — IMetadataService Contract', () => { expect(result.success).toBe(true); expect(result.itemsPublished).toBe(1); }); + + it('should fail when dependency is not found or not published', async () => { + await manager.register('view', 'opp_list', { + name: 'opp_list', label: 'Opp List', packageId: 'com.acme.dep', + metadata: { columns: ['name'] }, + }); + + // Register a dependency pointing to a non-existent item + manager.addDependency({ + sourceType: 'view', + sourceName: 'opp_list', + targetType: 'object', + targetName: 'opportunity', + kind: 'reference', + }); + + const result = await manager.publishPackage('com.acme.dep', { validate: true }); + expect(result.success).toBe(false); + expect(result.validationErrors).toBeDefined(); + expect(result.validationErrors!.some(e => e.message.includes('opportunity'))).toBe(true); + }); + + it('should pass dependency check when target is in the same package', async () => { + await manager.register('object', 'project', { + name: 'project', label: 'Project', packageId: 'com.acme.same', + metadata: { fields: ['name'] }, + }); + await manager.register('view', 'project_list', { + name: 'project_list', label: 'Project List', packageId: 'com.acme.same', + metadata: { columns: ['name'] }, + }); + + // Dependency within the same package + manager.addDependency({ + sourceType: 'view', + sourceName: 'project_list', + targetType: 'object', + targetName: 'project', + kind: 'reference', + }); + + const result = await manager.publishPackage('com.acme.same', { validate: true }); + expect(result.success).toBe(true); + expect(result.itemsPublished).toBe(2); + }); + + it('should pass dependency check when target is already published', async () => { + // Pre-existing published object (different package) + await manager.register('object', 'account', { + name: 'account', label: 'Account', packageId: 'com.acme.core', + publishedDefinition: { fields: ['name'] }, + state: 'active', + }); + + // View in a different package references the published object + await manager.register('view', 'account_list', { + name: 'account_list', label: 'Account List', packageId: 'com.acme.views', + metadata: { columns: ['name'] }, + }); + + manager.addDependency({ + sourceType: 'view', + sourceName: 'account_list', + targetType: 'object', + targetName: 'account', + kind: 'reference', + }); + + const result = await manager.publishPackage('com.acme.views', { validate: true }); + expect(result.success).toBe(true); + expect(result.itemsPublished).toBe(1); + }); }); describe('revertPackage', () => {