Skip to content

Commit 6bef205

Browse files
Copilothotlong
andcommitted
feat(spec): add package publish protocol - MetadataRecordSchema fields, PackagePublishResultSchema, IMetadataService methods
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4f66710 commit 6bef205

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,15 @@ describe('Metadata Service Contract', () => {
343343
listObjects: async () => [],
344344
// Package
345345
unregisterPackage: async () => {},
346+
publishPackage: async () => ({
347+
success: true,
348+
packageId: 'test',
349+
version: 1,
350+
publishedAt: new Date().toISOString(),
351+
itemsPublished: 0,
352+
}),
353+
revertPackage: async () => {},
354+
getPublished: async () => undefined,
346355
// Query
347356
query: async () => ({ items: [], total: 0, page: 1, pageSize: 50 }),
348357
// Bulk
@@ -378,5 +387,39 @@ describe('Metadata Service Contract', () => {
378387
expect(typeof service.validate).toBe('function');
379388
expect(typeof service.getRegisteredTypes).toBe('function');
380389
expect(typeof service.getDependencies).toBe('function');
390+
expect(typeof service.publishPackage).toBe('function');
391+
expect(typeof service.revertPackage).toBe('function');
392+
expect(typeof service.getPublished).toBe('function');
393+
});
394+
395+
it('should allow implementation with package publish support', async () => {
396+
const service: IMetadataService = {
397+
register: async () => {},
398+
get: async () => undefined,
399+
list: async () => [],
400+
unregister: async () => {},
401+
exists: async () => false,
402+
listNames: async () => [],
403+
getObject: async () => undefined,
404+
listObjects: async () => [],
405+
publishPackage: async (packageId, options) => ({
406+
success: true,
407+
packageId,
408+
version: 2,
409+
publishedAt: new Date().toISOString(),
410+
itemsPublished: 3,
411+
}),
412+
revertPackage: async () => {},
413+
getPublished: async (_type, _name) => ({ name: 'account', label: 'Account' }),
414+
};
415+
416+
const result = await service.publishPackage!('com.acme.crm', { publishedBy: 'admin' });
417+
expect(result.success).toBe(true);
418+
expect(result.packageId).toBe('com.acme.crm');
419+
expect(result.version).toBe(2);
420+
expect(result.itemsPublished).toBe(3);
421+
422+
const published = await service.getPublished!('object', 'account');
423+
expect(published).toEqual({ name: 'account', label: 'Account' });
381424
});
382425
});

packages/spec/src/contracts/metadata-service.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838
import type { MetadataQuery, MetadataQueryResult, MetadataValidationResult, MetadataBulkResult, MetadataDependency } from '../kernel/metadata-plugin.zod';
3939
import type { MetadataOverlay } from '../kernel/metadata-customization.zod';
40+
import type { PackagePublishResult } from '../system/metadata-persistence.zod';
4041

4142
/**
4243
* Metadata watch callback signature
@@ -213,6 +214,38 @@ export interface IMetadataService {
213214
*/
214215
unregisterPackage?(packageName: string): Promise<void>;
215216

217+
/**
218+
* Publish an entire package:
219+
* 1. Validate all draft items (dependency check)
220+
* 2. Snapshot all items in the package
221+
* 3. Increment package version
222+
* 4. Set all items state → active
223+
* @param packageId - The package ID to publish
224+
* @param options - Publish options
225+
* @returns Publish result with version and item count
226+
*/
227+
publishPackage?(packageId: string, options?: {
228+
changeNote?: string;
229+
publishedBy?: string;
230+
validate?: boolean;
231+
}): Promise<PackagePublishResult>;
232+
233+
/**
234+
* Revert entire package to last published state.
235+
* Restores all metadata definitions from their published snapshots.
236+
* @param packageId - The package ID to revert
237+
*/
238+
revertPackage?(packageId: string): Promise<void>;
239+
240+
/**
241+
* Get the published version of any metadata item (for runtime serving).
242+
* Returns published_definition if exists, else current definition.
243+
* @param type - Metadata type
244+
* @param name - Item name/identifier
245+
* @returns The published definition, or current definition if never published
246+
*/
247+
getPublished?(type: string, name: string): Promise<unknown | undefined>;
248+
216249
// ==========================================
217250
// Query / Search
218251
// ==========================================

packages/spec/src/system/metadata-persistence.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
MetadataManagerConfigSchema,
1818
MetadataFallbackStrategySchema,
1919
MetadataSourceSchema,
20+
PackagePublishResultSchema,
2021
} from './metadata-persistence.zod';
2122

2223
describe('MetadataScopeSchema', () => {
@@ -100,6 +101,35 @@ describe('MetadataRecordSchema', () => {
100101
expect(record.tags).toEqual(['crm', 'custom']);
101102
});
102103

104+
it('should accept publishing fields', () => {
105+
const record = MetadataRecordSchema.parse({
106+
id: 'abc-123',
107+
name: 'account_list_view',
108+
type: 'view',
109+
metadata: { columns: ['name'] },
110+
publishedDefinition: { columns: ['name', 'email'] },
111+
publishedAt: '2025-06-01T12:00:00Z',
112+
publishedBy: 'admin-user',
113+
});
114+
115+
expect(record.publishedDefinition).toEqual({ columns: ['name', 'email'] });
116+
expect(record.publishedAt).toBe('2025-06-01T12:00:00Z');
117+
expect(record.publishedBy).toBe('admin-user');
118+
});
119+
120+
it('should allow omitting publishing fields (backward compatible)', () => {
121+
const record = MetadataRecordSchema.parse({
122+
id: 'abc-123',
123+
name: 'test',
124+
type: 'object',
125+
metadata: {},
126+
});
127+
128+
expect(record.publishedDefinition).toBeUndefined();
129+
expect(record.publishedAt).toBeUndefined();
130+
expect(record.publishedBy).toBeUndefined();
131+
});
132+
103133
it('should default version to 1', () => {
104134
const record = MetadataRecordSchema.parse({
105135
id: 'x', name: 'y', type: 'z', metadata: {},
@@ -518,3 +548,44 @@ describe('MetadataSourceSchema', () => {
518548
expect(() => MetadataSourceSchema.parse('unknown')).toThrow();
519549
});
520550
});
551+
552+
describe('PackagePublishResultSchema', () => {
553+
it('should accept a successful publish result', () => {
554+
const result = PackagePublishResultSchema.parse({
555+
success: true,
556+
packageId: 'com.acme.crm',
557+
version: 2,
558+
publishedAt: '2025-06-01T12:00:00Z',
559+
itemsPublished: 5,
560+
});
561+
562+
expect(result.success).toBe(true);
563+
expect(result.packageId).toBe('com.acme.crm');
564+
expect(result.version).toBe(2);
565+
expect(result.publishedAt).toBe('2025-06-01T12:00:00Z');
566+
expect(result.itemsPublished).toBe(5);
567+
expect(result.validationErrors).toBeUndefined();
568+
});
569+
570+
it('should accept a failed publish result with validation errors', () => {
571+
const result = PackagePublishResultSchema.parse({
572+
success: false,
573+
packageId: 'com.acme.crm',
574+
version: 1,
575+
publishedAt: '2025-06-01T12:00:00Z',
576+
itemsPublished: 0,
577+
validationErrors: [
578+
{ type: 'view', name: 'missing_view', message: 'Referenced object not found' },
579+
],
580+
});
581+
582+
expect(result.success).toBe(false);
583+
expect(result.validationErrors).toHaveLength(1);
584+
expect(result.validationErrors![0].type).toBe('view');
585+
});
586+
587+
it('should reject missing required fields', () => {
588+
expect(() => PackagePublishResultSchema.parse({})).toThrow();
589+
expect(() => PackagePublishResultSchema.parse({ success: true })).toThrow();
590+
});
591+
});

packages/spec/src/system/metadata-persistence.zod.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ export const MetadataRecordSchema = z.object({
111111
/** Classification tags */
112112
tags: z.array(z.string()).optional().describe('Classification tags for filtering and grouping'),
113113

114+
/** Package Publishing */
115+
publishedDefinition: z.unknown().optional()
116+
.describe('Snapshot of the last published definition'),
117+
publishedAt: z.string().datetime().optional()
118+
.describe('When this metadata was last published'),
119+
publishedBy: z.string().optional()
120+
.describe('Who published this version'),
121+
114122
/** Audit */
115123
createdBy: z.string().optional(),
116124
createdAt: z.string().datetime().optional().describe('Creation timestamp'),
@@ -121,6 +129,25 @@ export const MetadataRecordSchema = z.object({
121129
export type MetadataRecord = z.infer<typeof MetadataRecordSchema>;
122130
export type MetadataScope = z.infer<typeof MetadataScopeSchema>;
123131

132+
/**
133+
* Package Publish Result
134+
* Returned by `publishPackage()` after a package-level metadata publish operation.
135+
*/
136+
export const PackagePublishResultSchema = z.object({
137+
success: z.boolean().describe('Whether the publish succeeded'),
138+
packageId: z.string().describe('The package ID that was published'),
139+
version: z.number().int().describe('New version number after publish'),
140+
publishedAt: z.string().datetime().describe('Publish timestamp'),
141+
itemsPublished: z.number().int().describe('Total metadata items published'),
142+
validationErrors: z.array(z.object({
143+
type: z.string().describe('Metadata type that failed validation'),
144+
name: z.string().describe('Item name that failed validation'),
145+
message: z.string().describe('Validation error message'),
146+
})).optional().describe('Validation errors if publish failed'),
147+
});
148+
149+
export type PackagePublishResult = z.infer<typeof PackagePublishResultSchema>;
150+
124151
/**
125152
* Metadata Format
126153
* Supported file formats for metadata serialization.

0 commit comments

Comments
 (0)