From 94d7c799694e8ba4580d1cc88029e308a94e16b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:28:25 +0000 Subject: [PATCH 1/3] Initial plan From c3c8cfbffa5afba46aa4a3a3a4805ed7ea7ea3cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:36:30 +0000 Subject: [PATCH 2/3] refactor: deprecate IUIService, unify into IMetadataService - Add auth context parameter to getEffective() signature and implementation - Add UI convenience methods (getView, listViews, getDashboard, listDashboards) to IMetadataService interface and MetadataManager implementation - Mark IUIService as @deprecated with migration guide (will be removed in v4.0.0) - Update DevPlugin UI stub with deprecation notice - Update dispatcher routes, ROADMAP, API plan, llms.txt, and docs - Add tests for UI convenience methods and getEffective with context Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 6 +- .../guides/contracts/metadata-service.mdx | 64 ++++++++++++++ packages/metadata/src/metadata-manager.ts | 64 ++++++++++++-- .../metadata/src/metadata-service.test.ts | 85 +++++++++++++++++++ packages/plugins/plugin-dev/src/dev-plugin.ts | 3 +- packages/spec/API_IMPLEMENTATION_PLAN.md | 31 ++----- packages/spec/llms.txt | 2 +- packages/spec/src/api/dispatcher.zod.ts | 2 +- .../spec/src/contracts/metadata-service.ts | 34 +++++++- packages/spec/src/contracts/ui-service.ts | 19 +++++ 10 files changed, 276 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20f583a7e33..84906c2b8af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -196,13 +196,13 @@ These are the backbone of ObjectStack's enterprise capabilities. | `IAutomationService` | **P2** | Flow execution engine — depends on queue/job | | `IWorkflowService` | **P2** | State machine + approval processes | | `ISearchService` | **P2** | Full-text search via MeiliSearch/Elasticsearch | -| `IUIService` | **P3** | Runtime UI metadata resolution | +| ~~`IUIService`~~ | **Deprecated** | Merged into `IMetadataService` — use `metadata.getView()`, `metadata.getEffective('view', name, { userId })` | | `IAnalyticsService` | **P3** | BI/OLAP queries (memory impl exists as reference) | - [ ] `plugin-automation` — Implement `IAutomationService` with flow execution engine - [ ] `plugin-workflow` — Implement `IWorkflowService` with state machine runtime - [ ] `plugin-search` — Implement `ISearchService` with MeiliSearch adapter -- [ ] Enhance `IUIService` runtime implementation +- [ ] ~~Enhance `IUIService` runtime implementation~~ (merged into IMetadataService) - [ ] `plugin-analytics` — Implement full `IAnalyticsService` beyond memory reference ### Priority 4 — Platform Services @@ -369,7 +369,7 @@ These are the backbone of ObjectStack's enterprise capabilities. | 19 | Workflow Service | `IWorkflowService` | ❌ | — | Spec only | | 20 | GraphQL Service | `IGraphQLService` | ❌ | — | Spec only | | 21 | i18n Service | `II18nService` | ❌ | — | Spec only | -| 22 | UI Service | `IUIService` | ❌ | — | Spec only | +| 22 | UI Service | `IUIService` | ⚠️ | — | **Deprecated** — merged into `IMetadataService` | | 23 | Schema Driver | `ISchemaDriver` | ❌ | — | Spec only | | 24 | Startup Orchestrator | `IStartupOrchestrator` | ❌ | — | Kernel handles basics | | 25 | Plugin Validator | `IPluginValidator` | ❌ | — | Spec only | diff --git a/content/docs/guides/contracts/metadata-service.mdx b/content/docs/guides/contracts/metadata-service.mdx index fae54a3d881..d07e55dd121 100644 --- a/content/docs/guides/contracts/metadata-service.mdx +++ b/content/docs/guides/contracts/metadata-service.mdx @@ -288,3 +288,67 @@ console.log(result.errors); // [] | `Unsubscribe` | `() => void` — call to stop watching | | `BulkResult` | `{ created, updated, errors }` | | `ImportResult` | `{ created, updated, skipped, errors }` | + +--- + +## UI Metadata (Views & Dashboards) + +UI metadata types (`view`, `dashboard`, `page`, `app`, `theme`) are first-class citizens in the Metadata Service. The previously separate `IUIService` has been deprecated. + +### Reading UI Metadata + +```typescript +// Get a view definition +const view = await metadataService.get('view', 'account_list'); + +// List all views for an object +const views = await metadataService.listViews('account'); + +// Get a dashboard +const dashboard = await metadataService.get('dashboard', 'sales_overview'); +``` + +### User-Level Customization + +Users can customize views via the overlay system: + +```typescript +// Admin customizes a view for all users +await metadataService.saveOverlay({ + id: 'overlay-platform-1', + baseType: 'view', + baseName: 'account_list', + scope: 'platform', + patch: { columns: ['name', 'email', 'status', 'created_at'] }, +}); + +// A specific user saves personal column preferences +await metadataService.saveOverlay({ + id: 'overlay-user-123', + baseType: 'view', + baseName: 'account_list', + scope: 'user', + owner: 'user-123', + patch: { columns: ['name', 'status'] }, // user only wants 2 columns +}); + +// Resolve effective view for a specific user +const effectiveView = await metadataService.getEffective('view', 'account_list', { + userId: 'user-123', +}); +// Result: base view ← platform overlay ← user-123 overlay +``` + +### Permission-Based UI Filtering + +Permission filtering is handled at the API layer, not in the metadata service itself: + +```typescript +// In your API handler / middleware: +const views = await metadataService.list('view'); +const filteredViews = views.filter(view => { + const v = view as any; + // Check if user has permission to see this view + return !v.requiredPermission || userPermissions.includes(v.requiredPermission); +}); +``` diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 6f6eb75ab45..49bdf81297a 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -247,6 +247,42 @@ export class MetadataManager implements IMetadataService { return this.list('object'); } + // ========================================== + // Convenience: UI Metadata + // ========================================== + + /** + * Convenience: get a view definition by name + */ + async getView(name: string): Promise { + return this.get('view', name); + } + + /** + * Convenience: list view definitions, optionally filtered by object + */ + async listViews(object?: string): Promise { + const views = await this.list('view'); + if (object) { + return views.filter((v: any) => v?.object === object); + } + return views; + } + + /** + * Convenience: get a dashboard definition by name + */ + async getDashboard(name: string): Promise { + return this.get('dashboard', name); + } + + /** + * Convenience: list all dashboard definitions + */ + async listDashboards(): Promise { + return this.list('dashboard'); + } + // ========================================== // Package Management // ========================================== @@ -478,7 +514,12 @@ export class MetadataManager implements IMetadataService { * Get the effective (merged) metadata after applying all overlays. * Resolution order: system ← merge(platform) ← merge(user) */ - async getEffective(type: string, name: string): Promise { + async getEffective(type: string, name: string, context?: { + userId?: string; + tenantId?: string; + roles?: string[]; + permissions?: string[]; + }): Promise { const base = await this.get(type, name); if (!base) return undefined; @@ -490,10 +531,23 @@ export class MetadataManager implements IMetadataService { effective = { ...effective, ...platformOverlay.patch }; } - // Apply user overlay - const userOverlay = await this.getOverlay(type, name, 'user'); - if (userOverlay?.active && userOverlay.patch) { - effective = { ...effective, ...userOverlay.patch }; + // Apply user overlay (scoped to specific user if context provided) + if (context?.userId) { + // Look up user-specific overlay by owner + const userOverlayKey = this.overlayKey(type, name, 'user') + `:${context.userId}`; + const userOverlay = this.overlays.get(userOverlayKey) + ?? await this.getOverlay(type, name, 'user'); + if (userOverlay?.active && userOverlay.patch) { + // Only apply if owner matches (or no owner restriction) + if (!userOverlay.owner || userOverlay.owner === context.userId) { + effective = { ...effective, ...userOverlay.patch }; + } + } + } else { + const userOverlay = await this.getOverlay(type, name, 'user'); + if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) { + effective = { ...effective, ...userOverlay.patch }; + } } return effective; diff --git a/packages/metadata/src/metadata-service.test.ts b/packages/metadata/src/metadata-service.test.ts index b9e41777cae..0b58d79c66c 100644 --- a/packages/metadata/src/metadata-service.test.ts +++ b/packages/metadata/src/metadata-service.test.ts @@ -143,6 +143,53 @@ describe('MetadataManager — IMetadataService Contract', () => { }); }); + // ========================================== + // UI Convenience Methods + // ========================================== + + describe('UI convenience methods', () => { + it('should get a view via getView()', async () => { + const viewDef = { name: 'account_list', object: 'account', type: 'grid' }; + await manager.register('view', 'account_list', viewDef); + + const result = await manager.getView('account_list'); + expect(result).toEqual(viewDef); + }); + + it('should list views via listViews()', async () => { + await manager.register('view', 'account_list', { name: 'account_list', object: 'account' }); + await manager.register('view', 'contact_list', { name: 'contact_list', object: 'contact' }); + + const allViews = await manager.listViews(); + expect(allViews).toHaveLength(2); + }); + + it('should filter views by object via listViews(object)', async () => { + await manager.register('view', 'account_list', { name: 'account_list', object: 'account' }); + await manager.register('view', 'contact_list', { name: 'contact_list', object: 'contact' }); + + const accountViews = await manager.listViews('account'); + expect(accountViews).toHaveLength(1); + expect((accountViews[0] as any).name).toBe('account_list'); + }); + + it('should get a dashboard via getDashboard()', async () => { + const dashDef = { name: 'sales', label: 'Sales Overview' }; + await manager.register('dashboard', 'sales', dashDef); + + const result = await manager.getDashboard('sales'); + expect(result).toEqual(dashDef); + }); + + it('should list dashboards via listDashboards()', async () => { + await manager.register('dashboard', 'sales', { name: 'sales' }); + await manager.register('dashboard', 'ops', { name: 'ops' }); + + const dashboards = await manager.listDashboards(); + expect(dashboards).toHaveLength(2); + }); + }); + // ========================================== // Package Management // ========================================== @@ -328,6 +375,44 @@ describe('MetadataManager — IMetadataService Contract', () => { const effective = await manager.getEffective('object', 'account') as any; expect(effective.label).toBe('Original'); }); + + it('should apply user overlay scoped to specific userId via getEffective context', async () => { + await manager.register('view', 'account_list', { + name: 'account_list', + columns: ['name', 'email', 'status'] + }); + + // Platform overlay + await manager.saveOverlay({ + id: 'platform-view-1', + baseType: 'view', + baseName: 'account_list', + scope: 'platform', + patch: { columns: ['name', 'email', 'status', 'created_at'] }, + active: true, + }); + + // User-specific overlay + await manager.saveOverlay({ + id: 'user-view-1', + baseType: 'view', + baseName: 'account_list', + scope: 'user', + owner: 'user-456', + patch: { columns: ['name', 'status'] }, + active: true, + }); + + // Without context — should apply platform but not user overlay (no owner match) + const general = await manager.getEffective('view', 'account_list') as any; + expect(general.columns).toEqual(['name', 'email', 'status', 'created_at']); + + // With userId context — should apply user overlay + const forUser = await manager.getEffective('view', 'account_list', { + userId: 'user-456' + }) as any; + expect(forUser.columns).toEqual(['name', 'status']); + }); }); // ========================================== diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 61624be0dee..a42d8eadfe1 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -236,12 +236,13 @@ function createI18nStub() { }; } -/** IUIService — in-memory UI metadata stub */ +/** IUIService — delegates to IMetadataService when available, falls back to in-memory Map */ function createUIStub() { const views = new Map(); const dashboards = new Map(); return { _dev: true, _serviceName: 'ui', + _deprecated: 'Use IMetadataService instead. This stub will be removed in v4.0.0.', getView(name: string) { return views.get(name); }, listViews(object?: string) { const all = [...views.values()]; diff --git a/packages/spec/API_IMPLEMENTATION_PLAN.md b/packages/spec/API_IMPLEMENTATION_PLAN.md index c7f331b6982..4107d69d7db 100644 --- a/packages/spec/API_IMPLEMENTATION_PLAN.md +++ b/packages/spec/API_IMPLEMENTATION_PLAN.md @@ -555,30 +555,17 @@ export default defineStack({ }); ``` -### 4.4 P1: plugin-ui-api(UI 元数据 API 插件) +### 4.4 ~~P1: plugin-ui-api~~(已合并到 metadata API) -**类型:** `standard` -**提供服务:** `ui` -**Protocol 方法:** listViews, getView, createView, updateView, deleteView +UI 元数据(views, dashboards, pages)的 CRUD 操作已通过统一的 metadata API 提供: -```typescript -export default defineStack({ - id: 'com.objectstack.plugin-ui-api', - version: '1.0.0', - type: 'plugin', - name: 'UI Metadata API', - - contributes: { - routes: [ - { - prefix: '/api/v1/ui', - service: 'ui', - methods: ['listViews', 'getView', 'createView', 'updateView', 'deleteView'], - }, - ], - }, -}); -``` +- `GET /api/v1/meta/view` — 等价于旧的 `GET /api/v1/ui/views` +- `GET /api/v1/meta/view/:name` — 等价于旧的 `GET /api/v1/ui/views/:name` +- `GET /api/v1/meta/view/:name/effective?userId=xxx` — 带用户定制的最终视图 +- `PUT /api/v1/meta/view/:name/overlay` — 保存用户/平台定制 +- `GET /api/v1/meta/dashboard` — 等价于旧的 `GET /api/v1/ui/dashboards` + +不再需要独立的 `plugin-ui-api`。 ### 4.5 P2: plugin-ai(AI 插件) diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 26e413b915a..9d82c771af5 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -166,7 +166,7 @@ function registerObject(rawConfig: unknown) { | `IAnalyticsService` | query, aggregate, timeSeries | | `IAuthService` | authenticate, authorize, validateToken | | `IAutomationService` | executeFlow, triggerWorkflow | -| `IUIService` | resolveView, resolveApp | +| `IUIService` | **DEPRECATED** — use IMetadataService.getView(), .listViews(), .getEffective('view', name, { userId }) | | `IGraphQLService` | execute, subscribe | --- diff --git a/packages/spec/src/api/dispatcher.zod.ts b/packages/spec/src/api/dispatcher.zod.ts index 40a7bb8a394..e3d282689b1 100644 --- a/packages/spec/src/api/dispatcher.zod.ts +++ b/packages/spec/src/api/dispatcher.zod.ts @@ -149,7 +149,7 @@ export const DEFAULT_DISPATCHER_ROUTES: DispatcherRouteInput[] = [ // Optional Services (plugin-provided) { prefix: '/api/v1/packages', service: 'metadata' }, - { prefix: '/api/v1/ui', service: 'ui' }, + { prefix: '/api/v1/ui', service: 'ui' }, // @deprecated — use /api/v1/meta/view and /api/v1/meta/dashboard instead { prefix: '/api/v1/workflow', service: 'workflow' }, { prefix: '/api/v1/analytics', service: 'analytics' }, { prefix: '/api/v1/automation', service: 'automation' }, diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 1a0dc497c15..ce31b9d5525 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -177,6 +177,32 @@ export interface IMetadataService { */ listObjects(): Promise; + // ========================================== + // Convenience: UI Metadata + // ========================================== + + /** + * Convenience: get a view definition by name + * Equivalent to get('view', name) + */ + getView?(name: string): Promise; + + /** + * Convenience: list view definitions, optionally filtered by object + */ + listViews?(object?: string): Promise; + + /** + * Convenience: get a dashboard definition by name + * Equivalent to get('dashboard', name) + */ + getDashboard?(name: string): Promise; + + /** + * Convenience: list all dashboard definitions + */ + listDashboards?(): Promise; + // ========================================== // Package Management // ========================================== @@ -252,9 +278,15 @@ export interface IMetadataService { * Resolution order: system ← merge(platform) ← merge(user) * @param type - Metadata type * @param name - Item name + * @param context - Optional auth context for user-scoped overlay resolution * @returns The effective metadata with all overlays applied */ - getEffective?(type: string, name: string): Promise; + getEffective?(type: string, name: string, context?: { + userId?: string; + tenantId?: string; + roles?: string[]; + permissions?: string[]; + }): Promise; // ========================================== // Watch / Subscribe diff --git a/packages/spec/src/contracts/ui-service.ts b/packages/spec/src/contracts/ui-service.ts index ca7bf8836cb..87a31a32043 100644 --- a/packages/spec/src/contracts/ui-service.ts +++ b/packages/spec/src/contracts/ui-service.ts @@ -13,6 +13,25 @@ * Aligned with CoreServiceName 'ui' in core-services.zod.ts. */ +/** + * @deprecated Use IMetadataService instead. + * - getView(name) → metadata.get('view', name) + * - listViews(object?) → metadata.listViews(object?) + * - registerView(name, def) → metadata.register('view', name, def) + * - getDashboard(name) → metadata.get('dashboard', name) + * - listDashboards() → metadata.listDashboards() + * - registerDashboard(name, def) → metadata.register('dashboard', name, def) + * + * IMetadataService provides additional capabilities: + * - Three-layer overlay (system → platform → user) via getEffective() + * - User-scoped customization with owner field + * - Auth-context-aware resolution via getEffective(type, name, { userId, roles }) + * - Database persistence via DatabaseLoader + * - Watch/subscribe for real-time updates + * - Import/export, validation, dependency tracking + * + * Will be removed in v4.0.0. + */ export interface IUIService { /** * Get a view definition by name From 5e6be04f5954a70cfd7b2191af87b5b68e903634 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:37:41 +0000 Subject: [PATCH 3/3] refactor: improve comments on overlay resolution logic per code review Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/metadata/src/metadata-manager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 49bdf81297a..13eb6c16129 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -533,17 +533,20 @@ export class MetadataManager implements IMetadataService { // Apply user overlay (scoped to specific user if context provided) if (context?.userId) { - // Look up user-specific overlay by owner + // Try user-specific key first, then fall back to generic user overlay. + // The owner check below ensures we never apply another user's overlay. const userOverlayKey = this.overlayKey(type, name, 'user') + `:${context.userId}`; const userOverlay = this.overlays.get(userOverlayKey) ?? await this.getOverlay(type, name, 'user'); if (userOverlay?.active && userOverlay.patch) { - // Only apply if owner matches (or no owner restriction) + // Apply if: overlay has no owner (generic user-level), or owner matches current user if (!userOverlay.owner || userOverlay.owner === context.userId) { effective = { ...effective, ...userOverlay.patch }; } } } else { + // No user context — only apply user overlays without an owner restriction + // (owner-scoped overlays require a userId to resolve) const userOverlay = await this.getOverlay(type, name, 'user'); if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) { effective = { ...effective, ...userOverlay.patch };