Skip to content

Commit 33899db

Browse files
authored
Merge pull request #825 from objectstack-ai/copilot/implement-package-metadata-publishing
2 parents 57e814a + b7b8c3d commit 33899db

12 files changed

Lines changed: 902 additions & 0 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
398398
- [x] **In-Memory Driver** — Full CRUD, bulk ops, transactions, aggregation pipeline (Mingo), streaming
399399
- [x] **In-Memory Driver Persistence** — File-system (Node.js) and localStorage (Browser) persistence adapters with auto-save, custom adapter support
400400
- [x] **Metadata Service** — CRUD, query, bulk ops, overlay system, dependency tracking, import/export, file watching
401+
- [x] **Metadata Package Publishing**`publishPackage`, `revertPackage`, `getPublished` for atomic package-level metadata publishing with version snapshots
401402
- [x] **Serializers** — JSON, YAML, TypeScript format support
402403
- [x] **Loaders** — Memory, Filesystem, Remote (HTTP) loaders
403404
- [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
452453
- User overlay persistence across sessions
453454
- Multi-instance metadata synchronization
454455
- Production-grade metadata storage
456+
- Package-level metadata publishing (publishPackage / revertPackage / getPublished)
455457

456458
### Phase 4b: Infrastructure Service Upgrades (P1 — Weeks 3-4)
457459

content/docs/guides/contracts/metadata-service.mdx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,59 @@ const filteredViews = views.filter(view => {
352352
return !v.requiredPermission || userPermissions.includes(v.requiredPermission);
353353
});
354354
```
355+
356+
---
357+
358+
## Package Publishing
359+
360+
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.
361+
362+
### publishPackage
363+
364+
Publishes all metadata items in a package:
365+
1. Validates all items (optional)
366+
2. Snapshots each item's definition into `publishedDefinition`
367+
3. Increments the package version
368+
4. Sets all items to `active` state
369+
370+
```typescript
371+
const result = await metadataService.publishPackage('com.acme.crm', {
372+
publishedBy: 'admin-user',
373+
validate: true, // default: true
374+
changeNote: 'Added opportunity fields',
375+
});
376+
377+
console.log(result.success); // true
378+
console.log(result.version); // 2
379+
console.log(result.itemsPublished); // 5
380+
console.log(result.publishedAt); // "2025-06-01T12:00:00Z"
381+
```
382+
383+
### revertPackage
384+
385+
Reverts all metadata items in a package to their last published state. Discards any unpublished changes.
386+
387+
```typescript
388+
await metadataService.revertPackage('com.acme.crm');
389+
// All items restored to their publishedDefinition snapshots
390+
```
391+
392+
### getPublished
393+
394+
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.
395+
396+
```typescript
397+
// End user sees the published version
398+
const published = await metadataService.getPublished('object', 'opportunity');
399+
400+
// Designer sees the draft version (via regular get)
401+
const draft = await metadataService.get('object', 'opportunity');
402+
```
403+
404+
### REST Endpoints
405+
406+
| Method | Path | Description |
407+
|:---|:---|:---|
408+
| `POST` | `/packages/:id/publish` | Publish a package |
409+
| `POST` | `/packages/:id/revert` | Revert a package to last published state |
410+
| `GET` | `/metadata/:type/:name/published` | Get published version of a metadata item |

packages/metadata/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ The `MetadataManager` is the main orchestrator. It provides:
8686
- **Core CRUD**: `register`, `get`, `list`, `unregister`, `exists`, `listNames`
8787
- **Convenience**: `getObject`, `listObjects`
8888
- **Package Management**: `unregisterPackage` — unload all metadata from a package
89+
- **Package Publishing**: `publishPackage`, `revertPackage`, `getPublished` — atomic package-level metadata publishing
8990
- **Query / Search**: `query` with filtering, pagination, sorting by type/scope/state/tags
9091
- **Bulk Operations**: `bulkRegister`, `bulkUnregister` with error handling
9192
- **Import / Export**: `exportMetadata`, `importMetadata` with conflict resolution (skip/overwrite/merge)
@@ -201,6 +202,34 @@ const plugin = MetadataPlugin({
201202
kernel.use(plugin);
202203
```
203204

205+
## Package Publishing
206+
207+
ObjectStack supports **package-level metadata publishing** — all metadata items within a package are published atomically.
208+
209+
### Publish a Package
210+
211+
```typescript
212+
const result = await manager.publishPackage('com.acme.crm', {
213+
publishedBy: 'admin',
214+
validate: true,
215+
});
216+
// result: { success: true, version: 2, itemsPublished: 5, publishedAt: '...' }
217+
```
218+
219+
### Revert to Last Published State
220+
221+
```typescript
222+
await manager.revertPackage('com.acme.crm');
223+
// All items restored to their publishedDefinition snapshots
224+
```
225+
226+
### Get Published Version (Runtime Serving)
227+
228+
```typescript
229+
const published = await manager.getPublished('object', 'opportunity');
230+
// Returns publishedDefinition if exists, else current definition
231+
```
232+
204233
## Package Structure
205234

206235
```

packages/metadata/src/metadata-manager.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
MetadataSaveResult,
1616
MetadataWatchEvent,
1717
MetadataFormat,
18+
PackagePublishResult,
1819
} from '@objectstack/spec/system';
1920
import type {
2021
IMetadataService,
@@ -333,6 +334,187 @@ export class MetadataManager implements IMetadataService {
333334
}
334335
}
335336

337+
/**
338+
* Publish an entire package:
339+
* 1. Validate all draft items
340+
* 2. Snapshot all items in the package (publishedDefinition = clone(metadata))
341+
* 3. Increment version
342+
* 4. Set all items state → active
343+
*/
344+
async publishPackage(packageId: string, options?: {
345+
changeNote?: string;
346+
publishedBy?: string;
347+
validate?: boolean;
348+
}): Promise<PackagePublishResult> {
349+
const now = new Date().toISOString();
350+
const shouldValidate = options?.validate !== false;
351+
const publishedBy = options?.publishedBy;
352+
353+
// Collect all items belonging to this package
354+
const packageItems: Array<{ type: string; name: string; data: any }> = [];
355+
for (const [type, typeStore] of this.registry) {
356+
for (const [name, data] of typeStore) {
357+
const meta = data as any;
358+
if (meta?.packageId === packageId || meta?.package === packageId) {
359+
packageItems.push({ type, name, data: meta });
360+
}
361+
}
362+
}
363+
364+
if (packageItems.length === 0) {
365+
return {
366+
success: false,
367+
packageId,
368+
version: 0,
369+
publishedAt: now,
370+
itemsPublished: 0,
371+
validationErrors: [{ type: '', name: '', message: `No metadata items found for package '${packageId}'` }],
372+
};
373+
}
374+
375+
// Validation pass
376+
if (shouldValidate) {
377+
const validationErrors: Array<{ type: string; name: string; message: string }> = [];
378+
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+
336518
// ==========================================
337519
// Query / Search
338520
// ==========================================

0 commit comments

Comments
 (0)