diff --git a/objectstack.config.ts b/objectstack.config.ts index 9792e2c5..15e67ce0 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -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'; @@ -77,6 +78,7 @@ export default defineStack({ // Services new NotificationPlugin(), new I18nPlugin(), + new UIPlugin(), // createRealtimePlugin(), // Example Apps diff --git a/package.json b/package.json index 533d56b5..849eca6d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui/jest.config.cjs b/packages/ui/jest.config.cjs new file mode 100644 index 00000000..bd0b08ab --- /dev/null +++ b/packages/ui/jest.config.cjs @@ -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: ['/test'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts' + ] +}; diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..eb542bf6 --- /dev/null +++ b/packages/ui/package.json @@ -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" + ] +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 00000000..50d12924 --- /dev/null +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/ui/src/plugin.ts b/packages/ui/src/plugin.ts new file mode 100644 index 00000000..7a8ecf4a --- /dev/null +++ b/packages/ui/src/plugin.ts @@ -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 => { + 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() + } + + context.logger.info('[UI] Initialized successfully'); + }; + + /** + * Start plugin – ensure ObjectQL is available and register the sys_view object. + */ + async start(context: PluginContext): Promise { + // 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): Promise { + this.ensureObjectQL(); + + const record: Omit = { + name: viewName, + object_name: objectName, + label: (definition as any).label ?? viewName, + type: (definition as any).type ?? 'grid', + definition, + is_default: false, + is_public: true, + }; + + 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 { + this.ensureObjectQL(); + + return await this.objectql.findOne(this.viewObjectName, { + filters: [['name', '=', viewName]], + }); + } + + /** + * List all views for a given object. + */ + async listViews(objectName: string): Promise { + 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 { + 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 { + 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 { + this.objectql = undefined; + this.context?.logger.info('[UI] Destroyed'); + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + /** + * Register the sys_view metadata object in ObjectQL. + */ + private async registerViewObject(): Promise { + 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' }), + 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; + } +} diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts new file mode 100644 index 00000000..62a2b7d6 --- /dev/null +++ b/packages/ui/src/types.ts @@ -0,0 +1,69 @@ +/** + * UI Plugin Types + * + * Defines the configuration and record types for the UI metadata service. + * View definitions are persisted in a database via ObjectQL. + */ + +// ─── View Record ─────────────────────────────────────────────────────────────── + +/** + * Database record representing a stored view definition. + */ +export interface ViewRecord { + /** Auto-generated identifier */ + _id?: string; + /** Unique view name (snake_case) */ + name: string; + /** Object this view belongs to */ + object_name: string; + /** Human-readable label */ + label?: string; + /** View type discriminator */ + type: string; + /** Full view definition stored as JSON */ + definition: Record; + /** Whether this is the default view for the object */ + is_default?: boolean; + /** Whether this view is visible to all users */ + is_public?: boolean; +} + +// ─── Plugin Configuration ────────────────────────────────────────────────────── + +/** + * UI Plugin Configuration + */ +export interface UIServiceConfig { + /** Name of the ObjectQL object used to store views (default: 'sys_view') */ + viewObjectName?: string; +} + +// ─── Kernel Compliance Types (from @objectstack/spec) ────────────────────────── + +import type { + PluginHealthStatus, + PluginHealthReport as SpecPluginHealthReport, + PluginCapabilityManifest as SpecPluginCapabilityManifest, + PluginSecurityManifest as SpecPluginSecurityManifest, + PluginStartupResult as SpecPluginStartupResult, + EventBusConfig as SpecEventBusConfig, +} from '@objectstack/spec/kernel'; + +/** Plugin health status — from @objectstack/spec */ +export type HealthStatus = PluginHealthStatus; + +/** Aggregate health report — from @objectstack/spec */ +export type PluginHealthReport = SpecPluginHealthReport; + +/** Plugin capability manifest — from @objectstack/spec */ +export type PluginCapabilityManifest = SpecPluginCapabilityManifest; + +/** Plugin security manifest — from @objectstack/spec */ +export type PluginSecurityManifest = SpecPluginSecurityManifest; + +/** Plugin startup result — from @objectstack/spec */ +export type PluginStartupResult = SpecPluginStartupResult; + +/** Event bus configuration — from @objectstack/spec */ +export type EventBusConfig = SpecEventBusConfig; diff --git a/packages/ui/test/plugin.test.ts b/packages/ui/test/plugin.test.ts new file mode 100644 index 00000000..e8f2daca --- /dev/null +++ b/packages/ui/test/plugin.test.ts @@ -0,0 +1,359 @@ +/** + * Tests for UI Plugin + */ + +import { UIPlugin, getUIAPI } from '../src/index.js'; +import type { PluginContext } from '@objectstack/runtime'; + +// ─── Mock helpers ────────────────────────────────────────────────────────────── + +/** In-memory store used by the mock ObjectQL service */ +function createInMemoryObjectQL() { + const collections = new Map>(); + let idCounter = 0; + + const ensureCollection = (name: string) => { + if (!collections.has(name)) collections.set(name, new Map()); + return collections.get(name)!; + }; + + return { + registerObject: jest.fn(), + + async insert(objectName: string, record: any) { + const col = ensureCollection(objectName); + const id = `id-${++idCounter}`; + const doc = { ...record, _id: id }; + col.set(id, doc); + return doc; + }, + + async update(objectName: string, id: string, data: any) { + const col = ensureCollection(objectName); + const existing = col.get(id); + if (!existing) throw new Error('Not found'); + const updated = { ...existing, ...data, _id: id }; + col.set(id, updated); + return updated; + }, + + async delete(objectName: string, id: string) { + const col = ensureCollection(objectName); + col.delete(id); + return { _id: id }; + }, + + async findOne(objectName: string, options: any) { + const col = ensureCollection(objectName); + const filters: any[][] = options?.filters ?? []; + + for (const doc of col.values()) { + let match = true; + for (const [field, op, value] of filters) { + if (op === '=' && doc[field] !== value) match = false; + } + if (match) return doc; + } + return null; + }, + + async find(objectName: string, options: any) { + const col = ensureCollection(objectName); + const filters: any[][] = options?.filters ?? []; + const results: any[] = []; + + for (const doc of col.values()) { + let match = true; + for (const [field, op, value] of filters) { + if (op === '=' && doc[field] !== value) match = false; + } + if (match) results.push(doc); + } + + const sort = options?.sort; + if (sort && sort.length > 0) { + const { field, order } = sort[0]; + results.sort((a, b) => { + const cmp = String(a[field] ?? '').localeCompare(String(b[field] ?? '')); + return order === 'desc' ? -cmp : cmp; + }); + } + + return results; + }, + }; +} + +function createMockContext(objectql?: ReturnType): { + context: PluginContext; + kernel: any; +} { + const kernel = { + getService: jest.fn(), + services: new Map(), + }; + + if (objectql) { + kernel.services.set('objectql', objectql); + } + + const context: PluginContext = { + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + registerService: jest.fn((name: string, service: any) => { + kernel.services.set(name, service); + kernel.getService.mockImplementation((n: string) => { + if (kernel.services.has(n)) return kernel.services.get(n); + throw new Error(`Service ${n} not found`); + }); + }), + getService: jest.fn((name: string) => { + if (kernel.services.has(name)) return kernel.services.get(name); + throw new Error(`Service ${name} not found`); + }), + hasService: jest.fn((name: string) => kernel.services.has(name)), + getServices: jest.fn(() => kernel.services), + hook: jest.fn(), + trigger: jest.fn(), + getKernel: jest.fn(() => kernel), + } as any; + + return { context, kernel }; +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('UI Plugin', () => { + let plugin: UIPlugin; + let objectql: ReturnType; + let mockContext: PluginContext; + let mockKernel: any; + + beforeEach(async () => { + objectql = createInMemoryObjectQL(); + const mock = createMockContext(objectql); + mockContext = mock.context; + mockKernel = mock.kernel; + + plugin = new UIPlugin(); + await plugin.init(mockContext); + await plugin.start(mockContext); + }); + + afterEach(async () => { + await plugin.destroy(); + }); + + // ─── Plugin Metadata ─────────────────────────────────────────────────────── + + describe('Plugin Metadata', () => { + it('should have correct plugin metadata', () => { + expect(plugin.name).toBe('@objectos/ui'); + expect(plugin.version).toBe('0.1.0'); + expect(plugin.dependencies).toEqual([]); + }); + }); + + // ─── Plugin Lifecycle ────────────────────────────────────────────────────── + + describe('Plugin Lifecycle', () => { + it('should initialize and register the ui service', async () => { + expect(mockContext.registerService).toHaveBeenCalledWith('ui', plugin); + expect(mockContext.logger.info).toHaveBeenCalledWith( + expect.stringContaining('Initialized successfully'), + ); + }); + + it('should start successfully', async () => { + expect(mockContext.logger.info).toHaveBeenCalledWith( + expect.stringContaining('Started successfully'), + ); + }); + + it('should destroy successfully', async () => { + await plugin.destroy(); + expect(mockContext.logger.info).toHaveBeenCalledWith( + expect.stringContaining('Destroyed'), + ); + }); + }); + + // ─── View CRUD ───────────────────────────────────────────────────────────── + + describe('View CRUD', () => { + it('should save a new view definition', async () => { + const def = { type: 'grid', columns: ['name', 'status'] }; + const result = await plugin.saveView('test_list', 'account', def); + + expect(result).toBeDefined(); + expect(result._id).toBeDefined(); + expect(result.name).toBe('test_list'); + expect(result.object_name).toBe('account'); + expect(result.definition).toEqual(def); + }); + + it('should update an existing view definition', async () => { + const def1 = { type: 'grid', columns: ['name'] }; + await plugin.saveView('update_view', 'account', def1); + + const def2 = { type: 'grid', columns: ['name', 'status', 'owner'] }; + const updated = await plugin.saveView('update_view', 'account', def2); + + expect(updated.definition).toEqual(def2); + }); + + it('should load a view by name', async () => { + const def = { type: 'kanban', columns: ['name'] }; + await plugin.saveView('kanban_view', 'opportunity', def); + + const loaded = await plugin.loadView('kanban_view'); + expect(loaded).toBeDefined(); + expect(loaded!.name).toBe('kanban_view'); + expect(loaded!.definition).toEqual(def); + }); + + it('should return null for non-existent view', async () => { + const loaded = await plugin.loadView('does_not_exist'); + expect(loaded).toBeNull(); + }); + + it('should list views for an object', async () => { + await plugin.saveView('view_a', 'contact', { type: 'grid', columns: ['name'] }); + await plugin.saveView('view_b', 'contact', { type: 'kanban', columns: ['name'] }); + await plugin.saveView('other_view', 'account', { type: 'grid', columns: ['name'] }); + + const views = await plugin.listViews('contact'); + expect(views).toHaveLength(2); + // sorted by name ascending + expect(views[0].name).toBe('view_a'); + expect(views[1].name).toBe('view_b'); + }); + + it('should delete a view', async () => { + await plugin.saveView('to_delete', 'account', { type: 'grid', columns: [] }); + const deleted = await plugin.deleteView('to_delete'); + expect(deleted).toBe(true); + + const loaded = await plugin.loadView('to_delete'); + expect(loaded).toBeNull(); + }); + + it('should return false when deleting non-existent view', async () => { + const deleted = await plugin.deleteView('nope'); + expect(deleted).toBe(false); + }); + + it('should preserve complex nested definition', async () => { + const def = { + type: 'kanban', + columns: ['name', 'amount'], + kanban: { groupByField: 'stage', summarizeField: 'amount', columns: ['name'] }, + pagination: { pageSize: 50, pageSizeOptions: [25, 50, 100] }, + virtualScroll: true, + }; + + await plugin.saveView('complex_view', 'opportunity', def); + const loaded = await plugin.loadView('complex_view'); + + expect(loaded!.definition).toEqual(def); + expect((loaded!.definition as any).kanban.groupByField).toBe('stage'); + expect((loaded!.definition as any).pagination.pageSize).toBe(50); + expect((loaded!.definition as any).virtualScroll).toBe(true); + }); + }); + + // ─── Error handling ──────────────────────────────────────────────────────── + + describe('Error handling', () => { + it('should throw if ObjectQL not available', async () => { + const noOqlPlugin = new UIPlugin(); + const { context } = createMockContext(); // no objectql + await noOqlPlugin.init(context); + + await expect(noOqlPlugin.saveView('x', 'y', {})).rejects.toThrow( + 'ObjectQL service not available', + ); + }); + }); +}); + +// ─── Kernel Compliance ───────────────────────────────────────────────────────── + +describe('Kernel Compliance', () => { + let plugin: UIPlugin; + let context: PluginContext; + + beforeEach(async () => { + const objectql = createInMemoryObjectQL(); + const mock = createMockContext(objectql); + context = mock.context; + plugin = new UIPlugin(); + await plugin.init(context); + }); + + afterEach(async () => { + await plugin.destroy(); + }); + + describe('healthCheck()', () => { + it('should return healthy when ObjectQL is available', async () => { + const report = await plugin.healthCheck(); + expect(report.status).toBe('healthy'); + expect(report.metrics?.uptime).toBeGreaterThanOrEqual(0); + expect(report.checks).toHaveLength(1); + expect(report.checks![0].name).toBe('objectql-backend'); + }); + + it('should return degraded when ObjectQL is not available', async () => { + const noOqlPlugin = new UIPlugin(); + const { context: ctx } = createMockContext(); + await noOqlPlugin.init(ctx); + + const report = await noOqlPlugin.healthCheck(); + expect(report.status).toBe('degraded'); + }); + }); + + describe('getManifest()', () => { + it('should return capability and security manifests', () => { + const manifest = plugin.getManifest(); + expect(manifest.capabilities).toBeDefined(); + expect(manifest.security).toBeDefined(); + expect(manifest.security.pluginId).toBe('ui'); + }); + }); + + describe('getStartupResult()', () => { + it('should return successful startup result', () => { + const result = plugin.getStartupResult(); + expect(result.plugin.name).toBe('@objectos/ui'); + expect(result.success).toBe(true); + }); + }); +}); + +// ─── Helper ──────────────────────────────────────────────────────────────────── + +describe('getUIAPI helper', () => { + it('should return the UI service from kernel', async () => { + const objectql = createInMemoryObjectQL(); + const { context, kernel } = createMockContext(objectql); + const plugin = new UIPlugin(); + await plugin.init(context); + + const api = getUIAPI(kernel); + expect(api).toBe(plugin); + }); + + it('should return null when UI service is not registered', () => { + const kernel = { + getService: jest.fn(() => { throw new Error('not found'); }), + }; + const api = getUIAPI(kernel); + expect(api).toBeNull(); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 00000000..5f587110 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45f2f305..6118241b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@objectos/storage': specifier: workspace:* version: link:packages/storage + '@objectos/ui': + specifier: workspace:* + version: link:packages/ui '@objectos/workflow': specifier: workspace:* version: link:packages/workflow @@ -774,6 +777,34 @@ importers: specifier: ^5.4.2 version: 5.9.2 + packages/ui: + dependencies: + '@objectstack/runtime': + specifier: ^2.0.4 + version: 2.0.4(pino@10.3.0) + '@objectstack/spec': + specifier: 2.0.4 + version: 2.0.4 + devDependencies: + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^25.2.0 + version: 25.2.0 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@25.2.0)(ts-node@10.9.2(@types/node@25.2.0)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.2.0)(ts-node@10.9.2(@types/node@25.2.0)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/workflow: dependencies: '@objectql/core':