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
2 changes: 2 additions & 0 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { NotificationPlugin } from '@objectos/notification';
import { PermissionsPlugin } from '@objectos/permissions';
import { createRealtimePlugin } from '@objectos/realtime';
import { StoragePlugin } from '@objectos/storage';
import { UIPlugin } from '@objectos/ui';
import { WorkflowPlugin } from '@objectos/workflow';
import { resolve } from 'path';

Expand Down Expand Up @@ -77,6 +78,7 @@ export default defineStack({
// Services
new NotificationPlugin(),
new I18nPlugin(),
new UIPlugin(),
// createRealtimePlugin(),

// Example Apps
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"@objectos/permissions": "workspace:*",
"@objectos/realtime": "workspace:*",
"@objectos/storage": "workspace:*",
"@objectos/ui": "workspace:*",
"@objectos/workflow": "workspace:*",
"@objectql/core": "^4.2.0",
"@objectql/driver-mongo": "^4.2.0",
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
},
],
},
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts'
]
};
37 changes: 37 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@objectos/ui",
"version": "0.1.0",
"type": "module",
"license": "AGPL-3.0",
"description": "UI metadata service for ObjectOS — manages view definitions stored in database via ObjectQL",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc --emitDeclarationOnly --declaration",
"test": "jest --forceExit --passWithNoTests",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@objectstack/runtime": "^2.0.4",
"@objectstack/spec": "2.0.4"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^25.2.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"files": [
"dist"
],
"keywords": [
"objectos",
"ui",
"metadata",
"view",
"objectql"
]
}
14 changes: 14 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* UI Plugin — Public API
*
* Export all public interfaces and classes
*/

// Types
export type {
UIServiceConfig,
ViewRecord,
} from './types.js';

// Plugin
export { UIPlugin, getUIAPI } from './plugin.js';
277 changes: 277 additions & 0 deletions packages/ui/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* UI Plugin for ObjectOS
*
* Manages view-related metadata persisted in a database via ObjectQL.
* On init the plugin registers a `sys_view` object in ObjectQL and exposes
* CRUD helpers that other plugins and the Admin Console can call through
* the kernel service registry (`kernel.getService('ui')`).
*
* Architecture reference:
* @objectstack/spec examples/metadata-objectql
*/

import type { Plugin, PluginContext } from '@objectstack/runtime';
import type {
UIServiceConfig,
ViewRecord,
PluginHealthReport,
PluginCapabilityManifest,
PluginSecurityManifest,
PluginStartupResult,
} from './types.js';

/**
* UI Plugin
* Implements the Plugin interface for @objectstack/runtime
*/
export class UIPlugin implements Plugin {
name = '@objectos/ui';
version = '0.1.0';
dependencies: string[] = [];

private context?: PluginContext;
private objectql: any;
private startedAt?: number;
private viewObjectName: string;

constructor(config: UIServiceConfig = {}) {
this.viewObjectName = config.viewObjectName ?? 'sys_view';
}

// ─── Lifecycle ─────────────────────────────────────────────────────────────

/**
* Initialize plugin – register the UI service and define the sys_view object.
*/
init = async (context: PluginContext): Promise<void> => {
this.context = context;
this.startedAt = Date.now();

// Register as "ui" service (CoreServiceName)
context.registerService('ui', this);

// Obtain ObjectQL service for database access
try {
this.objectql = context.getService('objectql') ?? context.getService('data');
} catch {
// ObjectQL might not be available yet; will try again in start()
}
Comment on lines +53 to +58
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

The fallback context.getService('data') is effectively unreachable here: if getService('objectql') throws (likely when only the CoreServiceName data exists), the ?? context.getService('data') expression never runs because control jumps to catch. This will leave this.objectql unset even when the data service is available. Split the lookups (or use hasService) so that a missing objectql service still allows resolving data.

Copilot uses AI. Check for mistakes.

context.logger.info('[UI] Initialized successfully');
};

/**
* Start plugin – ensure ObjectQL is available and register the sys_view object.
*/
async start(context: PluginContext): Promise<void> {
// Re-try ObjectQL lookup if it wasn't available during init
if (!this.objectql) {
try {
this.objectql = context.getService('objectql') ?? context.getService('data');
} catch {
context.logger.warn('[UI] ObjectQL service not available – view persistence disabled');
}
}

if (this.objectql) {
await this.registerViewObject();
}

context.logger.info('[UI] Started successfully');
}

// ─── View CRUD ─────────────────────────────────────────────────────────────

/**
* Save (upsert) a view definition to the database.
*/
async saveView(viewName: string, objectName: string, definition: Record<string, unknown>): Promise<ViewRecord> {
this.ensureObjectQL();

const record: Omit<ViewRecord, '_id'> = {
name: viewName,
object_name: objectName,
label: (definition as any).label ?? viewName,
type: (definition as any).type ?? 'grid',
definition,
is_default: false,
is_public: true,
};
Comment on lines +88 to +99
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

saveView() persists definition as an object, but the registered ObjectQL schema defines definition as Field.textarea(...) (i.e., a string-like field, as used elsewhere for long text). This type mismatch is likely to break inserts/updates against a real ObjectQL backend. Consider serializing the definition (e.g., JSON) on write and parsing on read/list, and reflect that in ViewRecord (or introduce a separate persisted field like definition_json).

Copilot uses AI. Check for mistakes.

const existing = await this.objectql.findOne(this.viewObjectName, {
filters: [['name', '=', viewName]],
});

if (existing) {
return await this.objectql.update(this.viewObjectName, existing._id, record);
}
return await this.objectql.insert(this.viewObjectName, record);
}

/**
* Load a single view definition by name.
*/
async loadView(viewName: string): Promise<ViewRecord | null> {
this.ensureObjectQL();

return await this.objectql.findOne(this.viewObjectName, {
filters: [['name', '=', viewName]],
});
}

/**
* List all views for a given object.
*/
async listViews(objectName: string): Promise<ViewRecord[]> {
this.ensureObjectQL();

return await this.objectql.find(this.viewObjectName, {
filters: [['object_name', '=', objectName]],
sort: [{ field: 'name', order: 'asc' }],
});
}

/**
* Delete a view by name.
*/
async deleteView(viewName: string): Promise<boolean> {
this.ensureObjectQL();

const existing = await this.objectql.findOne(this.viewObjectName, {
filters: [['name', '=', viewName]],
});

if (!existing) return false;

await this.objectql.delete(this.viewObjectName, existing._id);
return true;
}

// ─── Kernel Compliance ─────────────────────────────────────────────────────

/**
* Health check
*/
async healthCheck(): Promise<PluginHealthReport> {
let checkStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
let message = 'UI service operational';

if (!this.objectql) {
checkStatus = 'degraded';
message = 'ObjectQL service not available';
}

return {
status: checkStatus,
timestamp: new Date().toISOString(),
message,
metrics: {
uptime: this.startedAt ? Date.now() - this.startedAt : 0,
},
checks: [
{
name: 'objectql-backend',
status: checkStatus === 'healthy' ? 'passed' : 'warning',
message,
},
],
};
}

/**
* Capability manifest
*/
getManifest(): { capabilities: PluginCapabilityManifest; security: PluginSecurityManifest } {
return {
capabilities: {},
security: {
pluginId: 'ui',
trustLevel: 'trusted',
permissions: { permissions: [], defaultGrant: 'deny' },
sandbox: { enabled: false, level: 'none' },
},
};
}

/**
* Startup result
*/
getStartupResult(): PluginStartupResult {
return {
plugin: { name: this.name, version: this.version },
success: !!this.context,
duration: 0,
};
}

/**
* Cleanup
*/
async destroy(): Promise<void> {
this.objectql = undefined;
this.context?.logger.info('[UI] Destroyed');
}

// ─── Internal ──────────────────────────────────────────────────────────────

/**
* Register the sys_view metadata object in ObjectQL.
*/
private async registerViewObject(): Promise<void> {
if (!this.objectql) return;

// Only attempt if ObjectQL exposes registerObject (engine instance)
if (typeof this.objectql.registerObject !== 'function') return;

try {
const { ObjectSchema, Field } = await import('@objectstack/spec/data');

const SysView = ObjectSchema.create({
name: this.viewObjectName,
label: 'View Metadata',
description: 'Stores UI view definitions',
fields: {
name: Field.text({ label: 'View Name', required: true, unique: true }),
object_name: Field.text({ label: 'Object Name', required: true }),
label: Field.text({ label: 'Label' }),
type: Field.select(['grid', 'kanban', 'calendar', 'timeline', 'gantt'], {
label: 'View Type',
required: true,
}),
definition: Field.textarea({ label: 'View Definition', required: true }),
is_default: Field.boolean({ label: 'Is Default' }),
Comment on lines +236 to +242
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

saveView() accepts any definition.type (and ViewRecord.type is string), but registerViewObject() constrains the persisted type field to a fixed Field.select([...]) list. If callers pass a different view type (or omit type and later migrate), writes can fail at the ObjectQL layer. Either validate/normalize type in saveView() to the allowed set, or loosen the schema/type definition so they stay consistent.

Copilot uses AI. Check for mistakes.
is_public: Field.boolean({ label: 'Is Public' }),
},
indexes: [
{ fields: ['name'], unique: true },
{ fields: ['object_name'], unique: false },
],
});

this.objectql.registerObject(SysView);
this.context?.logger.info(`[UI] Registered object: ${this.viewObjectName}`);
} catch (err) {
this.context?.logger.warn(`[UI] Could not register ${this.viewObjectName}: ${(err as Error).message}`);
}
}

/**
* Guard ensuring ObjectQL is available before data operations.
*/
private ensureObjectQL(): void {
if (!this.objectql) {
throw new Error('[UI] ObjectQL service not available. Cannot perform view operations.');
}
}
}

/**
* Helper to access the UI API from the kernel.
*/
export function getUIAPI(kernel: any): UIPlugin | null {
try {
return kernel.getService('ui');
} catch {
return null;
}
}
Loading
Loading