Skip to content

Commit 106c6d6

Browse files
authored
Merge pull request #666 from objectstack-ai/copilot/refactor-iuiservice-to-imetadataservice
2 parents 87c06f9 + 5e6be04 commit 106c6d6

10 files changed

Lines changed: 279 additions & 34 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,13 @@ These are the backbone of ObjectStack's enterprise capabilities.
196196
| `IAutomationService` | **P2** | Flow execution engine — depends on queue/job |
197197
| `IWorkflowService` | **P2** | State machine + approval processes |
198198
| `ISearchService` | **P2** | Full-text search via MeiliSearch/Elasticsearch |
199-
| `IUIService` | **P3** | Runtime UI metadata resolution |
199+
| ~~`IUIService`~~ | **Deprecated** | Merged into `IMetadataService` — use `metadata.getView()`, `metadata.getEffective('view', name, { userId })` |
200200
| `IAnalyticsService` | **P3** | BI/OLAP queries (memory impl exists as reference) |
201201

202202
- [ ] `plugin-automation` — Implement `IAutomationService` with flow execution engine
203203
- [ ] `plugin-workflow` — Implement `IWorkflowService` with state machine runtime
204204
- [ ] `plugin-search` — Implement `ISearchService` with MeiliSearch adapter
205-
- [ ] Enhance `IUIService` runtime implementation
205+
- [ ] ~~Enhance `IUIService` runtime implementation~~ (merged into IMetadataService)
206206
- [ ] `plugin-analytics` — Implement full `IAnalyticsService` beyond memory reference
207207

208208
### Priority 4 — Platform Services
@@ -369,7 +369,7 @@ These are the backbone of ObjectStack's enterprise capabilities.
369369
| 19 | Workflow Service | `IWorkflowService` ||| Spec only |
370370
| 20 | GraphQL Service | `IGraphQLService` ||| Spec only |
371371
| 21 | i18n Service | `II18nService` ||| Spec only |
372-
| 22 | UI Service | `IUIService` | || Spec only |
372+
| 22 | UI Service | `IUIService` | ⚠️ || **Deprecated** — merged into `IMetadataService` |
373373
| 23 | Schema Driver | `ISchemaDriver` ||| Spec only |
374374
| 24 | Startup Orchestrator | `IStartupOrchestrator` ||| Kernel handles basics |
375375
| 25 | Plugin Validator | `IPluginValidator` ||| Spec only |

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,67 @@ console.log(result.errors); // []
288288
| `Unsubscribe` | `() => void` — call to stop watching |
289289
| `BulkResult` | `{ created, updated, errors }` |
290290
| `ImportResult` | `{ created, updated, skipped, errors }` |
291+
292+
---
293+
294+
## UI Metadata (Views & Dashboards)
295+
296+
UI metadata types (`view`, `dashboard`, `page`, `app`, `theme`) are first-class citizens in the Metadata Service. The previously separate `IUIService` has been deprecated.
297+
298+
### Reading UI Metadata
299+
300+
```typescript
301+
// Get a view definition
302+
const view = await metadataService.get('view', 'account_list');
303+
304+
// List all views for an object
305+
const views = await metadataService.listViews('account');
306+
307+
// Get a dashboard
308+
const dashboard = await metadataService.get('dashboard', 'sales_overview');
309+
```
310+
311+
### User-Level Customization
312+
313+
Users can customize views via the overlay system:
314+
315+
```typescript
316+
// Admin customizes a view for all users
317+
await metadataService.saveOverlay({
318+
id: 'overlay-platform-1',
319+
baseType: 'view',
320+
baseName: 'account_list',
321+
scope: 'platform',
322+
patch: { columns: ['name', 'email', 'status', 'created_at'] },
323+
});
324+
325+
// A specific user saves personal column preferences
326+
await metadataService.saveOverlay({
327+
id: 'overlay-user-123',
328+
baseType: 'view',
329+
baseName: 'account_list',
330+
scope: 'user',
331+
owner: 'user-123',
332+
patch: { columns: ['name', 'status'] }, // user only wants 2 columns
333+
});
334+
335+
// Resolve effective view for a specific user
336+
const effectiveView = await metadataService.getEffective('view', 'account_list', {
337+
userId: 'user-123',
338+
});
339+
// Result: base view ← platform overlay ← user-123 overlay
340+
```
341+
342+
### Permission-Based UI Filtering
343+
344+
Permission filtering is handled at the API layer, not in the metadata service itself:
345+
346+
```typescript
347+
// In your API handler / middleware:
348+
const views = await metadataService.list('view');
349+
const filteredViews = views.filter(view => {
350+
const v = view as any;
351+
// Check if user has permission to see this view
352+
return !v.requiredPermission || userPermissions.includes(v.requiredPermission);
353+
});
354+
```

packages/metadata/src/metadata-manager.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,42 @@ export class MetadataManager implements IMetadataService {
247247
return this.list('object');
248248
}
249249

250+
// ==========================================
251+
// Convenience: UI Metadata
252+
// ==========================================
253+
254+
/**
255+
* Convenience: get a view definition by name
256+
*/
257+
async getView(name: string): Promise<unknown | undefined> {
258+
return this.get('view', name);
259+
}
260+
261+
/**
262+
* Convenience: list view definitions, optionally filtered by object
263+
*/
264+
async listViews(object?: string): Promise<unknown[]> {
265+
const views = await this.list('view');
266+
if (object) {
267+
return views.filter((v: any) => v?.object === object);
268+
}
269+
return views;
270+
}
271+
272+
/**
273+
* Convenience: get a dashboard definition by name
274+
*/
275+
async getDashboard(name: string): Promise<unknown | undefined> {
276+
return this.get('dashboard', name);
277+
}
278+
279+
/**
280+
* Convenience: list all dashboard definitions
281+
*/
282+
async listDashboards(): Promise<unknown[]> {
283+
return this.list('dashboard');
284+
}
285+
250286
// ==========================================
251287
// Package Management
252288
// ==========================================
@@ -478,7 +514,12 @@ export class MetadataManager implements IMetadataService {
478514
* Get the effective (merged) metadata after applying all overlays.
479515
* Resolution order: system ← merge(platform) ← merge(user)
480516
*/
481-
async getEffective(type: string, name: string): Promise<unknown | undefined> {
517+
async getEffective(type: string, name: string, context?: {
518+
userId?: string;
519+
tenantId?: string;
520+
roles?: string[];
521+
permissions?: string[];
522+
}): Promise<unknown | undefined> {
482523
const base = await this.get(type, name);
483524
if (!base) return undefined;
484525

@@ -490,10 +531,26 @@ export class MetadataManager implements IMetadataService {
490531
effective = { ...effective, ...platformOverlay.patch };
491532
}
492533

493-
// Apply user overlay
494-
const userOverlay = await this.getOverlay(type, name, 'user');
495-
if (userOverlay?.active && userOverlay.patch) {
496-
effective = { ...effective, ...userOverlay.patch };
534+
// Apply user overlay (scoped to specific user if context provided)
535+
if (context?.userId) {
536+
// Try user-specific key first, then fall back to generic user overlay.
537+
// The owner check below ensures we never apply another user's overlay.
538+
const userOverlayKey = this.overlayKey(type, name, 'user') + `:${context.userId}`;
539+
const userOverlay = this.overlays.get(userOverlayKey)
540+
?? await this.getOverlay(type, name, 'user');
541+
if (userOverlay?.active && userOverlay.patch) {
542+
// Apply if: overlay has no owner (generic user-level), or owner matches current user
543+
if (!userOverlay.owner || userOverlay.owner === context.userId) {
544+
effective = { ...effective, ...userOverlay.patch };
545+
}
546+
}
547+
} else {
548+
// No user context — only apply user overlays without an owner restriction
549+
// (owner-scoped overlays require a userId to resolve)
550+
const userOverlay = await this.getOverlay(type, name, 'user');
551+
if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) {
552+
effective = { ...effective, ...userOverlay.patch };
553+
}
497554
}
498555

499556
return effective;

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,53 @@ describe('MetadataManager — IMetadataService Contract', () => {
143143
});
144144
});
145145

146+
// ==========================================
147+
// UI Convenience Methods
148+
// ==========================================
149+
150+
describe('UI convenience methods', () => {
151+
it('should get a view via getView()', async () => {
152+
const viewDef = { name: 'account_list', object: 'account', type: 'grid' };
153+
await manager.register('view', 'account_list', viewDef);
154+
155+
const result = await manager.getView('account_list');
156+
expect(result).toEqual(viewDef);
157+
});
158+
159+
it('should list views via listViews()', async () => {
160+
await manager.register('view', 'account_list', { name: 'account_list', object: 'account' });
161+
await manager.register('view', 'contact_list', { name: 'contact_list', object: 'contact' });
162+
163+
const allViews = await manager.listViews();
164+
expect(allViews).toHaveLength(2);
165+
});
166+
167+
it('should filter views by object via listViews(object)', async () => {
168+
await manager.register('view', 'account_list', { name: 'account_list', object: 'account' });
169+
await manager.register('view', 'contact_list', { name: 'contact_list', object: 'contact' });
170+
171+
const accountViews = await manager.listViews('account');
172+
expect(accountViews).toHaveLength(1);
173+
expect((accountViews[0] as any).name).toBe('account_list');
174+
});
175+
176+
it('should get a dashboard via getDashboard()', async () => {
177+
const dashDef = { name: 'sales', label: 'Sales Overview' };
178+
await manager.register('dashboard', 'sales', dashDef);
179+
180+
const result = await manager.getDashboard('sales');
181+
expect(result).toEqual(dashDef);
182+
});
183+
184+
it('should list dashboards via listDashboards()', async () => {
185+
await manager.register('dashboard', 'sales', { name: 'sales' });
186+
await manager.register('dashboard', 'ops', { name: 'ops' });
187+
188+
const dashboards = await manager.listDashboards();
189+
expect(dashboards).toHaveLength(2);
190+
});
191+
});
192+
146193
// ==========================================
147194
// Package Management
148195
// ==========================================
@@ -328,6 +375,44 @@ describe('MetadataManager — IMetadataService Contract', () => {
328375
const effective = await manager.getEffective('object', 'account') as any;
329376
expect(effective.label).toBe('Original');
330377
});
378+
379+
it('should apply user overlay scoped to specific userId via getEffective context', async () => {
380+
await manager.register('view', 'account_list', {
381+
name: 'account_list',
382+
columns: ['name', 'email', 'status']
383+
});
384+
385+
// Platform overlay
386+
await manager.saveOverlay({
387+
id: 'platform-view-1',
388+
baseType: 'view',
389+
baseName: 'account_list',
390+
scope: 'platform',
391+
patch: { columns: ['name', 'email', 'status', 'created_at'] },
392+
active: true,
393+
});
394+
395+
// User-specific overlay
396+
await manager.saveOverlay({
397+
id: 'user-view-1',
398+
baseType: 'view',
399+
baseName: 'account_list',
400+
scope: 'user',
401+
owner: 'user-456',
402+
patch: { columns: ['name', 'status'] },
403+
active: true,
404+
});
405+
406+
// Without context — should apply platform but not user overlay (no owner match)
407+
const general = await manager.getEffective('view', 'account_list') as any;
408+
expect(general.columns).toEqual(['name', 'email', 'status', 'created_at']);
409+
410+
// With userId context — should apply user overlay
411+
const forUser = await manager.getEffective('view', 'account_list', {
412+
userId: 'user-456'
413+
}) as any;
414+
expect(forUser.columns).toEqual(['name', 'status']);
415+
});
331416
});
332417

333418
// ==========================================

packages/plugins/plugin-dev/src/dev-plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,12 +236,13 @@ function createI18nStub() {
236236
};
237237
}
238238

239-
/** IUIService — in-memory UI metadata stub */
239+
/** IUIService — delegates to IMetadataService when available, falls back to in-memory Map */
240240
function createUIStub() {
241241
const views = new Map<string, any>();
242242
const dashboards = new Map<string, any>();
243243
return {
244244
_dev: true, _serviceName: 'ui',
245+
_deprecated: 'Use IMetadataService instead. This stub will be removed in v4.0.0.',
245246
getView(name: string) { return views.get(name); },
246247
listViews(object?: string) {
247248
const all = [...views.values()];

packages/spec/API_IMPLEMENTATION_PLAN.md

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -555,30 +555,17 @@ export default defineStack({
555555
});
556556
```
557557

558-
### 4.4 P1: plugin-ui-api(UI 元数据 API 插件
558+
### 4.4 ~~P1: plugin-ui-api~~(已合并到 metadata API)
559559

560-
**类型:** `standard`
561-
**提供服务:** `ui`
562-
**Protocol 方法:** listViews, getView, createView, updateView, deleteView
560+
UI 元数据(views, dashboards, pages)的 CRUD 操作已通过统一的 metadata API 提供:
563561

564-
```typescript
565-
export default defineStack({
566-
id: 'com.objectstack.plugin-ui-api',
567-
version: '1.0.0',
568-
type: 'plugin',
569-
name: 'UI Metadata API',
570-
571-
contributes: {
572-
routes: [
573-
{
574-
prefix: '/api/v1/ui',
575-
service: 'ui',
576-
methods: ['listViews', 'getView', 'createView', 'updateView', 'deleteView'],
577-
},
578-
],
579-
},
580-
});
581-
```
562+
- `GET /api/v1/meta/view` — 等价于旧的 `GET /api/v1/ui/views`
563+
- `GET /api/v1/meta/view/:name` — 等价于旧的 `GET /api/v1/ui/views/:name`
564+
- `GET /api/v1/meta/view/:name/effective?userId=xxx` — 带用户定制的最终视图
565+
- `PUT /api/v1/meta/view/:name/overlay` — 保存用户/平台定制
566+
- `GET /api/v1/meta/dashboard` — 等价于旧的 `GET /api/v1/ui/dashboards`
567+
568+
不再需要独立的 `plugin-ui-api`
582569

583570
### 4.5 P2: plugin-ai(AI 插件)
584571

packages/spec/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ function registerObject(rawConfig: unknown) {
166166
| `IAnalyticsService` | query, aggregate, timeSeries |
167167
| `IAuthService` | authenticate, authorize, validateToken |
168168
| `IAutomationService` | executeFlow, triggerWorkflow |
169-
| `IUIService` | resolveView, resolveApp |
169+
| `IUIService` | **DEPRECATED** — use IMetadataService.getView(), .listViews(), .getEffective('view', name, { userId }) |
170170
| `IGraphQLService` | execute, subscribe |
171171

172172
---

packages/spec/src/api/dispatcher.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export const DEFAULT_DISPATCHER_ROUTES: DispatcherRouteInput[] = [
149149

150150
// Optional Services (plugin-provided)
151151
{ prefix: '/api/v1/packages', service: 'metadata' },
152-
{ prefix: '/api/v1/ui', service: 'ui' },
152+
{ prefix: '/api/v1/ui', service: 'ui' }, // @deprecated — use /api/v1/meta/view and /api/v1/meta/dashboard instead
153153
{ prefix: '/api/v1/workflow', service: 'workflow' },
154154
{ prefix: '/api/v1/analytics', service: 'analytics' },
155155
{ prefix: '/api/v1/automation', service: 'automation' },

0 commit comments

Comments
 (0)