Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…s, PackagePublishResultSchema, IMetadataService methods Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
… in MetadataManager with tests Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…Dispatcher and dispatcher-plugin Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…EADME.md, and ROADMAP.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
… for deep cloning Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
|
@copilot 仔细评估是否要完成的任务中要求的所有开发 |
…eferenced items exist in package or are already published Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Completed a thorough audit against all issue requirements. Here's the status: A. Protocol (
B. Runtime (
C. API (
D. Tests — ✅ All done (27 new tests)
E. Docs & ROADMAP — ✅ All done
The dependency validation was the main gap I found and fixed in b7b8c3d. |
There was a problem hiding this comment.
Pull request overview
Implements a package-level metadata publishing workflow across the spec contract, metadata runtime manager, and runtime HTTP surface so dependent metadata can be published/reverted atomically and consumers can fetch the last published snapshot.
Changes:
- Extend the spec metadata persistence model with
publishedDefinition/publishedAt/publishedByand addPackagePublishResult+ newIMetadataServicecontract methods. - Add
publishPackage(),revertPackage(), andgetPublished()toMetadataManagerwith validation + snapshotting behavior, plus corresponding tests. - Expose publish/revert and getPublished behaviors through runtime HTTP dispatching and update docs/roadmap.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/system/metadata-persistence.zod.ts | Adds published snapshot fields to MetadataRecordSchema and introduces PackagePublishResultSchema. |
| packages/spec/src/system/metadata-persistence.test.ts | Adds tests for new publishing fields and publish result schema validation. |
| packages/spec/src/contracts/metadata-service.ts | Extends IMetadataService with optional package publish/revert/getPublished methods. |
| packages/spec/src/contracts/metadata-service.test.ts | Updates contract tests to include the new optional methods. |
| packages/metadata/src/metadata-manager.ts | Implements publishPackage, revertPackage, and getPublished in the runtime metadata manager. |
| packages/metadata/src/metadata-service.test.ts | Adds unit + integration-style tests for package publish/revert/getPublished behavior. |
| packages/runtime/src/http-dispatcher.ts | Adds HTTP dispatch support for publish/revert and .../published retrieval. |
| packages/runtime/src/http-dispatcher.test.ts | Adds tests for the new dispatcher endpoints and broker fallbacks. |
| packages/runtime/src/dispatcher-plugin.ts | Registers new package publish/revert HTTP routes in the dispatcher plugin. |
| packages/metadata/README.md | Documents the new package publishing APIs. |
| content/docs/guides/contracts/metadata-service.mdx | Adds documentation for publish/revert/getPublished and REST endpoints. |
| ROADMAP.md | Marks package-level metadata publishing as completed and references follow-up work. |
| async publishPackage(packageId: string, options?: { | ||
| changeNote?: string; | ||
| publishedBy?: string; | ||
| validate?: boolean; | ||
| }): Promise<PackagePublishResult> { | ||
| const now = new Date().toISOString(); | ||
| const shouldValidate = options?.validate !== false; | ||
| const publishedBy = options?.publishedBy; | ||
|
|
There was a problem hiding this comment.
publishPackage() accepts changeNote in options but never uses or persists it. If it’s intentionally not supported yet, consider removing it from the options (or explicitly documenting it as not implemented); otherwise persist it as part of the publish audit trail so callers don’t assume it’s recorded.
| |:---|:---|:---| | ||
| | `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 | |
There was a problem hiding this comment.
The docs list REST endpoints under /metadata/..., but the runtime/adapters in this repo expose metadata routes under /meta (e.g. /api/v1/meta/...). To avoid sending users to non-existent endpoints, update these paths to the actual /meta/:type/:name/published route (and keep it consistent with the rest of the docs).
| | `GET` | `/metadata/:type/:name/published` | Get published version of a metadata item | | |
| | `GET` | `/meta/:type/:name/published` | Get published version of a metadata item | |
| // 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) }; | ||
| } |
There was a problem hiding this comment.
handlePackages() currently requires an ObjectQL registry to be present before it will route any requests. That means the new /packages/:id/publish and /packages/:id/revert endpoints won’t work when the ObjectQL service/registry is unavailable (even if the metadata service is available), because the handler returns early to handlePackagesViaBroker() which doesn’t implement publish/revert. Consider handling publish/revert before the registry availability check, or adding publish/revert support to the broker fallback path.
| // 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) }; | ||
| } |
There was a problem hiding this comment.
In the broker fallback for GET /meta/:type/:name/published, the result is always returned as 200 even if the broker returns undefined. The metadata-service path treats undefined as not found (404), so the broker fallback should mirror that behavior (check for undefined and return 404) to keep the endpoint semantics consistent.
| 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 }); | ||
| } |
There was a problem hiding this comment.
publishPackage() only scans the in-memory registry to collect package items. Items that exist only in loaders (e.g. DatabaseLoader/FilesystemLoader) won’t be included, so publishing a persisted package can incorrectly return “No metadata items found” or publish an incomplete set. Consider collecting items via the loader-backed APIs (e.g. iterate registered types and call list()/loadMany() then filter by packageId), and ensure the publish operation covers the full persisted package state.
| 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 }); | |
| } | |
| const packageItems: Array<{ type: string; name: string; data: any }> = []; | |
| const seenKeys = new Set<string>(); | |
| // 1) In-memory registry scan (existing behavior) | |
| 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) { | |
| const key = `${type}:${name}`; | |
| if (!seenKeys.has(key)) { | |
| seenKeys.add(key); | |
| packageItems.push({ type, name, data: meta }); | |
| } | |
| } | |
| } | |
| } | |
| // 2) Loader-backed collection via query API (ensures persisted-only items are included) | |
| // We access `query` through `any` to avoid changing the public interface here while still | |
| // leveraging the loader-backed implementation that likely exists behind IMetadataService. | |
| if (typeof (this as any).query === 'function') { | |
| try { | |
| const queryResult = await (this as any).query({ packageId } as any); | |
| const rawItems: any[] = | |
| (queryResult && (queryResult.items ?? queryResult.results ?? queryResult.data)) ?? []; | |
| for (const raw of rawItems) { | |
| const meta = raw as any; | |
| const itemPackageId = meta?.packageId ?? meta?.package; | |
| if (itemPackageId !== packageId) continue; | |
| const type: string | undefined = | |
| meta?.type ?? meta?.metadataType ?? meta?.kind; | |
| const name: string | undefined = | |
| meta?.name ?? meta?.id; | |
| if (!type || !name) continue; | |
| const key = `${type}:${name}`; | |
| if (seenKeys.has(key)) continue; | |
| seenKeys.add(key); | |
| packageItems.push({ type, name, data: meta }); | |
| } | |
| } catch (err) { | |
| // Fallback silently to registry-only behavior if the query API fails; | |
| // publishing should not crash due to an unsupported/failed query call. | |
| if ((this as any).logger && typeof (this as any).logger.warn === 'function') { | |
| (this as any).logger.warn( | |
| `publishPackage: loader-backed query failed for package '${packageId}':`, | |
| err, | |
| ); | |
| } |
| // 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: structuredClone(item.data.metadata ?? item.data), | ||
| publishedAt: now, | ||
| publishedBy: publishedBy ?? item.data.publishedBy, | ||
| version: newVersion, | ||
| state: 'active', | ||
| }; |
There was a problem hiding this comment.
The code uses the existing version field to represent the package publish version (newVersion) and overwrites it for every item. In this codebase, MetadataRecord.version is already used for optimistic concurrency / update counting (e.g. DatabaseLoader increments it on each save). Mixing these semantics will make publish versions drift with edits and can break concurrency expectations. Consider introducing a dedicated publish/version field (e.g. publishedVersion/packageVersion) and keep version for concurrency, or compute publish version from a separate stored counter.
| const updated = { | ||
| ...item.data, | ||
| publishedDefinition: structuredClone(item.data.metadata ?? item.data), | ||
| publishedAt: now, | ||
| publishedBy: publishedBy ?? item.data.publishedBy, | ||
| version: newVersion, | ||
| state: 'active', | ||
| }; |
There was a problem hiding this comment.
publishedDefinition is set via structuredClone(item.data.metadata ?? item.data). If an item is missing the metadata payload envelope, this snapshots the entire record object (including audit fields / previously publishedDefinition), and revertPackage() later writes that back into metadata, corrupting the stored shape. It would be safer to snapshot only item.data.metadata (and treat missing metadata as a validation error) so publishedDefinition consistently represents the published definition payload.
| metadata: structuredClone(item.data.publishedDefinition), | ||
| state: 'active', | ||
| }; | ||
| await this.register(item.type, item.name, reverted); |
There was a problem hiding this comment.
revertPackage() only updates items that already have publishedDefinition. New items created after the last publish (no publishedDefinition) are left untouched, so a “revert” can still leave unpublished additions in the package. Consider removing/archiving items without a published snapshot when reverting, so the package truly returns to the last published state.
| await this.register(item.type, item.name, reverted); | |
| await this.register(item.type, item.name, reverted); | |
| } else { | |
| // Item was created after the last publish and has no published snapshot. | |
| // Remove it so the package truly matches the last published state. | |
| const typeStore = this.registry.get(item.type); | |
| typeStore?.delete(item.name); |
Metadata items have cross-dependencies (Interface → View → Object → Field → Flow). Publishing individually risks broken references for end users. This implements atomic package-level publishing aligned with Salesforce 2GP / Power Platform Solution patterns.
Protocol (
packages/spec)MetadataRecordSchema— 3 new optional fields:publishedDefinition,publishedAt,publishedByPackagePublishResultSchema— new Zod schema for publish resultsIMetadataService— 3 new optional methods on the contractRuntime (
packages/metadata)MetadataManager.publishPackage()— schema validation → dependency validation → snapshot → version++ → state=activeMetadataManager.revertPackage()— restore all items from published snapshotsMetadataManager.getPublished()— return published definition, fallback to currentAPI (
packages/runtime)POST /packages/:id/publishandPOST /packages/:id/revertonhandlePackages()GET /metadata/:type/:name/publishedonhandleMetadata()dispatcher-plugin.tsUsage
Tests
27 new tests across spec (6), metadata (15), runtime (6). All existing tests pass.
publishPackage(): normal publish, version increment, empty package, schema validation failure, dependency not found, dependency in same package (pass), dependency already published (pass), skip validationrevertPackage(): normal revert, non-existent package, never-published errorgetPublished(): published returns snapshot, unpublished returns definition, non-existent returns undefinedDocs
Updated
metadata-service.mdx,packages/metadata/README.md,ROADMAP.md.Original prompt
This section details on the original issue you should resolve
<issue_title>[协议+Runtime] Package 级统一元数据发布机制 (publishPackage)</issue_title>
<issue_description>## 背景
基于对 Airtable Interface Designer、Salesforce 2GP Package、Power Platform Solution、Retool/Appsmith 等平台的分析(参见 #823),我们确定:
发布粒度应该是 Package(解决方案),而不是单个元数据项。
为什么不是按单个元数据发布?
元数据之间有依赖关系:Interface → View → Object → Field → Flow。如果只发布了 Interface 但没发布它引用的新 Object 字段,终端用户看到的界面会报错。
Salesforce 和 Power Platform 的做法是:整个 Package/Solution 一起发布,要么全部上线,要么全部不上。
行业对标
sfdx force:package:version:createObjectStack 定位企业级 → 采用 Salesforce/Power Platform 的 Package 级发布。
现有基础设施
已有的组件已经覆盖了 80%:
MetadataRecordSchema(metadata-persistence.zod.ts)state: draft/active/archivedMetadataStateSchemaIMetadataService(metadata-service.ts)MetadataManagerDeployBundleSchema(deploy-bundle.zod.ts)InstalledPackageSchema(package-registry.zod.ts)package_id字段 onsys_metadata需要做的事情
A. 协议层 (
packages/spec)A1. 扩展
MetadataRecordSchema— 加 3 个字段A2. 扩展
IMetadataService合约 — 加 3 个方法A3. 定义
PackagePublishResult类型B. Runtime 层 (
packages/metadata)B1.
MetadataManager实现publishPackage()package_id === packageId的所有元数据published_definition = clone(definition)for each itemdraft→activeB2.
MetadataManager实现revertPackage()definition = clone(published_definition)for each itemstate → active(丢弃未发布修改)B3.
MetadataManager实现getPublished()published_definitionif existsdefinitionif never publishedC. API 层 (
packages/runtime)C1.
HttpDispatcher加 publish 端点D. 测试
publishPackage()单元测试:正常发布、依赖校验失败、空包revertPackage()单元测试:正常回滚、从未发布过时报错getPublished()单元测试:已发布返回快照、未发布返回 definitionE. 文档 & ROADMAP
packages/metadata/README.md加 Package Publish 章节ROADMAP.mdcontent/docs/guides/contracts/metadata-service.mdx加 publishPackage 文档数据流
工作量估计
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.