Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
64 changes: 64 additions & 0 deletions content/docs/guides/contracts/metadata-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
```
Comment on lines +294 to +309

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc uses metadataService.listViews('account') / getEffective(..., { userId }) as the primary UI examples. Since listViews() is optional in the IMetadataService contract, readers may not be able to call it safely on all implementations. Consider either switching the examples to metadataService.list('view') + filtering, or explicitly stating that listViews()/getView() are convenience helpers provided by MetadataManager (and may not exist on every IMetadataService).

Copilot uses AI. Check for mistakes.

### 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);
});
```
67 changes: 62 additions & 5 deletions packages/metadata/src/metadata-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown | undefined> {
return this.get('view', name);
}

/**
* Convenience: list view definitions, optionally filtered by object
*/
async listViews(object?: string): Promise<unknown[]> {
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<unknown | undefined> {
return this.get('dashboard', name);
}

/**
* Convenience: list all dashboard definitions
*/
async listDashboards(): Promise<unknown[]> {
return this.list('dashboard');
}

// ==========================================
// Package Management
// ==========================================
Expand Down Expand Up @@ -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<unknown | undefined> {
async getEffective(type: string, name: string, context?: {
userId?: string;
tenantId?: string;
roles?: string[];
permissions?: string[];
}): Promise<unknown | undefined> {
const base = await this.get(type, name);
if (!base) return undefined;

Expand All @@ -490,10 +531,26 @@ 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) {
// 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) {
// 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 };
Comment on lines +535 to +544

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getEffective() tries to load a user-specific overlay via this.overlayKey(..., 'user') + ":${context.userId}", but overlayKey()/saveOverlay() currently key overlays only by type:name:scope (no owner). As a result this lookup can never succeed, and—more importantly—it's impossible to store more than one user overlay per item because user overlays will overwrite each other. Consider incorporating owner into the storage key for scope==='user' (and updating getOverlay/saveOverlay/removeOverlay accordingly, plus a test that saves overlays for two different users and resolves each correctly).

Copilot uses AI. Check for mistakes.
}
}
} 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 };
}
}

return effective;
Expand Down
85 changes: 85 additions & 0 deletions packages/metadata/src/metadata-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ==========================================
Expand Down Expand Up @@ -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']);
});
});

// ==========================================
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/plugin-dev/src/dev-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>();
const dashboards = new Map<string, any>();
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); },
Comment on lines +239 to 246

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stub’s header comment says it “delegates to IMetadataService when available”, but this implementation is purely an in-memory Map and does not delegate. Either adjust the comment to match behavior, or implement actual delegation (e.g., if the dev kernel has a metadata service, forward getView/listViews/etc. to it).

Copilot uses AI. Check for mistakes.
listViews(object?: string) {
const all = [...views.values()];
Expand Down
31 changes: 9 additions & 22 deletions packages/spec/API_IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 插件)

Expand Down
2 changes: 1 addition & 1 deletion packages/spec/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/api/dispatcher.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading
Loading